mirror of
https://github.com/morluto/rea
synced 2026-10-28 06:46:47 -04:00
6395e8d300
Group repeated session tool availability and provider capability facts, preserve complete named contracts and ordered limitations, and remove duplicate catalog descriptors. Update consumers and migration guidance. Validated by all 31 CI jobs, focused source checks, and actual Claude Code/Linux Hopper/native Windows Ghidra small-target workflows on the current integration tree. Refs #723. BREAKING CHANGE: binary_session no longer includes server_identity.catalog.tools; tool_availability and provider capabilities use grouped facts documented in docs/mcp-contracts.md. Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
1182 lines
41 KiB
TypeScript
1182 lines
41 KiB
TypeScript
import type {
|
|
AnalysisClient,
|
|
AnalysisProvider,
|
|
} from "../../../src/application/AnalysisProvider.js";
|
|
import type { BinarySession } from "../../../src/application/binary/BinarySession.js";
|
|
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
|
|
import type { JsonValue } from "../../../src/domain/jsonValue.js";
|
|
import type { RecordUnknownInput } from "../../../src/domain/residualUnknown.js";
|
|
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
|
|
import { HopperStartError } from "../../../src/domain/hopperErrors.js";
|
|
import { ProviderAdapterError } from "../../../src/domain/providerAdapterError.js";
|
|
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
|
|
import { err, ok as resultOk } from "../../../src/domain/result.js";
|
|
import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
|
|
import { createEvidence } from "../../../src/domain/evidence.js";
|
|
import { observed as ok } from "../../fixtures/analysisExecution.js";
|
|
import {
|
|
ControllableAnalysisClient,
|
|
createBinarySessionTargets,
|
|
createCacheProvider,
|
|
createDeferred,
|
|
createTestBinarySession,
|
|
} from "../../fixtures/binarySession.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { copyFile, writeFile } from "node:fs/promises";
|
|
import { extname, join } from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
const client = (fail = false): AnalysisClient => ({
|
|
execute: () => Promise.resolve(fail ? err(new HopperStartError()) : ok(null)),
|
|
close: () => Promise.resolve(resultOk(null)),
|
|
});
|
|
|
|
/** Open a target and run one address_name query, asserting both succeed. */
|
|
const openAndExecuteAddressName = async (
|
|
session: BinarySession,
|
|
target: string,
|
|
parameters: Readonly<Record<string, JsonValue>>,
|
|
): Promise<void> => {
|
|
expect((await session.open(target)).ok).toBe(true);
|
|
expect((await session.execute("address_name", parameters)).ok).toBe(true);
|
|
};
|
|
|
|
/** Shared network-reachability unknown input at one severity. */
|
|
const networkReachabilityUnknown = (
|
|
severity: "medium" | "high",
|
|
): RecordUnknownInput => ({
|
|
question: "Does this function reach the network?",
|
|
severity,
|
|
domain: "network",
|
|
supporting_evidence_ids: [],
|
|
contradicting_evidence_ids: [],
|
|
required_authority: "shipped-artifact",
|
|
required_confidence: "observed",
|
|
required_environment: null,
|
|
recommended_probes: [
|
|
{ operation: "analyze_function", rationale: "Inspect its callers." },
|
|
],
|
|
relationships: [],
|
|
});
|
|
|
|
/** Record the shared network-reachability unknown, asserting success. */
|
|
const recordNetworkReachabilityUnknown = (
|
|
session: BinarySession,
|
|
severity: "medium" | "high",
|
|
): void => {
|
|
expect(session.recordUnknown(networkReachabilityUnknown(severity)).ok).toBe(
|
|
true,
|
|
);
|
|
};
|
|
|
|
describe("detached provider and target metadata", () => {
|
|
it("returns detached provider and target metadata", async () => {
|
|
const [first] = await createBinarySessionTargets();
|
|
const session = createTestBinarySession(createCacheProvider([]));
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
expect(session.status()).toMatchObject({
|
|
analysis_run: {
|
|
run_id: expect.any(String),
|
|
process_lineage: { status: "not_observed" },
|
|
},
|
|
});
|
|
expect(session.listUnknowns()).toEqual([]);
|
|
const identity = session.providerIdentity();
|
|
Reflect.set(identity, "id", "forged");
|
|
expect(session.providerIdentity().id).toBe("fixture");
|
|
|
|
const active = session.activeTarget();
|
|
expect(active).toBeDefined();
|
|
if (active !== undefined) Reflect.set(active, "path", "/tmp/forged");
|
|
expect(session.activeTarget()?.path).toBe(first);
|
|
|
|
await session.close();
|
|
});
|
|
|
|
it("exposes provider operational health in session status", async () => {
|
|
const [first] = await createBinarySessionTargets();
|
|
let healthState: "idle" | "busy" | "exited" = "busy";
|
|
const provider: AnalysisProvider = {
|
|
identity: () => ({ id: "fixture", name: "Fixture", version: "1" }),
|
|
capabilities: () => [],
|
|
createClient: () => ({
|
|
execute: () => Promise.resolve(ok("ok")),
|
|
operationHealthSnapshot: () => ({
|
|
state: healthState,
|
|
stage: "analysis",
|
|
retryAction: healthState === "exited" ? "restart_provider" : "wait",
|
|
exitCode: healthState === "exited" ? 7 : null,
|
|
requests: [
|
|
{
|
|
requestId: 1,
|
|
operation: "find_code_for_string",
|
|
stage: "analysis",
|
|
},
|
|
],
|
|
}),
|
|
close: () => Promise.resolve(resultOk(null)),
|
|
}),
|
|
};
|
|
const session = createTestBinarySession(provider);
|
|
expect(session.status()).toMatchObject({
|
|
open: false,
|
|
provider_operation_health: null,
|
|
});
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
expect(session.status()).toMatchObject({
|
|
open: true,
|
|
provider_operation_health: {
|
|
state: "busy",
|
|
stage: "analysis",
|
|
retry_action: "wait",
|
|
exit_code: null,
|
|
requests: [
|
|
{
|
|
request_id: 1,
|
|
operation: "find_code_for_string",
|
|
stage: "analysis",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
healthState = "exited";
|
|
expect(session.status()).toMatchObject({
|
|
open: true,
|
|
provider_operation_health: {
|
|
state: "exited",
|
|
stage: "analysis",
|
|
retry_action: "restart_provider",
|
|
exit_code: 7,
|
|
requests: [
|
|
{
|
|
request_id: 1,
|
|
operation: "find_code_for_string",
|
|
stage: "analysis",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
await session.close();
|
|
expect(session.status()).toMatchObject({
|
|
open: false,
|
|
provider_operation_health: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("opening previewed targets", () => {
|
|
it("rechecks the active target after another serialized transition", async () => {
|
|
const [first, second] = await createBinarySessionTargets();
|
|
const calls: string[] = [];
|
|
const session = createTestBinarySession(createCacheProvider(calls));
|
|
try {
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
const target = session.activeTarget();
|
|
if (target === undefined) throw new Error("Expected an active target");
|
|
const preview = await session.previewTarget(target);
|
|
if (!preview.ok) throw preview.error;
|
|
expect(preview.value.sameTarget).toBe(true);
|
|
expect((await session.open(second)).ok).toBe(true);
|
|
expect((await session.openResolvedTarget(preview.value)).ok).toBe(true);
|
|
expect(session.activeTarget()?.path).toBe(first);
|
|
expect(calls).toEqual([]);
|
|
} finally {
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
it("rejects cancellation and a staged snapshot changed after preview without startup", async () => {
|
|
const [first, second] = await createBinarySessionTargets();
|
|
const calls: string[] = [];
|
|
const session = createTestBinarySession(createCacheProvider(calls));
|
|
const other = createTestBinarySession(createCacheProvider([]));
|
|
try {
|
|
const parsed = await parseBinaryTarget(first);
|
|
if (!parsed.ok) throw parsed.error;
|
|
const preview = await session.previewTarget(parsed.value);
|
|
if (!preview.ok) throw preview.error;
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
const cancelled = await session.openResolvedTarget(preview.value, {
|
|
signal: controller.signal,
|
|
});
|
|
expect(cancelled).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError" },
|
|
});
|
|
|
|
expect((await other.open(second)).ok).toBe(true);
|
|
const snapshot = other.exportAnalysisSnapshot();
|
|
if (!snapshot.ok) throw snapshot.error;
|
|
expect(session.importAnalysisSnapshot(snapshot.value).ok).toBe(true);
|
|
const rejected = await session.openResolvedTarget(preview.value);
|
|
expect(rejected).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "EvidenceIntegrityError" },
|
|
});
|
|
expect(calls).toEqual([]);
|
|
expect(session.activeTarget()).toBeUndefined();
|
|
} finally {
|
|
await session.close();
|
|
await other.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("fresh run identity", () => {
|
|
it("allocates one fresh run identity per provider client lifetime", async () => {
|
|
const [first, second] = await createBinarySessionTargets();
|
|
const provider = createCacheProvider([]);
|
|
const createClient = provider.createClient.bind(provider);
|
|
const runIds: string[] = [];
|
|
provider.createClient = (target, profile, context) => {
|
|
if (context === undefined)
|
|
throw new Error("missing analysis run context");
|
|
runIds.push(context.runId);
|
|
return createClient(target, profile, context);
|
|
};
|
|
const session = createTestBinarySession(provider);
|
|
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
expect(
|
|
(
|
|
await session.execute("address_name", {
|
|
address: "0x1000",
|
|
document: "first",
|
|
})
|
|
).ok,
|
|
).toBe(true);
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
expect((await session.open(second)).ok).toBe(true);
|
|
expect(
|
|
(
|
|
await session.execute("address_name", {
|
|
address: "0x1000",
|
|
document: "second",
|
|
})
|
|
).ok,
|
|
).toBe(true);
|
|
|
|
expect(runIds).toHaveLength(2);
|
|
expect(new Set(runIds).size).toBe(2);
|
|
await session.close();
|
|
});
|
|
});
|
|
|
|
describe("evidence metadata imports and snapshot cache", () => {
|
|
it("retains cached queries and exports after additive evidence imports", async () => {
|
|
const [target] = await createBinarySessionTargets();
|
|
const calls: string[] = [];
|
|
const session = createTestBinarySession(createCacheProvider(calls));
|
|
expect((await session.open(target)).ok).toBe(true);
|
|
expect(
|
|
(
|
|
await session.execute("address_name", {
|
|
address: "0x1000",
|
|
document: "first",
|
|
})
|
|
).ok,
|
|
).toBe(true);
|
|
const before = session.exportAnalysisSnapshot();
|
|
if (!before.ok) throw before.error;
|
|
const existing = session.exportEvidenceBundle().records[0];
|
|
if (existing === undefined || existing.subject === null)
|
|
throw new Error("Expected artifact-bound Evidence");
|
|
const addition = createEvidence(
|
|
{
|
|
path: existing.subject.local_path,
|
|
sha256: existing.subject.digest.sha256,
|
|
format: existing.subject.format,
|
|
...(existing.subject.architecture === null
|
|
? {}
|
|
: { architecture: existing.subject.architecture }),
|
|
},
|
|
{ id: "fixture", name: "Fixture", version: "1" },
|
|
{
|
|
predicateType: "rea.analysis",
|
|
operation: "health",
|
|
parameters: { source: "additive-import" },
|
|
result: true,
|
|
},
|
|
);
|
|
expect(
|
|
session.importEvidenceBundle(createEvidenceBundle([addition])),
|
|
).toEqual({ ok: true, value: 1 });
|
|
expect(
|
|
session.importEvidenceBundle(createEvidenceBundle([addition])),
|
|
).toEqual({ ok: true, value: 0 });
|
|
expect(session.exportAnalysisSnapshot()).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
entries: before.value.entries,
|
|
evidence_bundle: { records: expect.arrayContaining([addition]) },
|
|
},
|
|
});
|
|
const callsAfterInitialRead = [...calls];
|
|
expect(
|
|
(
|
|
await session.execute("address_name", {
|
|
address: "0x1000",
|
|
document: "first",
|
|
})
|
|
).ok,
|
|
).toBe(true);
|
|
expect(calls).toEqual(callsAfterInitialRead);
|
|
expect(session.exportAnalysisSnapshot().ok).toBe(true);
|
|
await session.close();
|
|
});
|
|
|
|
it("keeps changed evidence metadata out of snapshots until the session is recreated", async () => {
|
|
const [target] = await createBinarySessionTargets();
|
|
const calls: string[] = [];
|
|
const session = createTestBinarySession(createCacheProvider(calls));
|
|
expect((await session.open(target)).ok).toBe(true);
|
|
expect(
|
|
(
|
|
await session.execute("address_name", {
|
|
address: "0x1000",
|
|
document: "first",
|
|
})
|
|
).ok,
|
|
).toBe(true);
|
|
const baseline = session.exportAnalysisSnapshot();
|
|
if (!baseline.ok) throw baseline.error;
|
|
|
|
const evidence = session
|
|
.exportEvidenceBundle()
|
|
.records.find(({ operation }) => operation === "address_name");
|
|
if (evidence === undefined || evidence.subject === null)
|
|
throw new Error("Expected artifact-bound Evidence");
|
|
const callsAfterInitialRead = [...calls];
|
|
|
|
const relocated = {
|
|
...evidence,
|
|
subject: {
|
|
...evidence.subject,
|
|
local_path: `${evidence.subject.local_path}.moved`,
|
|
name: `${evidence.subject.name}.moved`,
|
|
},
|
|
};
|
|
expect(
|
|
session.importEvidenceBundle(createEvidenceBundle([relocated])),
|
|
).toEqual({ ok: true, value: 0 });
|
|
expect(session.exportEvidenceBundle().records).toContainEqual(relocated);
|
|
expect(
|
|
(
|
|
await session.execute("address_name", {
|
|
address: "0x1000",
|
|
document: "first",
|
|
})
|
|
).ok,
|
|
).toBe(true);
|
|
expect(calls).toHaveLength(callsAfterInitialRead.length + 1);
|
|
expect(session.exportAnalysisSnapshot()).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
message: expect.stringContaining(
|
|
"Analysis snapshots are unavailable after analysis metadata mutations",
|
|
),
|
|
},
|
|
});
|
|
expect((await session.open(target)).ok).toBe(true);
|
|
expect(session.exportAnalysisSnapshot().ok).toBe(false);
|
|
expect(session.importAnalysisSnapshot(baseline.value)).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
userMessage: expect.stringContaining(
|
|
"Close the active target without saving a snapshot",
|
|
),
|
|
},
|
|
});
|
|
expect(session.exportEvidenceBundle().records).toContainEqual(relocated);
|
|
const callsAfterInvalidation = calls.length;
|
|
expect(
|
|
(
|
|
await session.execute("address_name", {
|
|
address: "0x1000",
|
|
document: "first",
|
|
})
|
|
).ok,
|
|
).toBe(true);
|
|
expect(calls).toHaveLength(callsAfterInvalidation + 1);
|
|
await session.close();
|
|
expect((await session.open(target, { snapshot: baseline.value })).ok).toBe(
|
|
true,
|
|
);
|
|
expect(session.exportAnalysisSnapshot().ok).toBe(true);
|
|
await session.close();
|
|
});
|
|
});
|
|
|
|
describe("replay of exact immutable calls", () => {
|
|
it("replays exact immutable calls from a matching provider-neutral snapshot", async () => {
|
|
const [first, second] = await createBinarySessionTargets();
|
|
const initialCalls: string[] = [];
|
|
const initial = createTestBinarySession(createCacheProvider(initialCalls));
|
|
expect((await initial.open(first)).ok).toBe(true);
|
|
expect(
|
|
(
|
|
await initial.execute("address_name", {
|
|
address: "0x1000",
|
|
document: "first",
|
|
})
|
|
).ok,
|
|
).toBe(true);
|
|
const snapshot = initial.exportAnalysisSnapshot();
|
|
if (!snapshot.ok) throw snapshot.error;
|
|
expect(snapshot.value.entries).toHaveLength(1);
|
|
await initial.close();
|
|
|
|
const replayCalls: string[] = [];
|
|
const replay = createTestBinarySession(createCacheProvider(replayCalls));
|
|
expect(replay.importAnalysisSnapshot(snapshot.value)).toEqual({
|
|
ok: true,
|
|
value: 1,
|
|
});
|
|
expect((await replay.open(first)).ok).toBe(true);
|
|
const cached = await replay.execute("address_name", {
|
|
address: "0x1000",
|
|
document: "first",
|
|
});
|
|
expect(cached.ok).toBe(true);
|
|
if (cached.ok)
|
|
expect(cached.value.limitations).toContainEqual(
|
|
expect.stringContaining("local REA analysis snapshot"),
|
|
);
|
|
expect(
|
|
(
|
|
await replay.execute("set_address_name", {
|
|
address: "0x1000",
|
|
name: "renamed",
|
|
document: "first",
|
|
})
|
|
).ok,
|
|
).toBe(true);
|
|
expect(
|
|
(
|
|
await replay.execute("address_name", {
|
|
address: "0x1000",
|
|
document: "first",
|
|
})
|
|
).ok,
|
|
).toBe(true);
|
|
expect(replayCalls).toEqual(["set_address_name", "address_name"]);
|
|
expect(replay.exportAnalysisSnapshot().ok).toBe(false);
|
|
await replay.close();
|
|
|
|
const mismatch = createTestBinarySession(createCacheProvider([]));
|
|
expect(mismatch.importAnalysisSnapshot(snapshot.value).ok).toBe(true);
|
|
const opened = await mismatch.open(second);
|
|
expect(opened.ok).toBe(false);
|
|
if (!opened.ok) expect(opened.error._tag).toBe("EvidenceIntegrityError");
|
|
await mismatch.close();
|
|
|
|
const profileMismatchCalls: string[] = [];
|
|
const profileMismatchProvider = createCacheProvider(profileMismatchCalls);
|
|
const identity = profileMismatchProvider.identity();
|
|
profileMismatchProvider.resolveAnalysisProfile = () =>
|
|
Promise.resolve(
|
|
resultOk({
|
|
profile: createAnalysisProfile(
|
|
{
|
|
id: identity.id,
|
|
name: identity.name,
|
|
version: identity.version ?? "fixture-unresolved",
|
|
},
|
|
{ fixture: "different-profile" },
|
|
),
|
|
}),
|
|
);
|
|
const profileMismatch = createTestBinarySession(profileMismatchProvider);
|
|
expect(profileMismatch.importAnalysisSnapshot(snapshot.value).ok).toBe(
|
|
true,
|
|
);
|
|
const profileOpened = await profileMismatch.open(first);
|
|
expect(profileOpened.ok).toBe(false);
|
|
if (!profileOpened.ok) {
|
|
expect(profileOpened.error._tag).toBe("EvidenceIntegrityError");
|
|
expect(profileOpened.error.message).toContain("profile_mismatch");
|
|
}
|
|
expect(profileMismatchCalls).toEqual([]);
|
|
await profileMismatch.close();
|
|
|
|
const activeProfileMismatch = createTestBinarySession(
|
|
profileMismatchProvider,
|
|
);
|
|
expect((await activeProfileMismatch.open(first)).ok).toBe(true);
|
|
const activeImport = activeProfileMismatch.importAnalysisSnapshot(
|
|
snapshot.value,
|
|
);
|
|
expect(activeImport.ok).toBe(false);
|
|
if (!activeImport.ok)
|
|
expect(activeImport.error.message).toContain("profile_mismatch");
|
|
await activeProfileMismatch.close();
|
|
});
|
|
});
|
|
|
|
describe("snapshot switch rollback", () => {
|
|
it("retains the previous cache when a target switch snapshot conflicts", async () => {
|
|
const [first] = await createBinarySessionTargets();
|
|
const directory = await createTestTempDirectory("rea-session-copy-");
|
|
const second = join(directory, `second${extname(first)}`);
|
|
await copyFile(first, second);
|
|
const calls: string[] = [];
|
|
const session = createTestBinarySession(createCacheProvider(calls));
|
|
await openAndExecuteAddressName(session, first, {
|
|
address: "0x1000",
|
|
document: "first",
|
|
});
|
|
recordNetworkReachabilityUnknown(session, "medium");
|
|
const originalCache = session.exportAnalysisSnapshot();
|
|
expect(originalCache).toMatchObject({
|
|
ok: true,
|
|
value: { entries: [{ operation: "address_name" }] },
|
|
});
|
|
|
|
const conflicting = createTestBinarySession(createCacheProvider([]));
|
|
expect((await conflicting.open(second)).ok).toBe(true);
|
|
recordNetworkReachabilityUnknown(conflicting, "high");
|
|
const conflictingSnapshot = conflicting.exportAnalysisSnapshot();
|
|
expect(conflictingSnapshot.ok).toBe(true);
|
|
if (!conflictingSnapshot.ok || !originalCache.ok) return;
|
|
await conflicting.close();
|
|
|
|
expect(
|
|
await session.open(second, { snapshot: conflictingSnapshot.value }),
|
|
).toMatchObject({ ok: false });
|
|
expect(session.activeTarget()?.path).toBe(first);
|
|
expect(
|
|
(
|
|
await session.execute("address_name", {
|
|
address: "0x1000",
|
|
document: "first",
|
|
})
|
|
).ok,
|
|
).toBe(true);
|
|
expect(calls).toEqual(["address_name"]);
|
|
await session.close();
|
|
});
|
|
});
|
|
|
|
describe("snapshot cache eligibility", () => {
|
|
it("does not snapshot reads that depend on the provider cursor", async () => {
|
|
const [first] = await createBinarySessionTargets();
|
|
const calls: string[] = [];
|
|
const session = createTestBinarySession(createCacheProvider(calls));
|
|
await openAndExecuteAddressName(session, first, {});
|
|
expect((await session.execute("address_name", {})).ok).toBe(true);
|
|
expect(calls).toEqual(["address_name", "address_name"]);
|
|
expect(session.exportAnalysisSnapshot()).toMatchObject({
|
|
ok: true,
|
|
value: { entries: [] },
|
|
});
|
|
await session.close();
|
|
});
|
|
});
|
|
|
|
describe("provider health and availability", () => {
|
|
it("publishes provider-health availability changes and resets on target switch", async () => {
|
|
const [first, second] = await createBinarySessionTargets();
|
|
const provider = createCacheProvider([]);
|
|
provider.createClient = () => ({
|
|
execute: (operation) =>
|
|
Promise.resolve(
|
|
operation === "health"
|
|
? ok(null)
|
|
: err(new ProviderAdapterError("fixture", operation)),
|
|
),
|
|
close: () => Promise.resolve(resultOk(null)),
|
|
});
|
|
const session = createTestBinarySession(provider);
|
|
let changes = 0;
|
|
session.onAvailabilityChanged(() => {
|
|
changes += 1;
|
|
});
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
expect((await session.execute("address_name", {})).ok).toBe(false);
|
|
expect(session.status()).toMatchObject({
|
|
capabilities: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
provider: {
|
|
id: "fixture",
|
|
name: "Fixture analysis provider",
|
|
version: "1",
|
|
},
|
|
groups: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
operations: expect.arrayContaining(["address_name"]),
|
|
available: false,
|
|
reason:
|
|
"ProviderAdapterError: Provider fixture adapter failed during address_name",
|
|
}),
|
|
]),
|
|
}),
|
|
]),
|
|
});
|
|
expect(changes).toBe(1);
|
|
expect((await session.open(second)).ok).toBe(true);
|
|
expect(session.status()).toMatchObject({
|
|
capabilities: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
provider: {
|
|
id: "fixture",
|
|
name: "Fixture analysis provider",
|
|
version: "1",
|
|
},
|
|
groups: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
operations: expect.arrayContaining(["address_name"]),
|
|
available: true,
|
|
reason: null,
|
|
}),
|
|
]),
|
|
}),
|
|
]),
|
|
});
|
|
expect(changes).toBe(2);
|
|
});
|
|
|
|
it("isolates availability observers from execution results and session state", async () => {
|
|
const [first] = await createBinarySessionTargets();
|
|
const provider = createCacheProvider([]);
|
|
let providerCalls = 0;
|
|
provider.createClient = () => ({
|
|
execute: (operation) => {
|
|
if (operation === "health") return Promise.resolve(ok(null));
|
|
providerCalls += 1;
|
|
return Promise.resolve(
|
|
providerCalls === 1
|
|
? err(new ProviderAdapterError("fixture", operation))
|
|
: ok(operation),
|
|
);
|
|
},
|
|
close: () => Promise.resolve(resultOk(null)),
|
|
});
|
|
const session = createTestBinarySession(provider);
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
const input = { address: "0x1000", document: "first" };
|
|
expect((await session.execute("address_name", input)).ok).toBe(false);
|
|
|
|
session.onAvailabilityChanged(() => {
|
|
throw new Error("external observer failed");
|
|
});
|
|
session.onAvailabilityChanged(() =>
|
|
Promise.reject(new Error("async external observer failed")),
|
|
);
|
|
let delivered = 0;
|
|
session.onAvailabilityChanged(() => {
|
|
delivered += 1;
|
|
});
|
|
|
|
await expect(session.execute("address_name", input)).resolves.toMatchObject(
|
|
{ ok: true },
|
|
);
|
|
expect(delivered).toBe(1);
|
|
expect(session.status()).toMatchObject({
|
|
capabilities: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
provider: {
|
|
id: "fixture",
|
|
name: "Fixture analysis provider",
|
|
version: "1",
|
|
},
|
|
groups: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
operations: expect.arrayContaining(["address_name"]),
|
|
available: true,
|
|
reason: null,
|
|
}),
|
|
]),
|
|
}),
|
|
]),
|
|
});
|
|
expect((await session.execute("address_name", input)).ok).toBe(true);
|
|
expect(providerCalls).toBe(2);
|
|
});
|
|
|
|
it("does not replay operations with filesystem side effects", async () => {
|
|
const [first] = await createBinarySessionTargets();
|
|
const calls: string[] = [];
|
|
const session = createTestBinarySession(createCacheProvider(calls, true));
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
const input = { address: "0x1000", document: "first" };
|
|
expect((await session.execute("address_name", input)).ok).toBe(true);
|
|
expect((await session.execute("address_name", input)).ok).toBe(true);
|
|
expect(calls).toEqual(["address_name", "address_name"]);
|
|
await session.close();
|
|
});
|
|
});
|
|
|
|
describe("typed unavailability without dispatching", () => {
|
|
it("returns typed unavailability without dispatching a partial provider", async () => {
|
|
const [first] = await createBinarySessionTargets();
|
|
const operations: string[] = [];
|
|
const provider: AnalysisProvider = {
|
|
identity: () => ({ id: "partial", name: "Partial", version: "1" }),
|
|
capabilities: () => [
|
|
{
|
|
provider: { id: "partial", name: "Partial", version: "1" },
|
|
operation: "address_name",
|
|
available: false,
|
|
reason: "fixture intentionally omits symbol lookup",
|
|
effects: {
|
|
mutatesArtifact: false,
|
|
launchesProcess: false,
|
|
mayShowUi: false,
|
|
mayAccessNetwork: false,
|
|
mayWriteFilesystem: false,
|
|
changesPermissions: false,
|
|
requiresRoot: false,
|
|
},
|
|
limitations: ["No symbol lookup implementation."],
|
|
},
|
|
],
|
|
createClient: () => ({
|
|
execute: (operation) => {
|
|
operations.push(operation);
|
|
return Promise.resolve(ok(operation));
|
|
},
|
|
close: () => Promise.resolve(resultOk(null)),
|
|
}),
|
|
};
|
|
const session = createTestBinarySession(provider);
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
const result = await session.execute("address_name", {});
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toMatchObject({
|
|
_tag: "AnalysisCapabilityUnavailableError",
|
|
providerId: "partial",
|
|
operation: "address_name",
|
|
reason: "fixture intentionally omits symbol lookup",
|
|
});
|
|
}
|
|
expect(operations).toEqual(["health"]);
|
|
await session.close();
|
|
});
|
|
|
|
it("drains an admitted call before closing and closes idempotently", async () => {
|
|
const [first] = await createBinarySessionTargets();
|
|
const session = createTestBinarySession(() => client());
|
|
expect((await session.execute("binary_overview", {})).ok).toBe(false);
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
// The call has been admitted but has not resumed after the transition wait.
|
|
const admitted = session.execute("binary_overview", {});
|
|
const closing = session.close();
|
|
expect((await admitted).ok).toBe(true);
|
|
expect(await closing).toEqual({ ok: true, value: null });
|
|
expect(await session.close()).toEqual({ ok: true, value: null });
|
|
expect(await session.close()).toEqual({ ok: true, value: null });
|
|
});
|
|
|
|
it("keeps the active client when a switch fails", async () => {
|
|
const [first, second] = await createBinarySessionTargets();
|
|
let created = 0;
|
|
const session = createTestBinarySession(() => client(created++ === 1));
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
expect((await session.open(second)).ok).toBe(false);
|
|
expect(session.status()).toMatchObject({
|
|
open: true,
|
|
sha256:
|
|
"7692c3ad3540bb803c020b3aee66cd8887123234ea0c6e7143c0add73ff431ed",
|
|
architecture: null,
|
|
});
|
|
expect(JSON.stringify(session.status())).toContain("first.hop");
|
|
expect(created).toBe(3);
|
|
});
|
|
|
|
it("orders calls between queued transitions without deadlocking", async () => {
|
|
const [first, second] = await createBinarySessionTargets();
|
|
const clients: ControllableAnalysisClient[] = [];
|
|
const session = createTestBinarySession(() => {
|
|
const value = new ControllableAnalysisClient();
|
|
clients.push(value);
|
|
return value;
|
|
});
|
|
const one = session.open(first);
|
|
const firstCall = session.execute("binary_overview", {});
|
|
const controller = new AbortController();
|
|
const cancelled = session.execute(
|
|
"binary_overview",
|
|
{},
|
|
{ signal: controller.signal },
|
|
);
|
|
controller.abort();
|
|
const two = session.open(second);
|
|
const secondCall = session.execute("binary_overview", {});
|
|
const closing = session.close();
|
|
const afterClose = session.execute("binary_overview", {});
|
|
expect((await one).ok).toBe(true);
|
|
expect((await firstCall).ok).toBe(true);
|
|
expect(await cancelled).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError" },
|
|
});
|
|
expect((await two).ok).toBe(true);
|
|
expect((await secondCall).ok).toBe(true);
|
|
expect((await closing).ok).toBe(true);
|
|
expect((await afterClose).ok).toBe(false);
|
|
expect(session.status()).toMatchObject({ open: false });
|
|
expect(clients[0]?.closed).toBe(1);
|
|
expect(clients[1]?.closed).toBe(1);
|
|
});
|
|
|
|
it("cancels a composed admission queued behind open without invoking it", async () => {
|
|
const [first] = await createBinarySessionTargets();
|
|
const health = createDeferred<ReturnType<typeof ok>>();
|
|
const session = createTestBinarySession(
|
|
() => new ControllableAnalysisClient(health.promise),
|
|
);
|
|
const opening = session.open(first);
|
|
const controller = new AbortController();
|
|
let invoked = false;
|
|
const admitted = session.withAdmittedAnalysis(
|
|
"binary_overview",
|
|
controller.signal,
|
|
async () => {
|
|
invoked = true;
|
|
return null;
|
|
},
|
|
);
|
|
controller.abort();
|
|
await expect(admitted).resolves.toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisCancelledError",
|
|
operation: "binary_overview",
|
|
},
|
|
});
|
|
expect(invoked).toBe(false);
|
|
health.resolve(ok(null));
|
|
expect((await opening).ok).toBe(true);
|
|
await session.close();
|
|
});
|
|
});
|
|
|
|
describe("active client replacement", () => {
|
|
it("replaces the active client when a canonical path changes contents", async () => {
|
|
const directory = await createTestTempDirectory("bb-session-");
|
|
const path = join(directory, "mutable.hop");
|
|
await writeFile(path, "one");
|
|
const clients: Array<{
|
|
readonly targetSha256: string;
|
|
readonly calls: string[];
|
|
closed: number;
|
|
}> = [];
|
|
const session = createTestBinarySession((target) => {
|
|
const state = {
|
|
targetSha256: target.sha256,
|
|
calls: [] as string[],
|
|
closed: 0,
|
|
};
|
|
clients.push(state);
|
|
return {
|
|
execute: (operation) => {
|
|
state.calls.push(operation);
|
|
return Promise.resolve(
|
|
ok(operation === "health" ? null : target.sha256),
|
|
);
|
|
},
|
|
close: () => {
|
|
state.closed += 1;
|
|
return Promise.resolve(resultOk(null));
|
|
},
|
|
};
|
|
});
|
|
|
|
const first = await session.open(path);
|
|
expect(first.ok).toBe(true);
|
|
await writeFile(path, "two");
|
|
const second = await session.open(path);
|
|
expect(second.ok).toBe(true);
|
|
if (!first.ok || !second.ok) return;
|
|
|
|
expect(second.value.sha256).not.toBe(first.value.sha256);
|
|
expect(session.activeTarget()?.sha256).toBe(second.value.sha256);
|
|
expect(await session.execute("binary_overview", {})).toMatchObject({
|
|
ok: true,
|
|
value: { result: second.value.sha256 },
|
|
});
|
|
expect(clients).toMatchObject([
|
|
{
|
|
targetSha256: first.value.sha256,
|
|
calls: ["health"],
|
|
closed: 1,
|
|
},
|
|
{
|
|
targetSha256: second.value.sha256,
|
|
calls: ["health", "binary_overview"],
|
|
closed: 0,
|
|
},
|
|
]);
|
|
await session.close();
|
|
});
|
|
|
|
it("waits for an active call before closing its client during a switch", async () => {
|
|
const [first, second] = await createBinarySessionTargets();
|
|
const active = createDeferred<ReturnType<typeof ok>>();
|
|
const clients: ControllableAnalysisClient[] = [];
|
|
const session = createTestBinarySession(() => {
|
|
const value = new ControllableAnalysisClient(
|
|
undefined,
|
|
false,
|
|
clients.length === 0 ? active.promise : undefined,
|
|
);
|
|
clients.push(value);
|
|
return value;
|
|
});
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
const current = session.activeTarget();
|
|
if (current === undefined) throw new Error("Missing active target");
|
|
const preview = await session.previewTarget(current);
|
|
if (!preview.ok) throw preview.error;
|
|
const call = session.execute("procedure_pseudo_code", {});
|
|
const controller = new AbortController();
|
|
const reopening = session.openResolvedTarget(preview.value, {
|
|
signal: controller.signal,
|
|
});
|
|
// The resolved route needs no I/O; one event-loop turn lets it reach the drain.
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
controller.abort();
|
|
const switching = session.open(second);
|
|
await Promise.resolve();
|
|
expect(clients[0]?.closed).toBe(0);
|
|
active.resolve(ok(null));
|
|
expect((await call).ok).toBe(true);
|
|
expect(await reopening).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError", operation: "open_binary" },
|
|
});
|
|
expect((await switching).ok).toBe(true);
|
|
expect(clients[0]?.closed).toBe(1);
|
|
});
|
|
|
|
it("cancels an open queued behind another transition without creating a client", async () => {
|
|
const [first, second] = await createBinarySessionTargets();
|
|
const health = createDeferred<ReturnType<typeof ok>>();
|
|
let created = 0;
|
|
const session = createTestBinarySession(() => {
|
|
created += 1;
|
|
return new ControllableAnalysisClient(
|
|
created === 1 ? health.promise : undefined,
|
|
);
|
|
});
|
|
const opening = session.open(first);
|
|
const controller = new AbortController();
|
|
const queued = session.open(second, { signal: controller.signal });
|
|
controller.abort();
|
|
health.resolve(ok(null));
|
|
await opening;
|
|
const result = await queued;
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError");
|
|
expect(created).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("cancellation during profile resolution", () => {
|
|
it("owns provider rejection when profile resolution synchronously cancels opening", async () => {
|
|
const [first] = await createBinarySessionTargets();
|
|
const provider = createCacheProvider([]);
|
|
const controller = new AbortController();
|
|
provider.resolveAnalysisProfile = () => {
|
|
controller.abort();
|
|
return Promise.reject(new Error("Profile resolution cancelled"));
|
|
};
|
|
const session = createTestBinarySession(provider);
|
|
try {
|
|
await expect(
|
|
session.open(first, { signal: controller.signal }),
|
|
).resolves.toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError", operation: "open_binary" },
|
|
});
|
|
// Allow Node to report any rejection abandoned by the cancellation path.
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
expect(session.activeTarget()).toBeUndefined();
|
|
} finally {
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
it("cancels profile resolution even when the provider ignores its signal", async () => {
|
|
const [first] = await createBinarySessionTargets();
|
|
const provider = createCacheProvider([]);
|
|
let observedSignal: AbortSignal | undefined;
|
|
let created = 0;
|
|
provider.resolveAnalysisProfile = (_target, options) => {
|
|
observedSignal = options?.signal;
|
|
return new Promise<never>(() => undefined);
|
|
};
|
|
provider.createClient = () => {
|
|
created += 1;
|
|
return new ControllableAnalysisClient();
|
|
};
|
|
const session = createTestBinarySession(provider);
|
|
const controller = new AbortController();
|
|
const opening = session.open(first, { signal: controller.signal });
|
|
while (observedSignal === undefined)
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
expect(observedSignal).toBe(controller.signal);
|
|
|
|
controller.abort();
|
|
|
|
await expect(opening).resolves.toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError", operation: "open_binary" },
|
|
});
|
|
expect(created).toBe(0);
|
|
});
|
|
|
|
it("cancels a call while it waits for a transition", async () => {
|
|
const [first] = await createBinarySessionTargets();
|
|
const health = createDeferred<ReturnType<typeof ok>>();
|
|
const session = createTestBinarySession(
|
|
() => new ControllableAnalysisClient(health.promise),
|
|
);
|
|
const opening = session.open(first);
|
|
const controller = new AbortController();
|
|
const call = session.execute(
|
|
"binary_overview",
|
|
{},
|
|
{
|
|
signal: controller.signal,
|
|
},
|
|
);
|
|
controller.abort();
|
|
const result = await call;
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError");
|
|
health.resolve(ok(null));
|
|
await opening;
|
|
});
|
|
|
|
it("closes a failed candidate and reopens the previous target", async () => {
|
|
const [first, second] = await createBinarySessionTargets();
|
|
const clients: ControllableAnalysisClient[] = [];
|
|
const session = createTestBinarySession(() => {
|
|
const value = new ControllableAnalysisClient(
|
|
undefined,
|
|
clients.length === 1,
|
|
);
|
|
clients.push(value);
|
|
return value;
|
|
});
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
expect((await session.open(second)).ok).toBe(false);
|
|
expect((await session.execute("address_name", {})).ok).toBe(true);
|
|
expect(clients[1]?.closed).toBe(1);
|
|
expect(clients[0]?.closed).toBe(1);
|
|
expect(clients[2]?.closed).toBe(0);
|
|
expect(JSON.stringify(session.status())).toContain("first.hop");
|
|
await session.close();
|
|
await session.close();
|
|
expect(clients[2]?.closed).toBe(1);
|
|
});
|
|
|
|
it("closes the active bridge before starting a replacement", async () => {
|
|
const [first, second] = await createBinarySessionTargets();
|
|
let liveClients = 0;
|
|
let overlapped = false;
|
|
const session = createTestBinarySession(() => ({
|
|
execute: () => {
|
|
if (liveClients > 0) overlapped = true;
|
|
liveClients += 1;
|
|
return Promise.resolve(ok(null));
|
|
},
|
|
close: () => {
|
|
liveClients -= 1;
|
|
return Promise.resolve(resultOk(null));
|
|
},
|
|
}));
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
expect((await session.open(second)).ok).toBe(true);
|
|
expect(liveClients).toBe(1);
|
|
expect(overlapped).toBe(false);
|
|
await session.close();
|
|
expect(liveClients).toBe(0);
|
|
});
|
|
|
|
it("clears session state while preserving a typed provider cleanup failure", async () => {
|
|
const [first] = await createBinarySessionTargets();
|
|
const cleanupError = new ProviderCleanupError(
|
|
"fixture",
|
|
["fixture-document"],
|
|
{ reason: "shutdown acknowledgement missing" },
|
|
);
|
|
const session = createTestBinarySession(() => ({
|
|
execute: () => Promise.resolve(ok(null)),
|
|
close: () => Promise.resolve(err(cleanupError)),
|
|
}));
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
|
|
expect(await session.close()).toEqual(err(cleanupError));
|
|
expect(session.status()).toMatchObject({
|
|
open: false,
|
|
analysis_activity: { status: "not_observed", providers: [] },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("no replacement start", () => {
|
|
it("does not start a replacement after the active provider cleanup is unconfirmed", async () => {
|
|
const [first, second] = await createBinarySessionTargets();
|
|
const cleanupError = new ProviderCleanupError(
|
|
"fixture",
|
|
["fixture-document"],
|
|
{ reason: "shutdown acknowledgement missing" },
|
|
);
|
|
let created = 0;
|
|
const session = createTestBinarySession(() => {
|
|
created += 1;
|
|
return {
|
|
execute: () => Promise.resolve(ok(null)),
|
|
close: () => Promise.resolve(err(cleanupError)),
|
|
};
|
|
});
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
|
|
expect(await session.open(second)).toEqual(err(cleanupError));
|
|
expect(created).toBe(1);
|
|
expect(session.status()).toMatchObject({ open: false });
|
|
});
|
|
});
|
|
|
|
describe("selector origin forwarding", () => {
|
|
it("forwards the per-call selector origin to the provider client on both execution paths", async () => {
|
|
const [first] = await createBinarySessionTargets();
|
|
const seen: unknown[] = [];
|
|
const session = createTestBinarySession(() => ({
|
|
execute: (_operation, _parameters, options) => {
|
|
seen.push(options?.selectorOrigin);
|
|
return Promise.resolve(ok(null));
|
|
},
|
|
close: () => Promise.resolve(resultOk(null)),
|
|
}));
|
|
expect((await session.open(first)).ok, "the target opens").toBe(true);
|
|
seen.length = 0;
|
|
|
|
await session.execute(
|
|
"address_name",
|
|
{ address: "0x1000" },
|
|
{ selectorOrigin: "provider" },
|
|
);
|
|
await session.withAdmittedAnalysis(
|
|
"binary_overview",
|
|
undefined,
|
|
(analysis) =>
|
|
analysis.execute(
|
|
"address_name",
|
|
{ address: "0x2000" },
|
|
{ selectorOrigin: "provider" },
|
|
),
|
|
);
|
|
await session.execute("address_name", { address: "0x3000" });
|
|
|
|
expect(
|
|
seen,
|
|
"direct and admitted calls forward the origin; an unmarked call carries none",
|
|
).toEqual(["provider", "provider", undefined]);
|
|
});
|
|
});
|