test: overhaul deterministic suite architecture
This commit is contained in:
parent
7f2348619a
commit
513d9b1555
|
|
@ -18,10 +18,84 @@
|
|||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["tests/**/*.ts"],
|
||||
"files": ["tests/**/*.ts", "src/**/*.test.ts"],
|
||||
"rules": {
|
||||
"max-lines-per-function": "off",
|
||||
"max-lines": "off"
|
||||
"max-lines": [
|
||||
"error",
|
||||
{ "max": 500, "skipBlankLines": true, "skipComments": true }
|
||||
],
|
||||
"max-lines-per-function": [
|
||||
"error",
|
||||
{ "max": 150, "skipBlankLines": true, "skipComments": true }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/domain/**/*.test.ts"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"**/application/**",
|
||||
"**/server/**",
|
||||
"**/process/**",
|
||||
"**/browser/**",
|
||||
"**/ghidra/**",
|
||||
"**/hopper/**",
|
||||
"**/replay/**",
|
||||
"**/cli.*",
|
||||
"**/main.*"
|
||||
],
|
||||
"message": "Domain tests may import only their owning domain and inward dependencies."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/application/**/*.test.ts"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["**/server/**", "**/cli.*", "**/main.*"],
|
||||
"message": "Service tests must use application ports rather than CLI or MCP entrypoints."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["tests/composition/**/*.test.ts"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"**/server/**",
|
||||
"**/cli.*",
|
||||
"**/main.*",
|
||||
"node:child_process",
|
||||
"node:net",
|
||||
"node:http",
|
||||
"node:https",
|
||||
"ws",
|
||||
"playwright"
|
||||
],
|
||||
"message": "Composition tests may wire provider-neutral sessions but must not invoke CLI, MCP, process, network, or browser boundaries."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisProvider,
|
||||
ProviderIdentity,
|
||||
} from "./AnalysisProvider.js";
|
||||
import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js";
|
||||
import { composeBinarySession } from "./BinarySessionComposition.js";
|
||||
|
||||
const auxiliaryProvider = (identity: ProviderIdentity): AnalysisProvider => ({
|
||||
identity: () => identity,
|
||||
capabilities: () => [],
|
||||
createClient: (): AnalysisClient => {
|
||||
throw new Error("The composition check must not create provider clients");
|
||||
},
|
||||
});
|
||||
|
||||
describe("composeBinarySession", () => {
|
||||
it("exposes the complete configured provider set without starting clients", () => {
|
||||
const session = composeBinarySession(new AnalysisProviderRegistry([]), [
|
||||
auxiliaryProvider({
|
||||
id: "test-auxiliary",
|
||||
name: "Test auxiliary provider",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(session.providerIdentity()).toEqual({
|
||||
id: "composite:test-auxiliary",
|
||||
name: "REA composite analysis provider",
|
||||
version: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { projectBoundedCartesian } from "../src/domain/boundedCartesianProjection.js";
|
||||
import { projectBoundedCartesian } from "./boundedCartesianProjection.js";
|
||||
|
||||
describe("bounded Cartesian projection", () => {
|
||||
it("stops projection work at the output limit", () => {
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { inferJsonShape } from "../src/domain/jsonShape.js";
|
||||
import { inferJsonShape } from "./jsonShape.js";
|
||||
|
||||
describe("inferJsonShape", () => {
|
||||
it("retains paths and types without retaining JSON values", () => {
|
||||
|
|
@ -5,7 +5,7 @@ import {
|
|||
discoverObjcClasses,
|
||||
discoverObjcProtocols,
|
||||
discoverSwiftClasses,
|
||||
} from "../src/domain/symbolAnalysis.js";
|
||||
} from "./symbolAnalysis.js";
|
||||
|
||||
describe("symbol analysis", () => {
|
||||
it("filters Swift classes and reports the uncapped match count", () => {
|
||||
|
|
@ -11,7 +11,7 @@ import {
|
|||
totalResourceSize,
|
||||
type PackageManifest,
|
||||
type DigitalSignature,
|
||||
} from "../src/domain/packageAnalysis.js";
|
||||
} from "../../../src/domain/packageAnalysis.js";
|
||||
|
||||
describe("package analysis", () => {
|
||||
it("detects package format from extension", () => {
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js";
|
||||
import type { BinarySession } from "../../../src/application/BinarySession.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import {
|
||||
JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE,
|
||||
JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE,
|
||||
JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
|
||||
JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE,
|
||||
SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE,
|
||||
} from "../../../src/contracts/javascriptApplicationWorkflowExamples.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { observed } from "../../fixtures/analysisExecution.js";
|
||||
|
||||
interface TestHarness {
|
||||
readonly client: Client;
|
||||
readonly session: BinarySession;
|
||||
readonly resourceListChanges: () => number;
|
||||
readonly close: () => Promise<void>;
|
||||
}
|
||||
|
||||
async function createTestHarness(): Promise<TestHarness> {
|
||||
const session = composeBinarySessionFromFactory(() => ({
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
const server = createServer(session, session);
|
||||
const client = new Client({
|
||||
name: "application-workflow-test",
|
||||
version: "1",
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
let resourceListChanges = 0;
|
||||
client.setNotificationHandler("notifications/resources/list_changed", () => {
|
||||
resourceListChanges += 1;
|
||||
});
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
return {
|
||||
client,
|
||||
session,
|
||||
resourceListChanges: () => resourceListChanges,
|
||||
close: async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runInlineEvidenceScenarios(
|
||||
client: Client,
|
||||
session: BinarySession,
|
||||
): Promise<void> {
|
||||
const traced = await client.callTool({
|
||||
name: "trace_application_feature",
|
||||
arguments: JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE,
|
||||
});
|
||||
expect(traced.isError).not.toBe(true);
|
||||
expect(traced.structuredContent).toMatchObject({
|
||||
result: {
|
||||
schema_version: 1,
|
||||
coverage: { status: expect.any(String) },
|
||||
},
|
||||
});
|
||||
|
||||
const compared = await client.callTool({
|
||||
name: "compare_application_versions",
|
||||
arguments: {
|
||||
...JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE,
|
||||
unknown_registry_approved: true,
|
||||
},
|
||||
});
|
||||
expect(compared.isError).not.toBe(true);
|
||||
expect(compared.structuredContent).toMatchObject({
|
||||
result: {
|
||||
schema_version: 1,
|
||||
summary: { unknown: expect.any(Number) },
|
||||
coverage: { status: expect.any(String) },
|
||||
},
|
||||
});
|
||||
|
||||
const sourceCompared = await client.callTool({
|
||||
name: "compare_source_to_bundle",
|
||||
arguments: {
|
||||
reference: SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE.reference,
|
||||
application: JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application,
|
||||
},
|
||||
});
|
||||
expect(sourceCompared.isError).not.toBe(true);
|
||||
expect(sourceCompared.structuredContent).toMatchObject({
|
||||
result: {
|
||||
schema_version: 1,
|
||||
reference: {
|
||||
root_sha256: SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE.reference.root_sha256,
|
||||
},
|
||||
scoring: { algorithm: "rea-source-to-bundle-signals/v1" },
|
||||
},
|
||||
});
|
||||
expect(session.exportEvidenceBundle().records.length).toBeGreaterThan(2);
|
||||
}
|
||||
|
||||
async function runEvidenceIdScenarios(harness: TestHarness): Promise<void> {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const notificationsBeforeIdReuse = harness.resourceListChanges();
|
||||
const tracedById = await harness.client.callTool({
|
||||
name: "trace_application_feature",
|
||||
arguments: {
|
||||
...JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
|
||||
application_evidence_id:
|
||||
JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application.evidence_id,
|
||||
},
|
||||
});
|
||||
expect(tracedById.isError).not.toBe(true);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(harness.resourceListChanges()).toBe(notificationsBeforeIdReuse);
|
||||
|
||||
const comparedById = await harness.client.callTool({
|
||||
name: "compare_application_versions",
|
||||
arguments: {
|
||||
...JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE,
|
||||
left_evidence_id:
|
||||
JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE.left.evidence_id,
|
||||
right_evidence_id:
|
||||
JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE.right.evidence_id,
|
||||
},
|
||||
});
|
||||
expect(comparedById).toMatchObject({
|
||||
structuredContent: {
|
||||
result: {
|
||||
evidence_links: expect.arrayContaining([
|
||||
JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE.left.evidence_id,
|
||||
JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE.right.evidence_id,
|
||||
]),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const sourceComparedById = await harness.client.callTool({
|
||||
name: "compare_source_to_bundle",
|
||||
arguments: {
|
||||
...SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE,
|
||||
application_evidence_id:
|
||||
JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application.evidence_id,
|
||||
},
|
||||
});
|
||||
expect(sourceComparedById.isError).not.toBe(true);
|
||||
}
|
||||
|
||||
async function assertRejectedEvidenceReferences(
|
||||
client: Client,
|
||||
session: BinarySession,
|
||||
): Promise<void> {
|
||||
const missing = await client.callTool({
|
||||
name: "trace_application_feature",
|
||||
arguments: JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
|
||||
});
|
||||
expect(missing).toMatchObject({
|
||||
isError: true,
|
||||
structuredContent: {
|
||||
error: { details: { reason: "missing" } },
|
||||
},
|
||||
});
|
||||
|
||||
const wrongOperation = createEvidence(
|
||||
undefined,
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{ operation: "inventory_artifact", parameters: {}, result: {} },
|
||||
);
|
||||
const wrongPredicate = createEvidence(
|
||||
undefined,
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{
|
||||
predicateType: "fixture.application/v1",
|
||||
operation: "analyze_javascript_application",
|
||||
parameters: {},
|
||||
result: {},
|
||||
},
|
||||
);
|
||||
expect(session.recordEvidence(wrongOperation).ok).toBe(true);
|
||||
expect(session.recordEvidence(wrongPredicate).ok).toBe(true);
|
||||
for (const [record, reason] of [
|
||||
[wrongOperation, "wrong_operation"],
|
||||
[wrongPredicate, "wrong_predicate"],
|
||||
] as const) {
|
||||
const rejected = await client.callTool({
|
||||
name: "trace_application_feature",
|
||||
arguments: {
|
||||
...JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
|
||||
application_evidence_id: record.evidence_id,
|
||||
},
|
||||
});
|
||||
expect(rejected).toMatchObject({
|
||||
isError: true,
|
||||
structuredContent: { error: { details: { reason } } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function assertRejectedInlineEvidence(client: Client): Promise<void> {
|
||||
const duplicateNative = await client.callTool({
|
||||
name: "trace_application_feature",
|
||||
arguments: {
|
||||
...JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE,
|
||||
native_observations: [
|
||||
JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application,
|
||||
],
|
||||
native_observation_evidence_ids: [
|
||||
JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application.evidence_id,
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(duplicateNative.isError).toBe(true);
|
||||
|
||||
const spoofed = await client.callTool({
|
||||
name: "trace_application_feature",
|
||||
arguments: {
|
||||
...JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE,
|
||||
application: {
|
||||
...JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application,
|
||||
provider: { id: "spoofed", name: "spoofed", version: "1" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(spoofed.isError).toBe(true);
|
||||
}
|
||||
|
||||
describe("application workflow MCP parity", () => {
|
||||
it("traces and compares authenticated graph Evidence in the session", async () => {
|
||||
const harness = await createTestHarness();
|
||||
try {
|
||||
await runInlineEvidenceScenarios(harness.client, harness.session);
|
||||
await runEvidenceIdScenarios(harness);
|
||||
await assertRejectedEvidenceReferences(harness.client, harness.session);
|
||||
await assertRejectedInlineEvidence(harness.client);
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { copyFile, mkdir, rm } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { observed } from "../../fixtures/analysisExecution.js";
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/JavaScriptApplicationService.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascriptApplicationAnalysis.js";
|
||||
import { permissionAuthorityForRoot } from "../../fixtures/permissionAuthority.js";
|
||||
|
||||
describe("application workflow MCP parity", () => {
|
||||
it("compares exact parser export shapes through full Evidence and session IDs", async () => {
|
||||
const root = await createTestTempDirectory("rea-export-shape-mcp-");
|
||||
const leftRoot = join(root, "left");
|
||||
const rightRoot = join(root, "right");
|
||||
await Promise.all([mkdir(leftRoot), mkdir(rightRoot)]);
|
||||
await Promise.all([
|
||||
copyFile(
|
||||
resolve("tests/fixtures/replay/parser.mjs"),
|
||||
join(leftRoot, "parser.mjs"),
|
||||
),
|
||||
copyFile(
|
||||
resolve("tests/fixtures/replay/parser-v2.mjs"),
|
||||
join(rightRoot, "parser.mjs"),
|
||||
),
|
||||
]);
|
||||
const authority = await permissionAuthorityForRoot(
|
||||
root,
|
||||
["investigation_input"],
|
||||
["investigation_input"],
|
||||
);
|
||||
const [left, right] = await Promise.all([
|
||||
analyzeJavaScriptApplication(authority, {
|
||||
input_path: leftRoot,
|
||||
approved: true,
|
||||
}),
|
||||
analyzeJavaScriptApplication(authority, {
|
||||
input_path: rightRoot,
|
||||
approved: true,
|
||||
}),
|
||||
]);
|
||||
if (!left.ok) throw left.error;
|
||||
if (!right.ok) throw right.error;
|
||||
const session = composeBinarySessionFromFactory(() => ({
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
const server = createServer(session, session);
|
||||
const client = new Client({ name: "export-shape-test", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
const selectors = {
|
||||
left_module_path: "parser.mjs",
|
||||
left_export_name: "default",
|
||||
right_module_path: "parser.mjs",
|
||||
right_export_name: "default",
|
||||
};
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const full = await client.callTool({
|
||||
name: "compare_javascript_export_shapes",
|
||||
arguments: { left: left.value, right: right.value, ...selectors },
|
||||
});
|
||||
expect(full.isError).not.toBe(true);
|
||||
expect(full.structuredContent).toMatchObject({
|
||||
result: {
|
||||
summary: { added: 1, removed: 0, changed: 0, unknown: 0 },
|
||||
changes: [
|
||||
{
|
||||
status: "added",
|
||||
path: "/depth",
|
||||
right: { availability: "literal", value: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(session.exportEvidenceBundle().records).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
operation: "compare_javascript_export_shapes",
|
||||
predicate_type: "rea.javascript-export-shape-comparison/v1",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const byId = await client.callTool({
|
||||
name: "compare_javascript_export_shapes",
|
||||
arguments: {
|
||||
left_evidence_id: left.value.evidence_id,
|
||||
right_evidence_id: right.value.evidence_id,
|
||||
...selectors,
|
||||
},
|
||||
});
|
||||
expect(byId.isError).not.toBe(true);
|
||||
expect(byId.structuredContent).toMatchObject({
|
||||
result: {
|
||||
evidence_links: [left.value.evidence_id, right.value.evidence_id],
|
||||
changes: [expect.objectContaining({ path: "/depth" })],
|
||||
},
|
||||
});
|
||||
|
||||
const analyzed = javascriptApplicationAnalysisResultSchema.parse(
|
||||
left.value.normalized_result,
|
||||
);
|
||||
if (analyzed.schema_version !== 2)
|
||||
throw new TypeError("Expected semantic application Evidence");
|
||||
const seed = analyzed.semantic_graph.relations[0]?.source_node_id;
|
||||
if (seed === undefined)
|
||||
throw new TypeError("Expected at least one semantic relation");
|
||||
const semantic = await client.callTool({
|
||||
name: "trace_javascript_semantics",
|
||||
arguments: {
|
||||
application: left.value,
|
||||
query: {
|
||||
seed: { kind: "semantic-node", node_id: seed },
|
||||
direction: "forward-influence",
|
||||
include_ambiguous_dynamic_edges: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(semantic.isError).not.toBe(true);
|
||||
expect(semantic.structuredContent).toMatchObject({
|
||||
result: {
|
||||
schema_version: 1,
|
||||
source_evidence_id: left.value.evidence_id,
|
||||
source_graph_id: analyzed.semantic_graph.graph_id,
|
||||
summary: { retained_seed_matches: 1 },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, realpath } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js";
|
||||
import type { BinarySession } from "../../../src/application/BinarySession.js";
|
||||
import { PermissionAuthority } from "../../../src/application/PermissionAuthority.js";
|
||||
import { controlledReplayOutputSchema } from "../../../src/domain/javascriptReplay.js";
|
||||
import { nodeCharacterizationPreparationOutputSchema } from "../../../src/domain/nodeRuntimeCharacterization.js";
|
||||
import { createPermissionPolicy } from "../../../src/domain/permissionPolicy.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { observed } from "../../fixtures/analysisExecution.js";
|
||||
|
||||
function createReplayPolicy(root: string) {
|
||||
return {
|
||||
enabled: true,
|
||||
roots: [root],
|
||||
nodePath: process.execPath,
|
||||
bubblewrapPath: process.execPath,
|
||||
systemdRunPath: process.execPath,
|
||||
systemctlPath: process.execPath,
|
||||
shellPath: process.execPath,
|
||||
};
|
||||
}
|
||||
|
||||
function createReplayAuthority(policy: ReturnType<typeof createReplayPolicy>) {
|
||||
const ceiling = {
|
||||
capability: "javascript_replay" as const,
|
||||
roots: policy.roots,
|
||||
executables: [
|
||||
policy.nodePath,
|
||||
policy.bubblewrapPath,
|
||||
policy.systemdRunPath,
|
||||
policy.systemctlPath,
|
||||
policy.shellPath,
|
||||
],
|
||||
environment_names: [],
|
||||
network: "none" as const,
|
||||
mount: true,
|
||||
};
|
||||
return new PermissionAuthority(
|
||||
createPermissionPolicy(
|
||||
[ceiling],
|
||||
[
|
||||
{
|
||||
...ceiling,
|
||||
grant_id: "administrator:javascript_replay",
|
||||
lifetime: "administrator",
|
||||
operation_identity: null,
|
||||
expires_at: null,
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function createReplayScenario(root: string) {
|
||||
const policy = createReplayPolicy(root);
|
||||
const session = composeBinarySessionFromFactory(() => ({
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
const server = createServer(session, session, {
|
||||
permissionAuthority: createReplayAuthority(policy),
|
||||
javascriptReplayPolicy: policy,
|
||||
javascriptReplayHost: {
|
||||
readSource: async (path) => ({
|
||||
canonicalPath: await realpath(path),
|
||||
bytes: await readFile(path),
|
||||
}),
|
||||
identifyExecutable: async (path) => ({
|
||||
path,
|
||||
version: "fixture",
|
||||
sha256: "1".repeat(64),
|
||||
}),
|
||||
identifyWorker: async () => ({
|
||||
path: "/fixture/worker.js",
|
||||
version: "fixture-worker-1",
|
||||
sha256: "2".repeat(64),
|
||||
}),
|
||||
identifyRuntimeClosure: async (path) => [
|
||||
{
|
||||
sourcePath: path,
|
||||
destinationPath: "/runtime/node",
|
||||
sha256: "1".repeat(64),
|
||||
},
|
||||
],
|
||||
seccompDigest: () => "3".repeat(64),
|
||||
probe: async () => undefined,
|
||||
},
|
||||
javascriptReplayRunner: {
|
||||
execute: async (prepared) => ({
|
||||
schema_version: 1,
|
||||
plan_digest: prepared.publicPlan.plan_digest,
|
||||
outcomes: [
|
||||
{
|
||||
case_id: "heading",
|
||||
outcome: "return",
|
||||
value: { type: "heading", text: "Title" },
|
||||
input_sha256:
|
||||
prepared.publicPlan.cases[0]?.sha256 ?? "0".repeat(64),
|
||||
output_sha256: "2".repeat(64),
|
||||
truncated: false,
|
||||
},
|
||||
],
|
||||
stderr: "",
|
||||
termination: "completed",
|
||||
cleanup: { state: "complete", residual_resources: [] },
|
||||
limitations: ["fixture runner"],
|
||||
reproducer: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const client = new Client({ name: "replay-parity-test", version: "1" });
|
||||
return { client, server, session };
|
||||
}
|
||||
|
||||
function createReplayInput(root: string) {
|
||||
return {
|
||||
mode: "plan",
|
||||
left: {
|
||||
modules: [
|
||||
{
|
||||
alias: "parser",
|
||||
path: resolve(root, "parser.mjs"),
|
||||
format: "esm",
|
||||
role: "module",
|
||||
dependencies: {},
|
||||
},
|
||||
],
|
||||
entry_alias: "parser",
|
||||
entry_export: "default",
|
||||
},
|
||||
cases: [{ case_id: "heading", arguments: ["# Title"] }],
|
||||
approved: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function executeReplayScenario(
|
||||
client: Client,
|
||||
session: BinarySession,
|
||||
root: string,
|
||||
) {
|
||||
const input = createReplayInput(root);
|
||||
const planned = await client.callTool({
|
||||
name: "run_controlled_replay",
|
||||
arguments: input,
|
||||
});
|
||||
expect(planned.isError).not.toBe(true);
|
||||
const digest = controlledReplayOutputSchema.parse(planned.structuredContent)
|
||||
.plan?.plan_digest;
|
||||
expect(digest).toMatch(/^[a-f0-9]{64}$/u);
|
||||
const executed = await client.callTool({
|
||||
name: "run_controlled_replay",
|
||||
arguments: {
|
||||
...input,
|
||||
mode: "execute",
|
||||
approved: true,
|
||||
plan_digest: digest,
|
||||
},
|
||||
});
|
||||
expect(executed.isError).not.toBe(true);
|
||||
expect(executed.structuredContent).toMatchObject({
|
||||
phase: "execute",
|
||||
evidence: {
|
||||
authority: "controlled-replay",
|
||||
provider: { id: "rea-javascript-replay" },
|
||||
},
|
||||
});
|
||||
expect(session.exportEvidenceBundle().records).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ authority: "controlled-replay" }),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async function createCharacterizationInput(root: string) {
|
||||
const factoryPath = resolve(root, "sanitizer.factory.txt");
|
||||
const factoryBytes = await readFile(factoryPath);
|
||||
const factorySha256 = createHash("sha256").update(factoryBytes).digest("hex");
|
||||
return {
|
||||
preparation_approved: true,
|
||||
selected_alias: "bundle",
|
||||
expected_effect: "pure",
|
||||
instrumentation: {
|
||||
artifact_path: factoryPath,
|
||||
artifact_sha256: factorySha256,
|
||||
selection: {
|
||||
byte_start: 0,
|
||||
byte_end: factoryBytes.byteLength,
|
||||
selected_sha256: factorySha256,
|
||||
export_name: "selected",
|
||||
},
|
||||
},
|
||||
replay: {
|
||||
mode: "plan",
|
||||
left: {
|
||||
modules: [
|
||||
{
|
||||
alias: "bundle",
|
||||
path: factoryPath,
|
||||
format: "commonjs-factory",
|
||||
role: "module",
|
||||
dependencies: {},
|
||||
},
|
||||
],
|
||||
entry_alias: "bundle",
|
||||
entry_export: "selected",
|
||||
},
|
||||
cases: [{ case_id: "heading", arguments: ["# Title"] }],
|
||||
approved: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function executeCharacterizationScenario(
|
||||
client: Client,
|
||||
session: BinarySession,
|
||||
root: string,
|
||||
) {
|
||||
const input = await createCharacterizationInput(root);
|
||||
const plan = await client.callTool({
|
||||
name: "prepare_node_characterization",
|
||||
arguments: input,
|
||||
});
|
||||
expect(plan.isError).not.toBe(true);
|
||||
const approvedPlan = nodeCharacterizationPreparationOutputSchema.parse(
|
||||
plan.structuredContent,
|
||||
).plan;
|
||||
const characterized = await client.callTool({
|
||||
name: "execute_node_characterization",
|
||||
arguments: {
|
||||
execution_approved: true,
|
||||
approved_plan_sha256: approvedPlan.plan_sha256,
|
||||
preparation: input,
|
||||
},
|
||||
});
|
||||
expect(characterized.isError).not.toBe(true);
|
||||
expect(characterized.structuredContent).toMatchObject({
|
||||
phase: "execution",
|
||||
evidence: {
|
||||
authority: "controlled-replay",
|
||||
provider: { id: "rea-node-characterization" },
|
||||
},
|
||||
});
|
||||
expect(session.exportEvidenceBundle().records).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
predicate_type: "rea.javascript-export-transformation/v1",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
predicate_type: "rea.runtime-characterization/v1",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
describe("application workflow MCP parity", () => {
|
||||
it("plans and executes the same controlled replay contract", async () => {
|
||||
const root = resolve("tests/fixtures/replay");
|
||||
const { client, server, session } = createReplayScenario(root);
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
await executeReplayScenario(client, session, root);
|
||||
await executeCharacterizationScenario(client, session, root);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -5,7 +5,7 @@ import { promisify } from "node:util";
|
|||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import {
|
||||
JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE,
|
||||
|
|
@ -13,29 +13,28 @@ import {
|
|||
JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
|
||||
JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE,
|
||||
SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE,
|
||||
} from "../src/contracts/javascriptApplicationWorkflowExamples.js";
|
||||
} from "../../../src/contracts/javascriptApplicationWorkflowExamples.js";
|
||||
import {
|
||||
createEvidenceBundle,
|
||||
serializeEvidenceBundle,
|
||||
} from "../src/domain/evidenceBundle.js";
|
||||
import { analyzeJavaScriptApplication } from "../src/application/JavaScriptApplicationService.js";
|
||||
import { permissionAuthorityForRoot } from "./fixtures/permissionAuthority.js";
|
||||
import { REPLAY_MACHINE_RUN_EXAMPLE } from "../src/contracts/replayMachineExample.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../src/domain/javascriptApplicationAnalysis.js";
|
||||
} from "../../../src/domain/evidenceBundle.js";
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/JavaScriptApplicationService.js";
|
||||
import { permissionAuthorityForRoot } from "../../fixtures/permissionAuthority.js";
|
||||
import { REPLAY_MACHINE_RUN_EXAMPLE } from "../../../src/contracts/replayMachineExample.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascriptApplicationAnalysis.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const temporary: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporary
|
||||
.splice(0)
|
||||
.map(async (path) => rm(path, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("application workflow CLI parity", () => {
|
||||
const temporary: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporary
|
||||
.splice(0)
|
||||
.map(async (path) => rm(path, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts inline trace JSON and file-backed comparison JSON", async () => {
|
||||
const traced = await runCli([
|
||||
"trace-application-feature",
|
||||
|
|
@ -127,7 +126,9 @@ describe("application workflow CLI parity", () => {
|
|||
},
|
||||
});
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
describe("application workflow CLI Evidence resolution", () => {
|
||||
it("resolves Evidence IDs from an explicitly authorized bundle", async () => {
|
||||
const root = await createTestTempDirectory("rea-application-bundle-cli-");
|
||||
temporary.push(root);
|
||||
|
|
@ -223,8 +224,10 @@ describe("application workflow CLI parity", () => {
|
|||
environment,
|
||||
);
|
||||
expect(denied).toMatchObject({ code: "permission_required" });
|
||||
}, 20_000);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe("application workflow CLI export Evidence", () => {
|
||||
it("compares exact export shapes from an authorized Evidence bundle", async () => {
|
||||
const root = await createTestTempDirectory("rea-export-shape-cli-");
|
||||
temporary.push(root);
|
||||
|
|
@ -295,7 +298,9 @@ describe("application workflow CLI parity", () => {
|
|||
},
|
||||
});
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
describe("application workflow CLI validation and replay", () => {
|
||||
it("returns safe actionable JSON validation details", async () => {
|
||||
const malformed = await runCli([
|
||||
"trace-application-feature",
|
||||
|
|
@ -5,9 +5,9 @@ import { promisify } from "node:util";
|
|||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { writeVersionedJavaScriptApplicationFixtures } from "./fixtures/javascriptArtifactApplication.js";
|
||||
import { writeVersionedJavaScriptApplicationFixtures } from "../../fixtures/javascriptArtifactApplication.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
|
||||
|
|
@ -2,34 +2,34 @@ import { rm } from "node:fs/promises";
|
|||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { analyzeJavaScriptApplication } from "../src/application/JavaScriptApplicationService.js";
|
||||
import { compareApplicationVersionsEvidence } from "../src/application/JavaScriptApplicationWorkflowService.js";
|
||||
import { createEvidence } from "../src/domain/evidence.js";
|
||||
import { applicationVersionComparisonResultSchema } from "../src/domain/javascriptApplicationVersionComparisonSchemas.js";
|
||||
import { compareJavaScriptApplicationVersions } from "../src/domain/javascriptApplicationVersionComparison.js";
|
||||
import { createJavaScriptApplicationGraph } from "../src/domain/javascriptApplicationGraph.js";
|
||||
import { traceApplicationFeature } from "../src/domain/javascriptFeatureTrace.js";
|
||||
import { applicationFeatureTraceResultSchema } from "../src/domain/javascriptFeatureTraceSchemas.js";
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/JavaScriptApplicationService.js";
|
||||
import { compareApplicationVersionsEvidence } from "../../../src/application/JavaScriptApplicationWorkflowService.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { applicationVersionComparisonResultSchema } from "../../../src/domain/javascriptApplicationVersionComparisonSchemas.js";
|
||||
import { compareJavaScriptApplicationVersions } from "../../../src/domain/javascriptApplicationVersionComparison.js";
|
||||
import { createJavaScriptApplicationGraph } from "../../../src/domain/javascriptApplicationGraph.js";
|
||||
import { traceApplicationFeature } from "../../../src/domain/javascriptFeatureTrace.js";
|
||||
import { applicationFeatureTraceResultSchema } from "../../../src/domain/javascriptFeatureTraceSchemas.js";
|
||||
import {
|
||||
APPLICATION_GRAPH_DIGESTS,
|
||||
buildSyntheticJavaScriptApplicationGraph,
|
||||
} from "./fixtures/javascriptApplicationGraph.js";
|
||||
import { writeVersionedJavaScriptApplicationFixtures } from "./fixtures/javascriptArtifactApplication.js";
|
||||
import { permissionAuthorityForRoot } from "./fixtures/permissionAuthority.js";
|
||||
} from "../../fixtures/javascriptApplicationGraph.js";
|
||||
import { writeVersionedJavaScriptApplicationFixtures } from "../../fixtures/javascriptArtifactApplication.js";
|
||||
import { permissionAuthorityForRoot } from "../../fixtures/permissionAuthority.js";
|
||||
|
||||
const temporary: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporary
|
||||
.splice(0)
|
||||
.map(async (path) => rm(path, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("JavaScript application workflows", () => {
|
||||
const temporary: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporary
|
||||
.splice(0)
|
||||
.map(async (path) => rm(path, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
it("traces a contextBridge API through IPC into linked native evidence", () => {
|
||||
const graph = buildSyntheticJavaScriptApplicationGraph();
|
||||
const nativeEvidence = createEvidence(
|
||||
|
|
@ -129,7 +129,9 @@ describe("JavaScript application workflows", () => {
|
|||
expect(bounded.coverage.omitted_nodes).toBeGreaterThan(0);
|
||||
expect(bounded.summary.native_handoffs).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JavaScript application comparison workflows", () => {
|
||||
it("matches rechunked modules by exact source and minified modules by structural fingerprint", async () => {
|
||||
const root = await createTestTempDirectory("rea-application-versions-");
|
||||
temporary.push(root);
|
||||
|
|
@ -10,11 +10,10 @@ import {
|
|||
nativeLibsByArchitecture,
|
||||
type MobileArtifactManifest,
|
||||
type AndroidManifest,
|
||||
type IosManifest,
|
||||
type SigningInfo,
|
||||
type NativeLibrary,
|
||||
type MobileResource,
|
||||
} from "../src/domain/mobileApplicationInvestigation.js";
|
||||
} from "../../../src/domain/mobileApplicationInvestigation.js";
|
||||
|
||||
describe("mobile application investigation", () => {
|
||||
it("detects mobile format from extension", () => {
|
||||
|
|
@ -1,19 +1,22 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { packageHopperEnvironment } from "../scripts/verify-package-environment.mjs";
|
||||
import { packageHopperEnvironment } from "../../../scripts/verify-package-environment.mjs";
|
||||
|
||||
describe("package installation workflows", () => {
|
||||
it("runs package E2E without the retired native rebuild script", async () => {
|
||||
const continuousIntegration = await readFile(
|
||||
new URL("../.github/workflows/ci.yml", import.meta.url),
|
||||
new URL("../../../.github/workflows/ci.yml", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const realHopperLinux = await readFile(
|
||||
new URL("../.github/workflows/real-hopper-linux.yml", import.meta.url),
|
||||
new URL(
|
||||
"../../../.github/workflows/real-hopper-linux.yml",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const realHopperMac = await readFile(
|
||||
new URL("../.github/workflows/real-hopper.yml", import.meta.url),
|
||||
new URL("../../../.github/workflows/real-hopper.yml", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
|
|
@ -21,7 +24,7 @@ describe("package installation workflows", () => {
|
|||
expect(continuousIntegration).toContain("npm run verify:package");
|
||||
expect(continuousIntegration).toContain("name: Static checks");
|
||||
expect(continuousIntegration).toContain("npm run check:ci");
|
||||
expect(continuousIntegration).toContain("shard: [1/2, 2/2]");
|
||||
expect(continuousIntegration).toContain("shard: [1/4, 2/4, 3/4, 4/4]");
|
||||
expect(continuousIntegration).toContain("name: Build package");
|
||||
expect(continuousIntegration).toContain("npm run build:cached");
|
||||
expect(continuousIntegration).toContain("name: rea-dist");
|
||||
|
|
@ -61,11 +64,11 @@ describe("package installation workflows", () => {
|
|||
|
||||
it("verifies the package before publish and runs published canaries outside the checkout", async () => {
|
||||
const release = await readFile(
|
||||
new URL("../.github/workflows/release.yml", import.meta.url),
|
||||
new URL("../../../.github/workflows/release.yml", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const canary = await readFile(
|
||||
new URL("../scripts/verify-published-package.mjs", import.meta.url),
|
||||
new URL("../../../scripts/verify-published-package.mjs", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { runSetup } from "../../../src/application/Setup.js";
|
||||
import { FakeSetupHost, options } from "./setupTestSupport.js";
|
||||
|
||||
describe("setup workflow", () => {
|
||||
it("omits an aligned managed skill from an otherwise empty plan", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
host.skill = "unchanged";
|
||||
const result = await runSetup(options(false), host);
|
||||
expect(result.status).toBe("ready");
|
||||
expect(result.plannedActions).toEqual([]);
|
||||
expect(result.appliedActions).toEqual([]);
|
||||
expect(host.skillInstalls).toBe(0);
|
||||
});
|
||||
|
||||
it("installs missing Linux demo dependencies for existing Hopper", async () => {
|
||||
const host = new FakeSetupHost("linux");
|
||||
host.distribution = {
|
||||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
supported: true,
|
||||
};
|
||||
host.hopper = "/opt/hopper/bin/Hopper";
|
||||
host.linuxDemoRuntimeMissing = true;
|
||||
const result = await runSetup(
|
||||
{ ...options(false), structured: false },
|
||||
host,
|
||||
() => Promise.resolve(true),
|
||||
);
|
||||
expect(result.status).toBe("ready");
|
||||
expect(host.hopperInstalls).toBe(1);
|
||||
expect(result.plannedActions.map(({ kind }) => kind)).toContain(
|
||||
"install_hopper",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not reinstall Hopper for an unsupported configured path", async () => {
|
||||
const host = new FakeSetupHost("linux");
|
||||
host.distribution = {
|
||||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
supported: true,
|
||||
};
|
||||
host.hopper = "/custom/Hopper";
|
||||
host.unsupportedHopperVersion = true;
|
||||
const result = await runSetup(
|
||||
{ ...options(false), structured: false },
|
||||
host,
|
||||
() => Promise.resolve(true),
|
||||
);
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(host.hopperInstalls).toBe(0);
|
||||
expect(result.plannedActions.map(({ kind }) => kind)).not.toContain(
|
||||
"install_hopper",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a failed Hopper installation without configuring clients", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopperInstallSucceeds = false;
|
||||
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
|
||||
const result = await runSetup(options(true, true), host);
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(result.code).toBe("download_failed");
|
||||
expect(result.remediation).toBe("Download failed.");
|
||||
expect(host.configurations).toBe(0);
|
||||
});
|
||||
|
||||
it("blocks malformed client configuration before Hopper or file mutation", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.clients = [
|
||||
{ name: "codex", displayName: "Codex", configPath: "/codex.toml" },
|
||||
];
|
||||
host.clientInspections.set("codex", {
|
||||
status: "invalid",
|
||||
remediation: "Repair the existing TOML.",
|
||||
});
|
||||
|
||||
const result = await runSetup(options(true, true), host);
|
||||
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(result.remediation).toBe("Codex: Repair the existing TOML.");
|
||||
expect(host.hopperInstalls).toBe(0);
|
||||
expect(host.configurations).toBe(0);
|
||||
expect(host.skillInstalls).toBe(0);
|
||||
});
|
||||
|
||||
it("blocks malformed configuration before unrelated writes with an existing provider", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
host.clients = [
|
||||
{
|
||||
name: "claude_desktop",
|
||||
displayName: "Claude Desktop",
|
||||
configPath: "/claude.json",
|
||||
},
|
||||
{ name: "cursor", displayName: "Cursor", configPath: "/cursor.json" },
|
||||
];
|
||||
host.clientInspections.set("claude_desktop", {
|
||||
status: "invalid",
|
||||
remediation: "Repair the existing JSON.",
|
||||
});
|
||||
|
||||
const result = await runSetup(options(true), host);
|
||||
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(result.remediation).toBe(
|
||||
"Claude Desktop: Repair the existing JSON.",
|
||||
);
|
||||
expect(host.configurations).toBe(0);
|
||||
expect(host.skillInstalls).toBe(0);
|
||||
});
|
||||
|
||||
it("plans a client update when Hopper installation changes its desired environment", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.clients = [
|
||||
{ name: "codex", displayName: "Codex", configPath: "/codex.toml" },
|
||||
];
|
||||
host.clientInspections.set("codex", { status: "already_current" });
|
||||
|
||||
const result = await runSetup(options(false), host);
|
||||
|
||||
expect(
|
||||
result.plannedActions.map(({ id, operation }) => ({
|
||||
id,
|
||||
operation,
|
||||
})),
|
||||
).toContainEqual({
|
||||
id: "configure_client:codex",
|
||||
operation: "update",
|
||||
});
|
||||
expect(host.configurations).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setup workflow action selection", () => {
|
||||
it("executes only actions selected by the interactive adapter", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
host.clients = [
|
||||
{ name: "codex", displayName: "Codex", configPath: "/codex.toml" },
|
||||
{ name: "cursor", displayName: "Cursor", configPath: "/cursor.json" },
|
||||
];
|
||||
|
||||
const result = await runSetup(
|
||||
{ ...options(false), structured: false },
|
||||
host,
|
||||
() =>
|
||||
Promise.resolve({
|
||||
approved: true,
|
||||
selectedActionIds: ["configure_client:codex"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.plannedActions.map(({ id }) => id)).toEqual([
|
||||
"configure_client:codex",
|
||||
]);
|
||||
expect(result.clients).toEqual({ codex: { status: "configured" } });
|
||||
expect(host.configurations).toBe(1);
|
||||
expect(host.skillInstalls).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"path",
|
||||
"Agent configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun setup.",
|
||||
],
|
||||
[
|
||||
"backup",
|
||||
"Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.",
|
||||
],
|
||||
[
|
||||
"write",
|
||||
"Agent configuration could not be updated. Check file permissions, then rerun setup.",
|
||||
],
|
||||
[
|
||||
"readback",
|
||||
"Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.",
|
||||
],
|
||||
] as const)("explains %s configuration recovery", async (reason, message) => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
host.clients = [{ name: "internal_client_key", configPath: "/agent.json" }];
|
||||
host.clientResults.set("internal_client_key", { status: "failed", reason });
|
||||
const result = await runSetup(options(true), host);
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(result.remediation).toBe(message);
|
||||
expect(result.remediation).not.toContain("internal_client_key");
|
||||
});
|
||||
});
|
||||
|
|
@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||
import {
|
||||
setupInstallFailure,
|
||||
type HopperInstallFailureReason,
|
||||
} from "../src/application/SetupInstallFailure.js";
|
||||
} from "../../../src/application/SetupInstallFailure.js";
|
||||
|
||||
describe("setup installer failure projection", () => {
|
||||
it.each([
|
||||
|
|
@ -11,17 +11,17 @@ import {
|
|||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import {
|
||||
configureTomlClient,
|
||||
detectClients,
|
||||
} from "../src/application/Setup.js";
|
||||
} from "../../../src/application/Setup.js";
|
||||
import {
|
||||
runUninstall,
|
||||
systemUninstallHost,
|
||||
type UninstallFileSystem,
|
||||
} from "../src/application/Uninstall.js";
|
||||
} from "../../../src/application/Uninstall.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
|
|
@ -142,7 +142,9 @@ describe("agent lifecycle", () => {
|
|||
readFile(`${configPath}.rea.backup`, "utf8"),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent lifecycle removal", () => {
|
||||
it("uninstalls only owned entries and refuses purge symlinks", async () => {
|
||||
const home = await createTestTempDirectory("rea-uninstall-");
|
||||
roots.push(home);
|
||||
|
|
@ -250,7 +252,9 @@ describe("agent lifecycle", () => {
|
|||
);
|
||||
expect(await readFile(config, "utf8")).toBe("not-json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent lifecycle failure reporting", () => {
|
||||
it.each([
|
||||
[
|
||||
"read",
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
registrationPermissionEnvironment,
|
||||
runSetup,
|
||||
type SetupProgressEvent,
|
||||
} from "../../../src/application/Setup.js";
|
||||
import { FakeSetupHost, options } from "./setupTestSupport.js";
|
||||
|
||||
describe("setup workflow", () => {
|
||||
it("propagates only the explicit non-secret investigation root policy", () => {
|
||||
expect(
|
||||
registrationPermissionEnvironment({
|
||||
REA_INVESTIGATION_INPUT_ROOTS_JSON: '["/approved/apps"]',
|
||||
AUTHORIZATION: "secret",
|
||||
}),
|
||||
).toEqual({
|
||||
REA_INVESTIGATION_INPUT_ROOTS_JSON: '["/approved/apps"]',
|
||||
});
|
||||
expect(registrationPermissionEnvironment({})).toEqual({});
|
||||
});
|
||||
|
||||
it("returns a complete plan without mutation", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
|
||||
const result = await runSetup(options(false), host);
|
||||
expect(result.status).toBe("needs_confirmation");
|
||||
expect(result.plannedActions.map(({ kind }) => kind)).toEqual([
|
||||
"install_hopper",
|
||||
"configure_client",
|
||||
"install_skill",
|
||||
]);
|
||||
expect(host.hopperInstalls).toBe(0);
|
||||
expect(host.configurations).toBe(0);
|
||||
expect(result.remediation).toBe(
|
||||
"Review the setup plan, then rerun interactively or with --yes.",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports ready after an accepted Linux plan installs healthy Hopper", async () => {
|
||||
const host = new FakeSetupHost("linux");
|
||||
host.distribution = {
|
||||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
supported: true,
|
||||
};
|
||||
const result = await runSetup(
|
||||
{ ...options(false), structured: false },
|
||||
host,
|
||||
() => Promise.resolve(true),
|
||||
);
|
||||
expect(result.status).toBe("ready");
|
||||
expect(result.appliedActions).toEqual([
|
||||
"installed_hopper",
|
||||
"installed_skill",
|
||||
]);
|
||||
expect(host.hopperInstalls).toBe(1);
|
||||
expect(result.remediation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requires first-run activation after installing Hopper on macOS", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
const result = await runSetup(
|
||||
{ ...options(false), structured: false },
|
||||
host,
|
||||
() => Promise.resolve(true),
|
||||
);
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(result.remediation).toContain("choose its demo mode");
|
||||
});
|
||||
|
||||
it("declines an interactive plan without mutation", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
const result = await runSetup(
|
||||
{ ...options(false), structured: false },
|
||||
host,
|
||||
() => Promise.resolve(false),
|
||||
);
|
||||
expect(result.status).toBe("planned");
|
||||
expect(result.appliedActions).toEqual([]);
|
||||
expect(host.hopperInstalls).toBe(0);
|
||||
expect(host.doctorCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("treats an empty interactive selection as cancellation", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.clients = [{ name: "codex", configPath: "/codex.toml" }];
|
||||
|
||||
const result = await runSetup(
|
||||
{ ...options(false), structured: false },
|
||||
host,
|
||||
() => Promise.resolve({ approved: false, selectedActionIds: [] }),
|
||||
);
|
||||
|
||||
expect(result.status).toBe("planned");
|
||||
expect(result.plannedActions).toEqual([]);
|
||||
expect(result.appliedActions).toEqual([]);
|
||||
expect(host.configurations).toBe(0);
|
||||
expect(host.skillInstalls).toBe(0);
|
||||
expect(host.hopperInstalls).toBe(0);
|
||||
expect(host.doctorCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("requires the Hopper flag for unattended setup", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
const result = await runSetup(options(true), host);
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(host.hopperInstalls).toBe(0);
|
||||
expect(result.appliedActions).toEqual(["installed_skill"]);
|
||||
expect(result.remediation).toContain("--install-hopper");
|
||||
expect(result.remediation).toBe(
|
||||
"Hopper is optional for non-Hopper providers. Rerun with --yes --install-hopper for deep native analysis.",
|
||||
);
|
||||
});
|
||||
|
||||
it("installs Hopper when unattended authorization is explicit", async () => {
|
||||
const host = new FakeSetupHost("linux");
|
||||
host.distribution = {
|
||||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
supported: true,
|
||||
};
|
||||
const result = await runSetup(options(true, true), host);
|
||||
expect(result.appliedActions).toEqual([
|
||||
"installed_hopper",
|
||||
"installed_skill",
|
||||
]);
|
||||
expect(host.hopperInstalls).toBe(1);
|
||||
expect(result.status).toBe("ready");
|
||||
expect(result.remediation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reuses existing Hopper and configures detected clients", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
host.clients = [
|
||||
{ name: "claude", configPath: "/claude.json" },
|
||||
{ name: "cursor", configPath: "/cursor.json" },
|
||||
];
|
||||
const result = await runSetup(options(true), host);
|
||||
expect(result.status).toBe("ready");
|
||||
expect(result.appliedActions).toEqual([
|
||||
"configured_claude",
|
||||
"configured_cursor",
|
||||
"installed_skill",
|
||||
]);
|
||||
expect(host.hopperInstalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setup workflow lifecycle and provider planning", () => {
|
||||
it("reports append-only lifecycle progress for each selected action", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
host.clients = [
|
||||
{
|
||||
name: "codex",
|
||||
displayName: "Codex",
|
||||
configPath: "/codex.toml",
|
||||
},
|
||||
];
|
||||
const progress: SetupProgressEvent[] = [];
|
||||
await runSetup(
|
||||
{ ...options(true), onProgress: (event) => progress.push(event) },
|
||||
host,
|
||||
);
|
||||
expect(progress).toEqual([
|
||||
{ actionId: "configure_client:codex", label: "Codex", state: "started" },
|
||||
{
|
||||
actionId: "configure_client:codex",
|
||||
label: "Codex",
|
||||
state: "completed",
|
||||
},
|
||||
{
|
||||
actionId: "install_skill",
|
||||
label: "REA reverse-engineering skill",
|
||||
state: "started",
|
||||
},
|
||||
{
|
||||
actionId: "install_skill",
|
||||
label: "REA reverse-engineering skill",
|
||||
state: "completed",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("plans and propagates BYO Ghidra without installing software", async () => {
|
||||
const host = new FakeSetupHost("linux");
|
||||
host.distribution = {
|
||||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
supported: true,
|
||||
};
|
||||
host.ghidra = "/opt/ghidra_12.1.2_PUBLIC";
|
||||
host.javaHome = "/usr/lib/jvm/jdk-21";
|
||||
host.doctorHealthy = true;
|
||||
host.skill = "unchanged";
|
||||
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
|
||||
|
||||
const plan = await runSetup(options(false), host);
|
||||
expect(plan.plannedActions.map(({ kind }) => kind)).toEqual([
|
||||
"configure_client",
|
||||
]);
|
||||
expect(plan.plannedActions[0]?.detail).toContain(
|
||||
"GHIDRA_INSTALL_DIR=/opt/ghidra_12.1.2_PUBLIC",
|
||||
);
|
||||
expect(plan.plannedActions[0]?.detail).toContain(
|
||||
"JAVA_HOME=/usr/lib/jvm/jdk-21",
|
||||
);
|
||||
expect(host.hopperInstalls).toBe(0);
|
||||
expect(host.configurations).toBe(0);
|
||||
|
||||
const applied = await runSetup(options(true), host);
|
||||
expect(applied.status).toBe("ready");
|
||||
expect(host.hopperInstalls).toBe(0);
|
||||
expect(host.configuredProviderEnvironments).toContainEqual({
|
||||
GHIDRA_INSTALL_DIR: "/opt/ghidra_12.1.2_PUBLIC",
|
||||
JAVA_HOME: "/usr/lib/jvm/jdk-21",
|
||||
});
|
||||
});
|
||||
|
||||
it("reinstalls existing Hopper when explicitly requested", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
host.skill = "unchanged";
|
||||
host.clients = [{ name: "codex", configPath: "/codex.toml" }];
|
||||
host.clientResults.set("codex", { status: "unchanged" });
|
||||
|
||||
const result = await runSetup(options(true, true), host);
|
||||
|
||||
expect(result.plannedActions.map(({ kind }) => kind)).toEqual([
|
||||
"install_hopper",
|
||||
"configure_client",
|
||||
]);
|
||||
expect(result.appliedActions).toEqual(["installed_hopper"]);
|
||||
expect(host.hopperInstalls).toBe(1);
|
||||
expect(host.hopperReplaceRequests).toEqual([true]);
|
||||
expect(host.configurations).toBe(0);
|
||||
expect(host.checkedHopperPaths).toEqual(["/manual/Hopper"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { runSetup } from "../../../src/application/Setup.js";
|
||||
import { FakeSetupHost, options } from "./setupTestSupport.js";
|
||||
|
||||
describe("setup workflow", () => {
|
||||
it("records every detected client outcome after an earlier failure", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
host.clients = [
|
||||
{ name: "first", configPath: "/first.json" },
|
||||
{ name: "second", configPath: "/second.json" },
|
||||
{ name: "third", configPath: "/third.json" },
|
||||
];
|
||||
host.clientResults.set("first", { status: "failed", reason: "write" });
|
||||
host.clientResults.set("second", { status: "configured" });
|
||||
host.clientResults.set("third", { status: "failed", reason: "readback" });
|
||||
|
||||
const result = await runSetup(options(true), host);
|
||||
|
||||
expect(host.configurations).toBe(3);
|
||||
expect(result.clients).toEqual({
|
||||
first: { status: "failed", reason: "write" },
|
||||
second: { status: "configured" },
|
||||
third: { status: "failed", reason: "readback" },
|
||||
});
|
||||
expect(result.appliedActions).toEqual(["configured_second"]);
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(result.remediation).toContain("could not be updated");
|
||||
});
|
||||
|
||||
it("explains skill installation recovery", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
host.skill = "failed";
|
||||
const result = await runSetup(options(true), host);
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(result.remediation).toBe(
|
||||
"REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.",
|
||||
);
|
||||
});
|
||||
|
||||
it("delegates remaining unhealthy checks to doctor remediation", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
host.doctorHealthy = false;
|
||||
const result = await runSetup(options(true), host);
|
||||
expect(result.remediation).toBe(
|
||||
"Run rea doctor and apply each reported remediation.",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses requested readiness scope without hiding environment drift", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
host.doctorHealthy = false;
|
||||
host.scopedDoctorHealthy = true;
|
||||
const readinessScope = {
|
||||
clients: ["codex"],
|
||||
providers: [] as string[],
|
||||
skill: true,
|
||||
};
|
||||
|
||||
const result = await runSetup(
|
||||
{
|
||||
...options(true),
|
||||
proposeHopper: false,
|
||||
installSkill: false,
|
||||
readinessScope,
|
||||
},
|
||||
host,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "ready",
|
||||
doctor: { healthy: true, environment_healthy: false },
|
||||
});
|
||||
expect(host.doctorScopes).toEqual(expect.arrayContaining([readinessScope]));
|
||||
});
|
||||
|
||||
it("explains unsupported Node and macOS recovery", async () => {
|
||||
const platformHost = new FakeSetupHost("win32");
|
||||
expect((await runSetup(options(true), platformHost)).remediation).toBe(
|
||||
"REA supports Hopper on macOS and selected 64-bit Linux distributions.",
|
||||
);
|
||||
|
||||
const nodeHost = new FakeSetupHost();
|
||||
nodeHost.nodeVersion = "20.0.0";
|
||||
expect((await runSetup(options(true), nodeHost)).remediation).toBe(
|
||||
"Install Node.js 22.19+ or 24.11+ and rerun setup.",
|
||||
);
|
||||
|
||||
const macHost = new FakeSetupHost();
|
||||
macHost.version = "11.7";
|
||||
expect((await runSetup(options(true), macHost)).remediation).toBe(
|
||||
"Upgrade to macOS 12 or newer.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsupported hosts before mutation", async () => {
|
||||
const host = new FakeSetupHost("linux");
|
||||
host.distribution = {
|
||||
id: "debian",
|
||||
versionId: "13",
|
||||
packageFamily: "deb",
|
||||
supported: false,
|
||||
};
|
||||
const result = await runSetup(options(true, true), host);
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(host.hopperInstalls).toBe(0);
|
||||
expect(result.remediation).toBe(
|
||||
"Automated Hopper setup supports Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux; configure an existing supported provider instead.",
|
||||
);
|
||||
});
|
||||
|
||||
it("configures BYO Ghidra on Linux outside Hopper's installer matrix", async () => {
|
||||
const host = new FakeSetupHost("linux");
|
||||
host.distribution = {
|
||||
id: "debian",
|
||||
versionId: "13",
|
||||
packageFamily: "deb",
|
||||
supported: false,
|
||||
};
|
||||
host.ghidra = "/opt/ghidra_12.1.2_PUBLIC";
|
||||
host.javaHome = "/usr/lib/jvm/jdk-21";
|
||||
host.doctorHealthy = true;
|
||||
host.skill = "unchanged";
|
||||
host.clients = [{ name: "codex", configPath: "/codex.toml" }];
|
||||
|
||||
const result = await runSetup(options(true), host);
|
||||
|
||||
expect(result.status).toBe("ready");
|
||||
expect(host.hopperInstalls).toBe(0);
|
||||
expect(host.configuredProviderEnvironments).toContainEqual({
|
||||
GHIDRA_INSTALL_DIR: "/opt/ghidra_12.1.2_PUBLIC",
|
||||
JAVA_HOME: "/usr/lib/jvm/jdk-21",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import {
|
||||
type ClientConfigurationInspection,
|
||||
type ClientConfigurationResult,
|
||||
type SetupClient,
|
||||
type SetupHost,
|
||||
type SetupOptions,
|
||||
type SetupHopperInstallResult,
|
||||
type SetupProviderEnvironment,
|
||||
} from "../../../src/application/Setup.js";
|
||||
import type {
|
||||
DoctorCheck,
|
||||
DoctorReport,
|
||||
DoctorScope,
|
||||
} from "../../../src/application/Doctor.js";
|
||||
import type { LinuxDistribution } from "../../../src/application/LinuxHopper.js";
|
||||
|
||||
export class FakeSetupHost implements SetupHost {
|
||||
readonly platform: NodeJS.Platform;
|
||||
nodeVersion = "25.1.0";
|
||||
version: string | undefined = "14.5";
|
||||
distribution: LinuxDistribution | undefined;
|
||||
hopper: string | undefined;
|
||||
ghidra: string | undefined;
|
||||
javaHome: string | undefined;
|
||||
hopperInstallSucceeds = true;
|
||||
skill: "installed" | "unchanged" | "failed" = "installed";
|
||||
clients: readonly SetupClient[] = [];
|
||||
clientResults = new Map<string, ClientConfigurationResult>();
|
||||
clientInspections = new Map<string, ClientConfigurationInspection>();
|
||||
hopperInstalls = 0;
|
||||
hopperReplaceRequests: boolean[] = [];
|
||||
configurations = 0;
|
||||
skillInstalls = 0;
|
||||
doctorCalls = 0;
|
||||
doctorScopes: Array<DoctorScope | undefined> = [];
|
||||
checkedHopperPaths: Array<string | undefined> = [];
|
||||
configuredProviderEnvironments: SetupProviderEnvironment[] = [];
|
||||
doctorHealthy: boolean | undefined;
|
||||
scopedDoctorHealthy: boolean | undefined;
|
||||
linuxDemoRuntimeMissing = false;
|
||||
unsupportedHopperVersion = false;
|
||||
|
||||
constructor(platform: NodeJS.Platform = "darwin") {
|
||||
this.platform = platform;
|
||||
}
|
||||
|
||||
macosVersion = (): Promise<string | undefined> =>
|
||||
Promise.resolve(this.version);
|
||||
linuxDistribution = (): Promise<LinuxDistribution | undefined> =>
|
||||
Promise.resolve(this.distribution);
|
||||
hopperPath = (): Promise<string | undefined> => Promise.resolve(this.hopper);
|
||||
providerEnvironment = (): Promise<SetupProviderEnvironment> =>
|
||||
Promise.resolve({
|
||||
...(this.hopper === undefined
|
||||
? {}
|
||||
: { HOPPER_LAUNCHER_PATH: this.hopper }),
|
||||
...(this.ghidra === undefined ? {} : { GHIDRA_INSTALL_DIR: this.ghidra }),
|
||||
...(this.javaHome === undefined ? {} : { JAVA_HOME: this.javaHome }),
|
||||
});
|
||||
installHopper = (
|
||||
replaceExisting: boolean,
|
||||
): Promise<SetupHopperInstallResult> => {
|
||||
this.hopperInstalls += 1;
|
||||
this.hopperReplaceRequests.push(replaceExisting);
|
||||
this.linuxDemoRuntimeMissing = false;
|
||||
if (this.hopperInstallSucceeds) {
|
||||
this.hopper = "/manual/Hopper";
|
||||
return Promise.resolve({
|
||||
status: "installed",
|
||||
launcherPath: this.hopper,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
status: "failed",
|
||||
code: "download_failed",
|
||||
remediation: "Download failed.",
|
||||
});
|
||||
};
|
||||
detectedClients = (): Promise<readonly SetupClient[]> =>
|
||||
Promise.resolve(this.clients);
|
||||
configureClient = (
|
||||
client: SetupClient,
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
): Promise<ClientConfigurationResult> => {
|
||||
this.configurations += 1;
|
||||
this.configuredProviderEnvironments.push(providerEnvironment);
|
||||
return Promise.resolve(
|
||||
this.clientResults.get(client.name) ?? { status: "configured" },
|
||||
);
|
||||
};
|
||||
clientNeedsConfigure = (
|
||||
client: SetupClient,
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
): Promise<boolean> => {
|
||||
this.checkedHopperPaths.push(providerEnvironment.HOPPER_LAUNCHER_PATH);
|
||||
return Promise.resolve(
|
||||
this.clientResults.get(client.name)?.status !== "unchanged",
|
||||
);
|
||||
};
|
||||
inspectClientConfiguration = (
|
||||
client: SetupClient,
|
||||
): Promise<ClientConfigurationInspection> =>
|
||||
Promise.resolve(
|
||||
this.clientInspections.get(client.name) ?? { status: "update" },
|
||||
);
|
||||
skillNeedsInstall = (): Promise<boolean> =>
|
||||
Promise.resolve(this.skill !== "unchanged");
|
||||
installSkill = (): Promise<"installed" | "unchanged" | "failed"> => {
|
||||
this.skillInstalls += 1;
|
||||
return Promise.resolve(this.skill);
|
||||
};
|
||||
doctor = (scope?: DoctorScope): Promise<DoctorReport> => {
|
||||
this.doctorCalls += 1;
|
||||
this.doctorScopes.push(scope);
|
||||
const checks: DoctorCheck[] = [
|
||||
...(this.linuxDemoRuntimeMissing
|
||||
? ([
|
||||
{
|
||||
name: "hopper-demo-runtime",
|
||||
ok: false,
|
||||
classification: "missing_dependency",
|
||||
},
|
||||
] as const)
|
||||
: []),
|
||||
...(this.unsupportedHopperVersion
|
||||
? ([
|
||||
{
|
||||
name: "hopper-version",
|
||||
ok: false,
|
||||
classification: "config_drift",
|
||||
detail: this.hopper ?? "",
|
||||
},
|
||||
] as const)
|
||||
: []),
|
||||
];
|
||||
const environmentHealthy =
|
||||
this.doctorHealthy ??
|
||||
(this.hopper !== undefined &&
|
||||
!this.linuxDemoRuntimeMissing &&
|
||||
!this.unsupportedHopperVersion);
|
||||
const healthy =
|
||||
scope === undefined
|
||||
? environmentHealthy
|
||||
: (this.scopedDoctorHealthy ?? environmentHealthy);
|
||||
return Promise.resolve({
|
||||
healthy,
|
||||
environment_healthy: environmentHealthy,
|
||||
scope: {
|
||||
mode: scope === undefined ? "audit-wide" : "explicit",
|
||||
clients: scope?.clients ?? [],
|
||||
providers: scope?.providers ?? [],
|
||||
skill: scope?.skill === true,
|
||||
target: null,
|
||||
},
|
||||
scope_checks: checks,
|
||||
informational_checks: [],
|
||||
...(this.hopper === undefined ? {} : { hopperPath: this.hopper }),
|
||||
checks,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const options = (
|
||||
approved: boolean,
|
||||
installHopper = false,
|
||||
): SetupOptions => ({
|
||||
approved,
|
||||
installHopper,
|
||||
structured: true,
|
||||
});
|
||||
|
|
@ -6,7 +6,7 @@ import {
|
|||
type UpgradeHost,
|
||||
type UpgradeOutput,
|
||||
type NpmInstallationHost,
|
||||
} from "../src/application/Upgrade.js";
|
||||
} from "../../../src/application/Upgrade.js";
|
||||
|
||||
class FakeUpgradeHost implements UpgradeHost {
|
||||
latest: string | undefined = "0.6.0";
|
||||
|
|
@ -1,582 +0,0 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { copyFile, mkdir, readFile, realpath, rm } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
|
||||
import { BinarySession } from "../src/application/BinarySession.js";
|
||||
import {
|
||||
JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE,
|
||||
JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE,
|
||||
JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
|
||||
JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE,
|
||||
SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE,
|
||||
} from "../src/contracts/javascriptApplicationWorkflowExamples.js";
|
||||
import { createEvidence } from "../src/domain/evidence.js";
|
||||
import { createServer } from "../src/server/createServer.js";
|
||||
import { observed } from "./fixtures/analysisExecution.js";
|
||||
import { PermissionAuthority } from "../src/application/PermissionAuthority.js";
|
||||
import { createPermissionPolicy } from "../src/domain/permissionPolicy.js";
|
||||
import { controlledReplayOutputSchema } from "../src/domain/javascriptReplay.js";
|
||||
import { nodeCharacterizationPreparationOutputSchema } from "../src/domain/nodeRuntimeCharacterization.js";
|
||||
import { analyzeJavaScriptApplication } from "../src/application/JavaScriptApplicationService.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../src/domain/javascriptApplicationAnalysis.js";
|
||||
import { permissionAuthorityForRoot } from "./fixtures/permissionAuthority.js";
|
||||
|
||||
describe("application workflow MCP parity", () => {
|
||||
it("compares exact parser export shapes through full Evidence and session IDs", async () => {
|
||||
const root = await createTestTempDirectory("rea-export-shape-mcp-");
|
||||
const leftRoot = join(root, "left");
|
||||
const rightRoot = join(root, "right");
|
||||
await Promise.all([mkdir(leftRoot), mkdir(rightRoot)]);
|
||||
await Promise.all([
|
||||
copyFile(
|
||||
resolve("tests/fixtures/replay/parser.mjs"),
|
||||
join(leftRoot, "parser.mjs"),
|
||||
),
|
||||
copyFile(
|
||||
resolve("tests/fixtures/replay/parser-v2.mjs"),
|
||||
join(rightRoot, "parser.mjs"),
|
||||
),
|
||||
]);
|
||||
const authority = await permissionAuthorityForRoot(
|
||||
root,
|
||||
["investigation_input"],
|
||||
["investigation_input"],
|
||||
);
|
||||
const [left, right] = await Promise.all([
|
||||
analyzeJavaScriptApplication(authority, {
|
||||
input_path: leftRoot,
|
||||
approved: true,
|
||||
}),
|
||||
analyzeJavaScriptApplication(authority, {
|
||||
input_path: rightRoot,
|
||||
approved: true,
|
||||
}),
|
||||
]);
|
||||
if (!left.ok) throw left.error;
|
||||
if (!right.ok) throw right.error;
|
||||
const session = new BinarySession(() => ({
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
const server = createServer(session, session);
|
||||
const client = new Client({ name: "export-shape-test", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
const selectors = {
|
||||
left_module_path: "parser.mjs",
|
||||
left_export_name: "default",
|
||||
right_module_path: "parser.mjs",
|
||||
right_export_name: "default",
|
||||
};
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const full = await client.callTool({
|
||||
name: "compare_javascript_export_shapes",
|
||||
arguments: { left: left.value, right: right.value, ...selectors },
|
||||
});
|
||||
expect(full.isError).not.toBe(true);
|
||||
expect(full.structuredContent).toMatchObject({
|
||||
result: {
|
||||
summary: { added: 1, removed: 0, changed: 0, unknown: 0 },
|
||||
changes: [
|
||||
{
|
||||
status: "added",
|
||||
path: "/depth",
|
||||
right: { availability: "literal", value: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(session.exportEvidenceBundle().records).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
operation: "compare_javascript_export_shapes",
|
||||
predicate_type: "rea.javascript-export-shape-comparison/v1",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const byId = await client.callTool({
|
||||
name: "compare_javascript_export_shapes",
|
||||
arguments: {
|
||||
left_evidence_id: left.value.evidence_id,
|
||||
right_evidence_id: right.value.evidence_id,
|
||||
...selectors,
|
||||
},
|
||||
});
|
||||
expect(byId.isError).not.toBe(true);
|
||||
expect(byId.structuredContent).toMatchObject({
|
||||
result: {
|
||||
evidence_links: [left.value.evidence_id, right.value.evidence_id],
|
||||
changes: [expect.objectContaining({ path: "/depth" })],
|
||||
},
|
||||
});
|
||||
|
||||
const analyzed = javascriptApplicationAnalysisResultSchema.parse(
|
||||
left.value.normalized_result,
|
||||
);
|
||||
if (analyzed.schema_version !== 2)
|
||||
throw new TypeError("Expected semantic application Evidence");
|
||||
const seed = analyzed.semantic_graph.relations[0]?.source_node_id;
|
||||
if (seed === undefined)
|
||||
throw new TypeError("Expected at least one semantic relation");
|
||||
const semantic = await client.callTool({
|
||||
name: "trace_javascript_semantics",
|
||||
arguments: {
|
||||
application: left.value,
|
||||
query: {
|
||||
seed: { kind: "semantic-node", node_id: seed },
|
||||
direction: "forward-influence",
|
||||
include_ambiguous_dynamic_edges: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(semantic.isError).not.toBe(true);
|
||||
expect(semantic.structuredContent).toMatchObject({
|
||||
result: {
|
||||
schema_version: 1,
|
||||
source_evidence_id: left.value.evidence_id,
|
||||
source_graph_id: analyzed.semantic_graph.graph_id,
|
||||
summary: { retained_seed_matches: 1 },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("traces and compares authenticated graph Evidence in the session", async () => {
|
||||
const session = new BinarySession(() => ({
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
const server = createServer(session, session);
|
||||
const client = new Client({
|
||||
name: "application-workflow-test",
|
||||
version: "1",
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
let resourceListChanges = 0;
|
||||
client.setNotificationHandler(
|
||||
"notifications/resources/list_changed",
|
||||
() => {
|
||||
resourceListChanges += 1;
|
||||
},
|
||||
);
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const traced = await client.callTool({
|
||||
name: "trace_application_feature",
|
||||
arguments: JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE,
|
||||
});
|
||||
expect(traced.isError).not.toBe(true);
|
||||
expect(traced.structuredContent).toMatchObject({
|
||||
result: {
|
||||
schema_version: 1,
|
||||
coverage: { status: expect.any(String) },
|
||||
},
|
||||
});
|
||||
|
||||
const compared = await client.callTool({
|
||||
name: "compare_application_versions",
|
||||
arguments: {
|
||||
...JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE,
|
||||
unknown_registry_approved: true,
|
||||
},
|
||||
});
|
||||
expect(compared.isError).not.toBe(true);
|
||||
expect(compared.structuredContent).toMatchObject({
|
||||
result: {
|
||||
schema_version: 1,
|
||||
summary: { unknown: expect.any(Number) },
|
||||
coverage: { status: expect.any(String) },
|
||||
},
|
||||
});
|
||||
const sourceCompared = await client.callTool({
|
||||
name: "compare_source_to_bundle",
|
||||
arguments: {
|
||||
reference: SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE.reference,
|
||||
application:
|
||||
JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application,
|
||||
},
|
||||
});
|
||||
expect(sourceCompared.isError).not.toBe(true);
|
||||
expect(sourceCompared.structuredContent).toMatchObject({
|
||||
result: {
|
||||
schema_version: 1,
|
||||
reference: {
|
||||
root_sha256:
|
||||
SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE.reference.root_sha256,
|
||||
},
|
||||
scoring: { algorithm: "rea-source-to-bundle-signals/v1" },
|
||||
},
|
||||
});
|
||||
expect(session.exportEvidenceBundle().records.length).toBeGreaterThan(2);
|
||||
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const notificationsBeforeIdReuse = resourceListChanges;
|
||||
const tracedById = await client.callTool({
|
||||
name: "trace_application_feature",
|
||||
arguments: {
|
||||
...JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
|
||||
application_evidence_id:
|
||||
JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application
|
||||
.evidence_id,
|
||||
},
|
||||
});
|
||||
expect(tracedById.isError).not.toBe(true);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(resourceListChanges).toBe(notificationsBeforeIdReuse);
|
||||
|
||||
const comparedById = await client.callTool({
|
||||
name: "compare_application_versions",
|
||||
arguments: {
|
||||
...JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE,
|
||||
left_evidence_id:
|
||||
JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE.left
|
||||
.evidence_id,
|
||||
right_evidence_id:
|
||||
JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE.right
|
||||
.evidence_id,
|
||||
},
|
||||
});
|
||||
expect(comparedById).toMatchObject({
|
||||
structuredContent: {
|
||||
result: {
|
||||
evidence_links: expect.arrayContaining([
|
||||
JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE.left
|
||||
.evidence_id,
|
||||
JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE.right
|
||||
.evidence_id,
|
||||
]),
|
||||
},
|
||||
},
|
||||
});
|
||||
const sourceComparedById = await client.callTool({
|
||||
name: "compare_source_to_bundle",
|
||||
arguments: {
|
||||
...SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE,
|
||||
application_evidence_id:
|
||||
JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application
|
||||
.evidence_id,
|
||||
},
|
||||
});
|
||||
expect(sourceComparedById.isError).not.toBe(true);
|
||||
|
||||
const missing = await client.callTool({
|
||||
name: "trace_application_feature",
|
||||
arguments: JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
|
||||
});
|
||||
expect(missing).toMatchObject({
|
||||
isError: true,
|
||||
structuredContent: {
|
||||
error: { details: { reason: "missing" } },
|
||||
},
|
||||
});
|
||||
|
||||
const wrongOperation = createEvidence(
|
||||
undefined,
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{ operation: "inventory_artifact", parameters: {}, result: {} },
|
||||
);
|
||||
const wrongPredicate = createEvidence(
|
||||
undefined,
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{
|
||||
predicateType: "fixture.application/v1",
|
||||
operation: "analyze_javascript_application",
|
||||
parameters: {},
|
||||
result: {},
|
||||
},
|
||||
);
|
||||
expect(session.recordEvidence(wrongOperation).ok).toBe(true);
|
||||
expect(session.recordEvidence(wrongPredicate).ok).toBe(true);
|
||||
for (const [record, reason] of [
|
||||
[wrongOperation, "wrong_operation"],
|
||||
[wrongPredicate, "wrong_predicate"],
|
||||
] as const) {
|
||||
const rejected = await client.callTool({
|
||||
name: "trace_application_feature",
|
||||
arguments: {
|
||||
...JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
|
||||
application_evidence_id: record.evidence_id,
|
||||
},
|
||||
});
|
||||
expect(rejected).toMatchObject({
|
||||
isError: true,
|
||||
structuredContent: { error: { details: { reason } } },
|
||||
});
|
||||
}
|
||||
|
||||
const duplicateNative = await client.callTool({
|
||||
name: "trace_application_feature",
|
||||
arguments: {
|
||||
...JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE,
|
||||
native_observations: [
|
||||
JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application,
|
||||
],
|
||||
native_observation_evidence_ids: [
|
||||
JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application
|
||||
.evidence_id,
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(duplicateNative.isError).toBe(true);
|
||||
|
||||
const spoofed = await client.callTool({
|
||||
name: "trace_application_feature",
|
||||
arguments: {
|
||||
...JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE,
|
||||
application: {
|
||||
...JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application,
|
||||
provider: { id: "spoofed", name: "spoofed", version: "1" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(spoofed.isError).toBe(true);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("plans and executes the same controlled replay contract", async () => {
|
||||
const root = resolve("tests/fixtures/replay");
|
||||
const policy = {
|
||||
enabled: true,
|
||||
roots: [root],
|
||||
nodePath: process.execPath,
|
||||
bubblewrapPath: process.execPath,
|
||||
systemdRunPath: process.execPath,
|
||||
systemctlPath: process.execPath,
|
||||
shellPath: process.execPath,
|
||||
};
|
||||
const ceiling = {
|
||||
capability: "javascript_replay" as const,
|
||||
roots: [root],
|
||||
executables: [
|
||||
policy.nodePath,
|
||||
policy.bubblewrapPath,
|
||||
policy.systemdRunPath,
|
||||
policy.systemctlPath,
|
||||
policy.shellPath,
|
||||
],
|
||||
environment_names: [],
|
||||
network: "none" as const,
|
||||
mount: true,
|
||||
};
|
||||
const authority = new PermissionAuthority(
|
||||
createPermissionPolicy(
|
||||
[ceiling],
|
||||
[
|
||||
{
|
||||
...ceiling,
|
||||
grant_id: "administrator:javascript_replay",
|
||||
lifetime: "administrator",
|
||||
operation_identity: null,
|
||||
expires_at: null,
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
const session = new BinarySession(() => ({
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
const server = createServer(session, session, {
|
||||
permissionAuthority: authority,
|
||||
javascriptReplayPolicy: policy,
|
||||
javascriptReplayHost: {
|
||||
readSource: async (path) => ({
|
||||
canonicalPath: await realpath(path),
|
||||
bytes: await readFile(path),
|
||||
}),
|
||||
identifyExecutable: async (path) => ({
|
||||
path,
|
||||
version: "fixture",
|
||||
sha256: "1".repeat(64),
|
||||
}),
|
||||
identifyWorker: async () => ({
|
||||
path: "/fixture/worker.js",
|
||||
version: "fixture-worker-1",
|
||||
sha256: "2".repeat(64),
|
||||
}),
|
||||
identifyRuntimeClosure: async (path) => [
|
||||
{
|
||||
sourcePath: path,
|
||||
destinationPath: "/runtime/node",
|
||||
sha256: "1".repeat(64),
|
||||
},
|
||||
],
|
||||
seccompDigest: () => "3".repeat(64),
|
||||
probe: async () => undefined,
|
||||
},
|
||||
javascriptReplayRunner: {
|
||||
execute: async (prepared) => ({
|
||||
schema_version: 1,
|
||||
plan_digest: prepared.publicPlan.plan_digest,
|
||||
outcomes: [
|
||||
{
|
||||
case_id: "heading",
|
||||
outcome: "return",
|
||||
value: { type: "heading", text: "Title" },
|
||||
input_sha256:
|
||||
prepared.publicPlan.cases[0]?.sha256 ?? "0".repeat(64),
|
||||
output_sha256: "2".repeat(64),
|
||||
truncated: false,
|
||||
},
|
||||
],
|
||||
stderr: "",
|
||||
termination: "completed",
|
||||
cleanup: { state: "complete", residual_resources: [] },
|
||||
limitations: ["fixture runner"],
|
||||
reproducer: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const client = new Client({ name: "replay-parity-test", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
const input = {
|
||||
mode: "plan",
|
||||
left: {
|
||||
modules: [
|
||||
{
|
||||
alias: "parser",
|
||||
path: resolve(root, "parser.mjs"),
|
||||
format: "esm",
|
||||
role: "module",
|
||||
dependencies: {},
|
||||
},
|
||||
],
|
||||
entry_alias: "parser",
|
||||
entry_export: "default",
|
||||
},
|
||||
cases: [{ case_id: "heading", arguments: ["# Title"] }],
|
||||
approved: false,
|
||||
};
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const planned = await client.callTool({
|
||||
name: "run_controlled_replay",
|
||||
arguments: input,
|
||||
});
|
||||
expect(planned.isError).not.toBe(true);
|
||||
const digest = controlledReplayOutputSchema.parse(
|
||||
planned.structuredContent,
|
||||
).plan?.plan_digest;
|
||||
expect(digest).toMatch(/^[a-f0-9]{64}$/u);
|
||||
const executed = await client.callTool({
|
||||
name: "run_controlled_replay",
|
||||
arguments: {
|
||||
...input,
|
||||
mode: "execute",
|
||||
approved: true,
|
||||
plan_digest: digest,
|
||||
},
|
||||
});
|
||||
expect(executed.isError).not.toBe(true);
|
||||
expect(executed.structuredContent).toMatchObject({
|
||||
phase: "execute",
|
||||
evidence: {
|
||||
authority: "controlled-replay",
|
||||
provider: { id: "rea-javascript-replay" },
|
||||
},
|
||||
});
|
||||
expect(session.exportEvidenceBundle().records).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ authority: "controlled-replay" }),
|
||||
]),
|
||||
);
|
||||
const factoryPath = resolve(root, "sanitizer.factory.txt");
|
||||
const factoryBytes = await readFile(factoryPath);
|
||||
const factorySha256 = createHash("sha256")
|
||||
.update(factoryBytes)
|
||||
.digest("hex");
|
||||
const characterizationInput = {
|
||||
preparation_approved: true,
|
||||
selected_alias: "bundle",
|
||||
expected_effect: "pure",
|
||||
instrumentation: {
|
||||
artifact_path: factoryPath,
|
||||
artifact_sha256: factorySha256,
|
||||
selection: {
|
||||
byte_start: 0,
|
||||
byte_end: factoryBytes.byteLength,
|
||||
selected_sha256: factorySha256,
|
||||
export_name: "selected",
|
||||
},
|
||||
},
|
||||
replay: {
|
||||
mode: "plan",
|
||||
left: {
|
||||
modules: [
|
||||
{
|
||||
alias: "bundle",
|
||||
path: factoryPath,
|
||||
format: "commonjs-factory",
|
||||
role: "module",
|
||||
dependencies: {},
|
||||
},
|
||||
],
|
||||
entry_alias: "bundle",
|
||||
entry_export: "selected",
|
||||
},
|
||||
cases: [{ case_id: "heading", arguments: ["# Title"] }],
|
||||
approved: false,
|
||||
},
|
||||
};
|
||||
const characterizationPlan = await client.callTool({
|
||||
name: "prepare_node_characterization",
|
||||
arguments: characterizationInput,
|
||||
});
|
||||
expect(characterizationPlan.isError).not.toBe(true);
|
||||
const approvedPlan = nodeCharacterizationPreparationOutputSchema.parse(
|
||||
characterizationPlan.structuredContent,
|
||||
).plan;
|
||||
const characterized = await client.callTool({
|
||||
name: "execute_node_characterization",
|
||||
arguments: {
|
||||
execution_approved: true,
|
||||
approved_plan_sha256: approvedPlan.plan_sha256,
|
||||
preparation: characterizationInput,
|
||||
},
|
||||
});
|
||||
expect(characterized.isError).not.toBe(true);
|
||||
expect(characterized.structuredContent).toMatchObject({
|
||||
phase: "execution",
|
||||
evidence: {
|
||||
authority: "controlled-replay",
|
||||
provider: { id: "rea-node-characterization" },
|
||||
},
|
||||
});
|
||||
expect(session.exportEvidenceBundle().records).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
predicate_type: "rea.javascript-export-transformation/v1",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
predicate_type: "rea.runtime-characterization/v1",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,479 +0,0 @@
|
|||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { createPackageWithOptions } from "@electron/asar";
|
||||
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
|
||||
import { BinarySession } from "../src/application/BinarySession.js";
|
||||
import { ArtifactProvider } from "../src/artifacts/ArtifactProvider.js";
|
||||
import { ARTIFACT_COMPARISON_EXAMPLE } from "../src/contracts/artifactComparisonExample.js";
|
||||
import { evidenceBundleSchema } from "../src/domain/evidenceBundle.js";
|
||||
import { createServer } from "../src/server/createServer.js";
|
||||
import { observed } from "./fixtures/analysisExecution.js";
|
||||
|
||||
describe("artifact graph MCP integration", () => {
|
||||
it.each([
|
||||
{ unpacked: false, label: "embedded" },
|
||||
{ unpacked: true, label: "unpacked" },
|
||||
])(
|
||||
"returns actionable local details for an $label ASAR integrity error",
|
||||
async ({ unpacked }) => {
|
||||
const root = await createTestTempDirectory("rea-asar-integrity-mcp-");
|
||||
const source = join(root, "source");
|
||||
const original = "console.log('ok');\n";
|
||||
const changed = "console.log('no');\n";
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "main.js"), original);
|
||||
const archive = join(root, "fixture.asar");
|
||||
await createPackageWithOptions(
|
||||
source,
|
||||
archive,
|
||||
unpacked ? { unpack: "*.js" } : {},
|
||||
);
|
||||
if (unpacked) {
|
||||
await writeFile(join(`${archive}.unpacked`, "main.js"), changed);
|
||||
} else {
|
||||
const bytes = await readFile(archive);
|
||||
const contentOffset = bytes.indexOf(original);
|
||||
expect(contentOffset).toBeGreaterThanOrEqual(0);
|
||||
bytes.write(changed, contentOffset, "utf8");
|
||||
await writeFile(archive, bytes);
|
||||
}
|
||||
|
||||
const session = new BinarySession(new ArtifactProvider());
|
||||
const server = createServer(session, session);
|
||||
const client = new Client({ name: "asar-integrity-test", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: archive },
|
||||
});
|
||||
expect(opened.isError).not.toBe(true);
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "inventory_artifact",
|
||||
arguments: {},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.structuredContent).toEqual({
|
||||
error: {
|
||||
code: "artifact_integrity_mismatch",
|
||||
category: "integrity_mismatch",
|
||||
message:
|
||||
"Artifact is invalid or has changed. Get a fresh copy and try again.",
|
||||
details: {
|
||||
logical_path: "main.js",
|
||||
declared_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
|
||||
calculated_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
|
||||
unpacked,
|
||||
},
|
||||
retryable: false,
|
||||
remediation: {
|
||||
action:
|
||||
"Artifact is invalid or has changed. Get a fresh copy and try again.",
|
||||
restart_required: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
client.close(),
|
||||
server.close(),
|
||||
session.close(),
|
||||
]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("records an approved mismatch, preserves verified siblings, and never reports equivalence", async () => {
|
||||
const root = await createTestTempDirectory("rea-asar-continue-mcp-");
|
||||
const source = join(root, "source");
|
||||
await mkdir(source);
|
||||
const original = "console.log('ok');\n";
|
||||
const changed = "console.log('no');\n";
|
||||
const secondOriginal = "console.log('up');\n";
|
||||
const secondChanged = "console.log('dn');\n";
|
||||
await writeFile(join(source, "main.js"), original);
|
||||
await writeFile(join(source, "second.js"), secondOriginal);
|
||||
await writeFile(join(source, "sibling.js"), "console.log('sibling');\n");
|
||||
const archive = join(root, "fixture.asar");
|
||||
await createPackageWithOptions(source, archive, {});
|
||||
const bytes = await readFile(archive);
|
||||
const contentOffset = bytes.indexOf(original);
|
||||
const secondOffset = bytes.indexOf(secondOriginal);
|
||||
expect(contentOffset).toBeGreaterThanOrEqual(0);
|
||||
expect(secondOffset).toBeGreaterThanOrEqual(0);
|
||||
bytes.write(changed, contentOffset, "utf8");
|
||||
bytes.write(secondChanged, secondOffset, "utf8");
|
||||
await writeFile(archive, bytes);
|
||||
|
||||
const session = new BinarySession(new ArtifactProvider(false, true));
|
||||
const server = createServer(session, session);
|
||||
const client = new Client({ name: "asar-continue-test", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: archive },
|
||||
});
|
||||
const limited = await client.callTool({
|
||||
name: "inventory_artifact",
|
||||
arguments: {
|
||||
integrity_policy: "record-and-continue",
|
||||
integrity_continue_approved: true,
|
||||
max_integrity_mismatches: 1,
|
||||
},
|
||||
});
|
||||
expect(limited).toMatchObject({
|
||||
isError: true,
|
||||
structuredContent: { error: { code: "truncated" } },
|
||||
});
|
||||
const result = await client.callTool({
|
||||
name: "inventory_artifact",
|
||||
arguments: {
|
||||
integrity_policy: "record-and-continue",
|
||||
integrity_continue_approved: true,
|
||||
},
|
||||
});
|
||||
expect(result.isError).not.toBe(true);
|
||||
const compact = compactResult(result.structuredContent);
|
||||
const evidence = session.evidenceById(compact.evidence_id);
|
||||
if (evidence === undefined) throw new Error("missing inventory Evidence");
|
||||
const inventory = z
|
||||
.object({
|
||||
occurrences: z.object({
|
||||
items: z.array(
|
||||
z.object({ logical_path: z.string(), hash_status: z.string() }),
|
||||
),
|
||||
}),
|
||||
integrity_contradictions: z.array(
|
||||
z.object({
|
||||
logical_path: z.string(),
|
||||
declared_sha256: z.string(),
|
||||
observed_sha256: z.string(),
|
||||
trust: z.literal("observed-untrusted"),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.parse(compact.result);
|
||||
expect(inventory.integrity_contradictions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
logical_path: "main.js",
|
||||
trust: "observed-untrusted",
|
||||
declared_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
|
||||
observed_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
|
||||
}),
|
||||
expect.objectContaining({ logical_path: "second.js" }),
|
||||
]),
|
||||
);
|
||||
expect(inventory.integrity_contradictions).toHaveLength(2);
|
||||
expect(inventory.occurrences.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
logical_path: "main.js",
|
||||
hash_status: "mismatched",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
logical_path: "sibling.js",
|
||||
hash_status: "verified",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
logical_path: "second.js",
|
||||
hash_status: "mismatched",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const inspected = await client.callTool({
|
||||
name: "inspect_artifact",
|
||||
arguments: {
|
||||
integrity_policy: "record-and-continue",
|
||||
integrity_continue_approved: true,
|
||||
},
|
||||
});
|
||||
expect(inspected.isError).not.toBe(true);
|
||||
expect(compactResult(inspected.structuredContent).result).toMatchObject({
|
||||
contradictions: [
|
||||
expect.objectContaining({ occurrence_id: expect.any(String) }),
|
||||
expect.objectContaining({ occurrence_id: expect.any(String) }),
|
||||
],
|
||||
substeps: [
|
||||
expect.objectContaining({
|
||||
operation: "inventory_artifact",
|
||||
status: "completed",
|
||||
}),
|
||||
],
|
||||
});
|
||||
const compared = await client.callTool({
|
||||
name: "compare_artifacts",
|
||||
arguments: {
|
||||
left_evidence_ids: [evidence.evidence_id],
|
||||
right_evidence_ids: [evidence.evidence_id],
|
||||
},
|
||||
});
|
||||
expect(compared.isError).not.toBe(true);
|
||||
expect(compactResult(compared.structuredContent).result).toMatchObject({
|
||||
status: "contradiction",
|
||||
summary: { contradiction: 2 },
|
||||
changes: {
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
logical_path: "main.js",
|
||||
classification: "contradiction",
|
||||
dimensions: ["integrity"],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
logical_path: "second.js",
|
||||
classification: "contradiction",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
client.close(),
|
||||
server.close(),
|
||||
session.close(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects altered payloads that reuse session Evidence IDs", async () => {
|
||||
const session = new BinarySession(() => ({
|
||||
health: () => Promise.resolve(),
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
session.recordEvidence(ARTIFACT_COMPARISON_EXAMPLE.left);
|
||||
session.recordEvidence(ARTIFACT_COMPARISON_EXAMPLE.right);
|
||||
const server = createServer(session, session);
|
||||
const client = new Client({
|
||||
name: "artifact-authority-test",
|
||||
version: "1",
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const result = await client.callTool({
|
||||
name: "compare_artifacts",
|
||||
arguments: {
|
||||
...ARTIFACT_COMPARISON_EXAMPLE,
|
||||
left: {
|
||||
...ARTIFACT_COMPARISON_EXAMPLE.left,
|
||||
limitations: ["caller altered this record"],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(session.exportEvidenceBundle().records).toHaveLength(2);
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
client.close(),
|
||||
server.close(),
|
||||
session.close(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects comparison Evidence that is not owned by the session", async () => {
|
||||
const session = new BinarySession(() => ({
|
||||
health: () => Promise.resolve(),
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
const server = createServer(session, session);
|
||||
const client = new Client({
|
||||
name: "artifact-ownership-test",
|
||||
version: "1",
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const result = await client.callTool({
|
||||
name: "compare_artifacts",
|
||||
arguments: ARTIFACT_COMPARISON_EXAMPLE,
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(session.exportEvidenceBundle().records).toEqual([]);
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
client.close(),
|
||||
server.close(),
|
||||
session.close(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("opens archives without Hopper, compares them, and exports linked evidence", async () => {
|
||||
const directory = await createTestTempDirectory("rea-artifact-mcp-");
|
||||
const archive = join(directory, "fixture.ipa");
|
||||
const changedArchive = join(directory, "changed.ipa");
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add("Payload/Fixture.app/main.js", new TextReader("main();"));
|
||||
await writeFile(archive, await writer.close());
|
||||
const changedWriter = new ZipWriter(new Uint8ArrayWriter());
|
||||
await changedWriter.add(
|
||||
"Payload/Fixture.app/main.js",
|
||||
new TextReader("changed();"),
|
||||
);
|
||||
await writeFile(changedArchive, await changedWriter.close());
|
||||
const session = new BinarySession(new ArtifactProvider());
|
||||
const server = createServer(session, session);
|
||||
const client = new Client({ name: "artifact-mcp-test", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: archive },
|
||||
});
|
||||
expect(opened.isError).not.toBe(true);
|
||||
const inspected = await client.callTool({
|
||||
name: "inspect_artifact",
|
||||
arguments: { max_observations: 1, max_relationships: 1 },
|
||||
});
|
||||
expect(inspected.isError).not.toBe(true);
|
||||
const inspectionResult = compactResult(inspected.structuredContent);
|
||||
const inspectionEvidence = session.evidenceById(
|
||||
inspectionResult.evidence_id,
|
||||
);
|
||||
if (inspectionEvidence === undefined)
|
||||
throw new Error("missing inspection Evidence");
|
||||
const inspection = z
|
||||
.object({
|
||||
substeps: z.array(
|
||||
z.object({
|
||||
evidence_id: z.string(),
|
||||
status: z.literal("completed"),
|
||||
}),
|
||||
),
|
||||
coverage: z.object({
|
||||
status: z.literal("truncated"),
|
||||
substeps_completed: z.literal(1),
|
||||
}),
|
||||
unexplored_branches: z.array(z.object({ reason: z.string() })),
|
||||
})
|
||||
.parse(inspectionResult.result);
|
||||
expect(inspection.substeps).toHaveLength(1);
|
||||
expect(
|
||||
session.evidenceById(inspection.substeps[0]?.evidence_id ?? ""),
|
||||
).toMatchObject({ operation: "inventory_artifact" });
|
||||
expect(inspectionEvidence.evidence_links).toEqual([
|
||||
inspection.substeps[0]?.evidence_id,
|
||||
]);
|
||||
expect(inspection.unexplored_branches).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ reason: "page-limit" }),
|
||||
]),
|
||||
);
|
||||
const inventory = await client.callTool({
|
||||
name: "inventory_artifact",
|
||||
arguments: {},
|
||||
});
|
||||
expect(inventory.isError).not.toBe(true);
|
||||
const inventoryResult = compactResult(inventory.structuredContent);
|
||||
const evidence = session.evidenceById(inventoryResult.evidence_id);
|
||||
if (evidence === undefined) throw new Error("missing inventory Evidence");
|
||||
expect(evidence).toMatchObject({
|
||||
provider: { id: "rea-artifact-graph" },
|
||||
subject: { format: "ipa" },
|
||||
});
|
||||
expect(inventoryResult.result).toMatchObject({
|
||||
manifest: { root_format: "ipa" },
|
||||
});
|
||||
const openedChanged = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: changedArchive },
|
||||
});
|
||||
expect(openedChanged.isError).not.toBe(true);
|
||||
const changedInventory = await client.callTool({
|
||||
name: "inventory_artifact",
|
||||
arguments: {},
|
||||
});
|
||||
const changedResult = compactResult(changedInventory.structuredContent);
|
||||
const changedEvidence = session.evidenceById(changedResult.evidence_id);
|
||||
if (changedEvidence === undefined)
|
||||
throw new Error("missing changed Evidence");
|
||||
const compared = await client.callTool({
|
||||
name: "compare_artifacts",
|
||||
arguments: {
|
||||
left_evidence_ids: [evidence.evidence_id],
|
||||
right_evidence_ids: [changedEvidence.evidence_id],
|
||||
unknown_registry_approved: true,
|
||||
},
|
||||
});
|
||||
expect(compared.isError).not.toBe(true);
|
||||
expect(compactResult(compared.structuredContent)).toMatchObject({
|
||||
result: { status: "changed" },
|
||||
evidence_id: expect.stringMatching(/^ev_/u),
|
||||
});
|
||||
const unknowns = await client.callTool({
|
||||
name: "list_unknowns",
|
||||
arguments: { domain: "artifact-comparison" },
|
||||
});
|
||||
expect(unknowns.structuredContent).toMatchObject({
|
||||
result: {
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
unknown: expect.objectContaining({
|
||||
domain: "artifact-comparison",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
const snapshotted = await client.callTool({
|
||||
name: "snapshot_evidence_bundle",
|
||||
arguments: {},
|
||||
});
|
||||
const bundleUri = z
|
||||
.object({ result: z.object({ bundle_uri: z.string() }) })
|
||||
.parse(snapshotted.structuredContent).result.bundle_uri;
|
||||
const bundleResource = await client.readResource({ uri: bundleUri });
|
||||
const bundle = evidenceBundleSchema.parse(
|
||||
JSON.parse(
|
||||
z.object({ text: z.string() }).parse(bundleResource.contents[0]).text,
|
||||
),
|
||||
);
|
||||
expect(bundle.records).toHaveLength(6);
|
||||
expect(bundle.artifacts).toContainEqual({
|
||||
digest: { sha256: evidence.subject?.digest.sha256 },
|
||||
format: "ipa",
|
||||
architecture: null,
|
||||
});
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
client.close(),
|
||||
server.close(),
|
||||
session.close(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const compactResult = (value: unknown) =>
|
||||
z
|
||||
.object({
|
||||
result: z.unknown(),
|
||||
evidence_id: z.string(),
|
||||
evidence_uri: z.string(),
|
||||
})
|
||||
.parse(value);
|
||||
|
|
@ -1,523 +0,0 @@
|
|||
import {
|
||||
access,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createPackage, createPackageWithOptions } from "@electron/asar";
|
||||
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
|
||||
import { ArtifactProvider } from "../src/artifacts/ArtifactProvider.js";
|
||||
import { MachOSliceArtifactReader } from "../src/artifacts/MachOSliceArtifactReader.js";
|
||||
import { runProviderAnalysis } from "../src/application/DirectAnalysis.js";
|
||||
import {
|
||||
ArtifactPathRegistry,
|
||||
normalizeArtifactPath,
|
||||
} from "../src/artifacts/ArtifactPaths.js";
|
||||
import {
|
||||
artifactExtractionInputSchema,
|
||||
artifactInspectionInputSchema,
|
||||
artifactInventoryInputSchema,
|
||||
} from "../src/contracts/artifactToolContracts.js";
|
||||
import type { BinaryTarget } from "../src/domain/binaryTarget.js";
|
||||
import { parseBinaryTarget } from "../src/domain/binaryTarget.js";
|
||||
import { parseEvidence } from "../src/domain/evidence.js";
|
||||
import { ok } from "../src/domain/result.js";
|
||||
import type { NativeCommandRunner } from "../src/native/CommandRunner.js";
|
||||
import {
|
||||
artifactExtractionResultSchema,
|
||||
artifactInventoryResultSchema,
|
||||
} from "../src/domain/artifactGraph.js";
|
||||
import { artifactInspectionResultSchema } from "../src/domain/artifactInspection.js";
|
||||
|
||||
describe("artifact graph provider", () => {
|
||||
it("inventories app trees deterministically without following symlinks", async () => {
|
||||
const root = await createTestTempDirectory("rea-artifacts-");
|
||||
const app = join(root, "Fixture.app");
|
||||
await mkdir(join(app, "Contents", "Resources"), { recursive: true });
|
||||
await mkdir(join(app, "Contents", "MacOS"), { recursive: true });
|
||||
await mkdir(join(app, "Contents", "Frameworks", "Fixture.framework"), {
|
||||
recursive: true,
|
||||
});
|
||||
const machO = Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0, 0, 1]);
|
||||
await writeFile(join(app, "Contents", "MacOS", "Fixture"), machO, {
|
||||
mode: 0o755,
|
||||
});
|
||||
await writeFile(
|
||||
join(app, "Contents", "Frameworks", "Fixture.framework", "Fixture"),
|
||||
machO,
|
||||
);
|
||||
await writeFile(join(app, "Contents", "Resources", "main.js"), "main();\n");
|
||||
await writeFile(join(app, "Contents", "Resources", "main.js.MAP"), "{}\n");
|
||||
await symlink("/etc/passwd", join(app, "Contents", "Resources", "outside"));
|
||||
const client = new ArtifactProvider().createClient(
|
||||
target(app, "directory"),
|
||||
);
|
||||
const input = artifactInventoryInputSchema.parse({
|
||||
node_limit: 500,
|
||||
occurrence_limit: 500,
|
||||
edge_limit: 500,
|
||||
});
|
||||
const first = await client.execute("inventory_artifact", input);
|
||||
const second = await client.execute("inventory_artifact", input);
|
||||
expect(first.ok).toBe(true);
|
||||
expect(second.ok).toBe(true);
|
||||
if (!first.ok || !second.ok) return;
|
||||
const left = artifactInventoryResultSchema.parse(first.value.result);
|
||||
const right = artifactInventoryResultSchema.parse(second.value.result);
|
||||
expect(left.manifest).toEqual(right.manifest);
|
||||
expect(left.nodes.items.map(({ kind }) => kind)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"executable",
|
||||
"framework",
|
||||
"javascript",
|
||||
"source-map",
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
left.occurrences.items.find(({ logical_path: path }) =>
|
||||
path.endsWith("outside"),
|
||||
),
|
||||
).toMatchObject({ artifact_id: null, entry_kind: "symlink" });
|
||||
const script = left.occurrences.items.find(({ logical_path: path }) =>
|
||||
path.endsWith("main.js"),
|
||||
);
|
||||
const sourceMap = left.occurrences.items.find(({ logical_path: path }) =>
|
||||
path.endsWith("main.js.MAP"),
|
||||
);
|
||||
expect(left.edges.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
relation: "maps-source",
|
||||
parent_artifact_id: script?.artifact_id,
|
||||
child_artifact_id: sourceMap?.artifact_id,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("streams ZIP and official ASAR inventories within shared limits", async () => {
|
||||
const root = await createTestTempDirectory("rea-containers-");
|
||||
const zipPath = join(root, "fixture.apk");
|
||||
const zipWriter = new ZipWriter(new Uint8ArrayWriter());
|
||||
await zipWriter.add("assets/index.js", new TextReader("export default 1;"));
|
||||
await zipWriter.add("lib/arm64-v8a/addon.so", new TextReader("native"));
|
||||
await writeFile(zipPath, await zipWriter.close());
|
||||
const parsed = await parseBinaryTarget(zipPath);
|
||||
expect(parsed.ok && parsed.value.format).toBe("apk");
|
||||
if (!parsed.ok) return;
|
||||
const zipResult = await inventory(parsed.value);
|
||||
expect(zipResult.occurrences.total).toBe(3);
|
||||
expect(zipResult.nodes.items.map(({ kind }) => kind)).toEqual(
|
||||
expect.arrayContaining(["javascript", "dynamic-library"]),
|
||||
);
|
||||
const cliEvidence = parseEvidence(
|
||||
await runProviderAnalysis(zipPath, "inventory_artifact", {}),
|
||||
);
|
||||
expect(cliEvidence).toMatchObject({
|
||||
operation: "inventory_artifact",
|
||||
provider: { id: "rea-artifact-graph" },
|
||||
subject: { format: "apk" },
|
||||
});
|
||||
const inspectionEvidence = parseEvidence(
|
||||
await runProviderAnalysis(zipPath, "inspect_artifact", {
|
||||
max_observations: 2,
|
||||
max_relationships: 1,
|
||||
}),
|
||||
);
|
||||
const inspection = artifactInspectionResultSchema.parse(
|
||||
inspectionEvidence.normalized_result,
|
||||
);
|
||||
expect(inspection).toMatchObject({
|
||||
substeps: [
|
||||
{
|
||||
operation: "inventory_artifact",
|
||||
status: "completed",
|
||||
evidence_id: expect.stringMatching(/^ev_[a-f0-9]{64}$/u),
|
||||
},
|
||||
],
|
||||
coverage: { status: "truncated", substeps_completed: 1 },
|
||||
});
|
||||
expect(inspectionEvidence.evidence_links).toEqual(
|
||||
inspection.evidence_links,
|
||||
);
|
||||
const cancellation = new AbortController();
|
||||
cancellation.abort();
|
||||
await expect(
|
||||
runProviderAnalysis(
|
||||
zipPath,
|
||||
"inventory_artifact",
|
||||
{},
|
||||
undefined,
|
||||
cancellation.signal,
|
||||
),
|
||||
).resolves.toMatchObject({ code: "cancelled" });
|
||||
await expect(
|
||||
runProviderAnalysis(
|
||||
zipPath,
|
||||
"inspect_artifact",
|
||||
{},
|
||||
undefined,
|
||||
cancellation.signal,
|
||||
),
|
||||
).resolves.toMatchObject({ code: "cancelled" });
|
||||
|
||||
const source = join(root, "asar-source");
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "main.js"), "console.log('ok');\n");
|
||||
const asarPath = join(root, "app.asar");
|
||||
await createPackage(source, asarPath);
|
||||
const asarResult = await inventory(target(asarPath, "asar"));
|
||||
expect(asarResult.occurrences.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ logical_path: "main.js" }),
|
||||
]),
|
||||
);
|
||||
|
||||
const unpackedPath = join(root, "unpacked.asar");
|
||||
await createPackageWithOptions(source, unpackedPath, { unpack: "*.js" });
|
||||
const unpackedResult = await inventory(target(unpackedPath, "asar"));
|
||||
expect(unpackedResult.occurrences.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
logical_path: "main.js",
|
||||
hash_status: "verified",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
await writeFile(
|
||||
join(`${unpackedPath}.unpacked`, "main.js"),
|
||||
"changed();\n",
|
||||
);
|
||||
const corrupted = await new ArtifactProvider()
|
||||
.createClient(target(unpackedPath, "asar"))
|
||||
.execute("inventory_artifact", artifactInventoryInputSchema.parse({}));
|
||||
expect(corrupted).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "ArtifactOperationError",
|
||||
reason: "integrity",
|
||||
artifactDetails: {
|
||||
logicalPath: "main.js",
|
||||
unpacked: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const failedInspection = await new ArtifactProvider()
|
||||
.createClient(target(unpackedPath, "asar"))
|
||||
.execute("inspect_artifact", artifactInspectionInputSchema.parse({}));
|
||||
expect(failedInspection).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "ArtifactOperationError", reason: "integrity" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["fixture.msix", "msix"],
|
||||
["fixture.appxbundle", "appx"],
|
||||
] as const)(
|
||||
"reuses the hardened ZIP reader for %s package inventory",
|
||||
async (name, format) => {
|
||||
const root = await createTestTempDirectory("rea-windows-package-");
|
||||
const path = join(root, name);
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add(
|
||||
"Assets/main.js",
|
||||
new TextReader("export default 'windows';"),
|
||||
);
|
||||
await writer.add(
|
||||
"VFS/ProgramFilesX64/App/addon.node",
|
||||
new TextReader("native"),
|
||||
);
|
||||
await writeFile(path, await writer.close());
|
||||
|
||||
const parsed = await parseBinaryTarget(path);
|
||||
expect(parsed.ok && parsed.value).toMatchObject({
|
||||
kind: "archive",
|
||||
format,
|
||||
});
|
||||
if (!parsed.ok) return;
|
||||
const result = await inventory(parsed.value);
|
||||
expect(result.manifest.root_format).toBe(format);
|
||||
expect(result.nodes.items.map(({ kind }) => kind)).toEqual(
|
||||
expect.arrayContaining(["javascript", "native-addon"]),
|
||||
);
|
||||
const selected = result.occurrences.items.find(
|
||||
({ logical_path: logicalPath }) => logicalPath === "Assets/main.js",
|
||||
);
|
||||
expect(selected).toBeDefined();
|
||||
if (selected === undefined) return;
|
||||
const output = join(root, "output");
|
||||
const extracted = await new ArtifactProvider()
|
||||
.createClient(parsed.value)
|
||||
.execute(
|
||||
"extract_artifact",
|
||||
artifactExtractionInputSchema.parse({
|
||||
approved: true,
|
||||
output_root: output,
|
||||
occurrence_ids: [selected.occurrence_id],
|
||||
}),
|
||||
);
|
||||
expect(extracted.ok).toBe(true);
|
||||
expect(await readFile(join(output, "Assets", "main.js"), "utf8")).toBe(
|
||||
"export default 'windows';",
|
||||
);
|
||||
expect(
|
||||
parseEvidence(
|
||||
await runProviderAnalysis(path, "inventory_artifact", {
|
||||
node_limit: 10,
|
||||
occurrence_limit: 10,
|
||||
edge_limit: 10,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
operation: "inventory_artifact",
|
||||
subject: { format },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects unsafe, colliding, and over-ratio entries", async () => {
|
||||
expect(() => normalizeArtifactPath("../escape", limits())).toThrow(
|
||||
/normalized/u,
|
||||
);
|
||||
const registry = new ArtifactPathRegistry();
|
||||
registry.add("A.js", "file");
|
||||
expect(() => registry.add("a.js", "file")).toThrow(/collision/u);
|
||||
|
||||
const root = await createTestTempDirectory("rea-bomb-");
|
||||
const zipPath = join(root, "bomb.zip");
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add("zeros", new TextReader("0".repeat(100_000)));
|
||||
await writeFile(zipPath, await writer.close());
|
||||
const client = new ArtifactProvider().createClient(target(zipPath, "zip"));
|
||||
const result = await client.execute(
|
||||
"inventory_artifact",
|
||||
artifactInventoryInputSchema.parse({ max_compression_ratio: 2 }),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "ArtifactOperationError", reason: "limit" },
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts only selected occurrences through an exclusively owned output tree", async () => {
|
||||
const root = await createTestTempDirectory("rea-extract-");
|
||||
const source = join(root, "source");
|
||||
await mkdir(join(source, "assets"), { recursive: true });
|
||||
await writeFile(join(source, "assets", "selected.js"), "selected();\n");
|
||||
await writeFile(join(source, "assets", "ignored.js"), "ignored();\n");
|
||||
const targetValue = target(source, "directory");
|
||||
const graph = await inventory(targetValue);
|
||||
const selected = graph.occurrences.items.find(
|
||||
({ logical_path: path }) => path === "assets/selected.js",
|
||||
);
|
||||
expect(selected).toBeDefined();
|
||||
if (selected === undefined) return;
|
||||
const cancelledOutput = join(root, "cancelled-output");
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const cancelled = await new ArtifactProvider()
|
||||
.createClient(targetValue)
|
||||
.execute(
|
||||
"extract_artifact",
|
||||
artifactExtractionInputSchema.parse({
|
||||
approved: true,
|
||||
output_root: cancelledOutput,
|
||||
occurrence_ids: [selected.occurrence_id],
|
||||
}),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(cancelled).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "ArtifactOperationError", reason: "cancelled" },
|
||||
});
|
||||
await expect(access(cancelledOutput)).rejects.toThrow();
|
||||
const output = join(root, "output");
|
||||
const result = await new ArtifactProvider()
|
||||
.createClient(targetValue)
|
||||
.execute(
|
||||
"extract_artifact",
|
||||
artifactExtractionInputSchema.parse({
|
||||
approved: true,
|
||||
output_root: output,
|
||||
occurrence_ids: [selected.occurrence_id],
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
const firstExtraction = artifactExtractionResultSchema.parse(
|
||||
result.value.result,
|
||||
);
|
||||
expect(firstExtraction).toMatchObject({
|
||||
output_root: "$OUTPUT_ROOT",
|
||||
containment_verified: true,
|
||||
artifacts: { total: 1 },
|
||||
extraction_manifest: { output_root_alias: "$OUTPUT_ROOT" },
|
||||
});
|
||||
expect(await readFile(join(output, "assets", "selected.js"), "utf8")).toBe(
|
||||
"selected();\n",
|
||||
);
|
||||
await expect(
|
||||
access(join(output, "assets", "ignored.js")),
|
||||
).rejects.toThrow();
|
||||
|
||||
const relocatedOutput = join(root, "relocated-output");
|
||||
const relocated = await new ArtifactProvider()
|
||||
.createClient(targetValue)
|
||||
.execute(
|
||||
"extract_artifact",
|
||||
artifactExtractionInputSchema.parse({
|
||||
approved: true,
|
||||
output_root: relocatedOutput,
|
||||
occurrence_ids: [selected.occurrence_id],
|
||||
}),
|
||||
);
|
||||
expect(relocated.ok).toBe(true);
|
||||
if (!relocated.ok) return;
|
||||
expect(
|
||||
artifactExtractionResultSchema.parse(relocated.value.result)
|
||||
.extraction_manifest,
|
||||
).toEqual(firstExtraction.extraction_manifest);
|
||||
|
||||
const second = await new ArtifactProvider()
|
||||
.createClient(targetValue)
|
||||
.execute(
|
||||
"extract_artifact",
|
||||
artifactExtractionInputSchema.parse({
|
||||
approved: true,
|
||||
output_root: output,
|
||||
occurrence_ids: [selected.occurrence_id],
|
||||
}),
|
||||
);
|
||||
expect(second).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "ArtifactOperationError", reason: "path" },
|
||||
});
|
||||
expect(await readdir(root)).not.toEqual(
|
||||
expect.arrayContaining([expect.stringMatching(/^\.output\.rea-/u)]),
|
||||
);
|
||||
});
|
||||
|
||||
it("paginates wide graphs without changing manifest identity", async () => {
|
||||
const root = await createTestTempDirectory("rea-wide-");
|
||||
await Promise.all(
|
||||
Array.from({ length: 520 }, async (_, index) =>
|
||||
writeFile(
|
||||
join(root, `file-${String(index).padStart(4, "0")}.txt`),
|
||||
`${index}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
const client = new ArtifactProvider().createClient(
|
||||
target(root, "directory"),
|
||||
);
|
||||
const [first, secondPage] = await Promise.all([
|
||||
client.execute(
|
||||
"inventory_artifact",
|
||||
artifactInventoryInputSchema.parse({ occurrence_limit: 500 }),
|
||||
),
|
||||
client.execute(
|
||||
"inventory_artifact",
|
||||
artifactInventoryInputSchema.parse({
|
||||
occurrence_offset: 500,
|
||||
occurrence_limit: 500,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
expect(first.ok && secondPage.ok).toBe(true);
|
||||
if (!first.ok || !secondPage.ok) return;
|
||||
const left = artifactInventoryResultSchema.parse(first.value.result);
|
||||
const right = artifactInventoryResultSchema.parse(secondPage.value.result);
|
||||
expect(left.manifest).toEqual(right.manifest);
|
||||
expect(left.occurrences).toMatchObject({ total: 521, next_offset: 500 });
|
||||
expect(right.occurrences).toMatchObject({
|
||||
offset: 500,
|
||||
next_offset: null,
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("uses bounded native lipo metadata for universal slice ranges", async () => {
|
||||
const root = await createTestTempDirectory("rea-slices-");
|
||||
const binary = join(root, "fat");
|
||||
await writeFile(binary, Buffer.from("0123456789abcdef"));
|
||||
const runner: NativeCommandRunner = {
|
||||
run: () =>
|
||||
Promise.resolve(
|
||||
ok({
|
||||
tool: "lipo",
|
||||
executable: "/usr/bin/lipo",
|
||||
executableSha256: "1".repeat(64),
|
||||
toolVersion: null,
|
||||
versionReason: "fixture",
|
||||
arguments: ["-detailed_info", binary],
|
||||
stdout:
|
||||
"architecture x86_64\n cputype 16777223\n cpusubtype 3\n offset 0\n size 8\n align 2^2\narchitecture arm64\n cputype 16777228\n cpusubtype 0\n offset 8\n size 8\n align 2^2\n",
|
||||
stderr: "",
|
||||
stdoutBytes: 1,
|
||||
stderrBytes: 0,
|
||||
stdoutTruncated: false,
|
||||
stderrTruncated: false,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
}),
|
||||
),
|
||||
};
|
||||
const reader = new MachOSliceArtifactReader(binary, runner);
|
||||
const entries = [];
|
||||
for await (const entry of reader.entries()) entries.push(entry);
|
||||
expect(entries).toHaveLength(2);
|
||||
const secondEntry = entries[1];
|
||||
expect(secondEntry).toBeDefined();
|
||||
if (secondEntry === undefined) return;
|
||||
expect(secondEntry).toMatchObject({
|
||||
path: "slices/arm64",
|
||||
byteOffset: 8,
|
||||
declaredSize: 8,
|
||||
});
|
||||
const chunks: Buffer[] = [];
|
||||
const stream = await reader.open(secondEntry);
|
||||
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
|
||||
expect(Buffer.concat(chunks).toString()).toBe("89abcdef");
|
||||
expect(reader.provenance()).toEqual([
|
||||
expect.objectContaining({ tool: "lipo", effects: ["read"] }),
|
||||
]);
|
||||
const provenance = reader.provenance();
|
||||
if (provenance[0] !== undefined)
|
||||
Reflect.set(provenance[0], "tool", "forged");
|
||||
expect(reader.provenance()[0]?.tool).toBe("lipo");
|
||||
});
|
||||
});
|
||||
|
||||
const inventory = async (targetValue: BinaryTarget) => {
|
||||
const result = await new ArtifactProvider().createClient(targetValue).execute(
|
||||
"inventory_artifact",
|
||||
artifactInventoryInputSchema.parse({
|
||||
node_limit: 500,
|
||||
occurrence_limit: 500,
|
||||
edge_limit: 500,
|
||||
}),
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
return artifactInventoryResultSchema.parse(result.value.result);
|
||||
};
|
||||
|
||||
const target = (
|
||||
path: string,
|
||||
format: BinaryTarget["format"] | "directory",
|
||||
): BinaryTarget => ({
|
||||
path,
|
||||
sourcePath: path,
|
||||
sha256: "0".repeat(64),
|
||||
kind: format === "directory" ? "executable" : "archive",
|
||||
format: format === "directory" ? "mach-o" : format,
|
||||
});
|
||||
|
||||
const limits = () => ({ maxDepth: 20, maxPathBytes: 4_096 });
|
||||
|
|
@ -1,919 +0,0 @@
|
|||
import { rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
|
||||
import { BinarySession } from "../src/application/BinarySession.js";
|
||||
import type { AnalysisClient } from "../src/application/AnalysisProvider.js";
|
||||
import type {
|
||||
AnalysisProvider,
|
||||
CapabilityDescriptor,
|
||||
} from "../src/application/AnalysisProvider.js";
|
||||
import {
|
||||
HopperStartError,
|
||||
ProviderAdapterError,
|
||||
} from "../src/domain/errors.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 { createAnalysisProfile } from "../src/domain/analysisProfile.js";
|
||||
import { createInvestigationWorkspace } from "../src/domain/investigationWorkspace.js";
|
||||
import { observed as ok } from "./fixtures/analysisExecution.js";
|
||||
|
||||
let directory: string | undefined;
|
||||
afterEach(async () => {
|
||||
if (directory !== undefined)
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
directory = undefined;
|
||||
});
|
||||
|
||||
describe("binary session", () => {
|
||||
it("returns detached provider, target, and workspace metadata", async () => {
|
||||
const [first] = await targets();
|
||||
const session = new BinarySession(cacheProvider([]));
|
||||
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);
|
||||
|
||||
const workspace = createInvestigationWorkspace(
|
||||
"Detached workspace",
|
||||
createEvidenceBundle([]),
|
||||
[],
|
||||
);
|
||||
expect(session.retainInvestigationWorkspace(workspace)).toBe("added");
|
||||
const direct = session.investigationWorkspace(workspace.workspace_id, 1);
|
||||
if (direct !== undefined) Reflect.set(direct, "revision", 999);
|
||||
const listed = session.investigationWorkspaces()[0];
|
||||
if (listed !== undefined) Reflect.set(listed, "workspace_id", "forged");
|
||||
expect(
|
||||
session.investigationWorkspace(workspace.workspace_id, 1),
|
||||
).toMatchObject({ revision: 1, workspace_id: workspace.workspace_id });
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("runs through a non-Hopper analysis provider", async () => {
|
||||
const [first] = await targets();
|
||||
const operations: string[] = [];
|
||||
const provider: AnalysisProvider = {
|
||||
identity: () => ({ id: "fixture", name: "Fixture", version: "1" }),
|
||||
capabilities: () => [
|
||||
{
|
||||
provider: { id: "fixture", name: "Fixture", version: "1" },
|
||||
operation: "address_name",
|
||||
inputContractVersion: 1,
|
||||
outputContractVersion: 1,
|
||||
available: true,
|
||||
reason: null,
|
||||
pagination: "none",
|
||||
exhaustive: true,
|
||||
effects: {
|
||||
mutatesArtifact: false,
|
||||
launchesProcess: false,
|
||||
mayShowUi: false,
|
||||
mayAccessNetwork: false,
|
||||
mayWriteFilesystem: false,
|
||||
changesPermissions: false,
|
||||
requiresRoot: false,
|
||||
},
|
||||
limits: {
|
||||
maxResults: null,
|
||||
maxPayloadBytes: null,
|
||||
timeoutMs: null,
|
||||
},
|
||||
limitations: [],
|
||||
},
|
||||
],
|
||||
createClient: (_target, _profile, context) => {
|
||||
if (context === undefined)
|
||||
throw new Error("missing analysis run context");
|
||||
return {
|
||||
execute: (operation) => {
|
||||
operations.push(operation);
|
||||
return Promise.resolve(ok(operation));
|
||||
},
|
||||
runtimeLineageSnapshots: () => [
|
||||
{
|
||||
provider: provider.identity(),
|
||||
observation: {
|
||||
status: "verified",
|
||||
observedAt: "2026-07-22T10:00:00.000Z",
|
||||
lineage: {
|
||||
schemaVersion: 1,
|
||||
runId: context.runId,
|
||||
launcherPid: 100,
|
||||
launcherParentPid: 1,
|
||||
processGroupId: 100,
|
||||
descendants: [
|
||||
{ pid: 101, parentPid: 100, processGroupId: 100 },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
requestActivitySnapshots: () => [
|
||||
{
|
||||
provider: provider.identity(),
|
||||
active: {
|
||||
requestId: 7,
|
||||
operation: "analyze_function",
|
||||
elapsedMs: 31_000,
|
||||
timeoutMs: 30_000,
|
||||
callerState: "timed_out",
|
||||
},
|
||||
queuedRequests: 2,
|
||||
},
|
||||
],
|
||||
close: () => Promise.resolve(),
|
||||
};
|
||||
},
|
||||
};
|
||||
const session = new BinarySession(provider);
|
||||
expect((await session.open(first)).ok).toBe(true);
|
||||
expect(await session.execute("address_name", {})).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
result: "address_name",
|
||||
rawResult: "address_name",
|
||||
provider: {
|
||||
id: "fixture",
|
||||
name: "Fixture analysis provider",
|
||||
version: "1",
|
||||
},
|
||||
limitations: [],
|
||||
locations: [],
|
||||
subject: null,
|
||||
},
|
||||
});
|
||||
expect(provider.identity().id).toBe("fixture");
|
||||
expect(provider.capabilities()[0]?.operation).toBe("address_name");
|
||||
expect(session.status()).toMatchObject({
|
||||
provider: { id: "fixture", name: "Fixture", version: "1" },
|
||||
providers: [{ id: "fixture", name: "Fixture", version: "1" }],
|
||||
capabilities: [
|
||||
{
|
||||
operation: "address_name",
|
||||
available: true,
|
||||
reason: null,
|
||||
effects: {
|
||||
mutates_artifact: false,
|
||||
launches_process: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
analysis_run: {
|
||||
run_id: expect.stringMatching(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u,
|
||||
),
|
||||
process_lineage: {
|
||||
status: "snapshots",
|
||||
snapshots: [
|
||||
{
|
||||
provider: { id: "fixture", name: "Fixture", version: "1" },
|
||||
observation: {
|
||||
status: "verified",
|
||||
observed_at: "2026-07-22T10:00:00.000Z",
|
||||
schema_version: 1,
|
||||
launcher_pid: 100,
|
||||
launcher_parent_pid: 1,
|
||||
process_group_id: 100,
|
||||
descendants: [
|
||||
{ pid: 101, parent_pid: 100, process_group_id: 100 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
analysis_activity: {
|
||||
status: "timed_out_busy",
|
||||
providers: [
|
||||
{
|
||||
provider: { id: "fixture", name: "Fixture", version: "1" },
|
||||
active: {
|
||||
request_id: 7,
|
||||
operation: "analyze_function",
|
||||
elapsed_ms: 31_000,
|
||||
timeout_ms: 30_000,
|
||||
caller_state: "timed_out",
|
||||
},
|
||||
queued_requests: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(operations).toEqual(["health", "address_name"]);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("allocates one fresh run identity per provider client lifetime", async () => {
|
||||
const [first, second] = await targets();
|
||||
const provider = cacheProvider([]);
|
||||
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 = new BinarySession(provider);
|
||||
|
||||
expect((await session.open(first)).ok).toBe(true);
|
||||
expect((await session.open(first)).ok).toBe(true);
|
||||
expect((await session.open(second)).ok).toBe(true);
|
||||
|
||||
expect(runIds).toHaveLength(2);
|
||||
expect(new Set(runIds).size).toBe(2);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("replays exact immutable calls from a matching provider-neutral snapshot", async () => {
|
||||
const [first, second] = await targets();
|
||||
const initialCalls: string[] = [];
|
||||
const initial = new BinarySession(cacheProvider(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();
|
||||
expect(snapshot.ok).toBe(true);
|
||||
if (!snapshot.ok) return;
|
||||
expect(snapshot.value.entries).toHaveLength(1);
|
||||
await initial.close();
|
||||
|
||||
const replayCalls: string[] = [];
|
||||
const replay = new BinarySession(cacheProvider(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(["health", "set_address_name", "address_name"]);
|
||||
expect(replay.exportAnalysisSnapshot().ok).toBe(false);
|
||||
expect((await replay.open(first)).ok).toBe(true);
|
||||
expect(replay.exportAnalysisSnapshot()).toMatchObject({
|
||||
ok: true,
|
||||
value: { entries: [{ operation: "address_name" }] },
|
||||
});
|
||||
await replay.close();
|
||||
|
||||
const mismatch = new BinarySession(cacheProvider([]));
|
||||
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 = cacheProvider(profileMismatchCalls);
|
||||
const identity = profileMismatchProvider.identity();
|
||||
profileMismatchProvider.resolveAnalysisProfile = () =>
|
||||
Promise.resolve(
|
||||
resultOk({
|
||||
profile: createAnalysisProfile(
|
||||
{
|
||||
id: identity.id,
|
||||
name: identity.name,
|
||||
version: identity.version ?? "fixture-unresolved",
|
||||
},
|
||||
1,
|
||||
{ fixture: "different-profile" },
|
||||
),
|
||||
compatibility: {},
|
||||
}),
|
||||
);
|
||||
const profileMismatch = new BinarySession(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 = new BinarySession(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();
|
||||
});
|
||||
|
||||
it("does not snapshot reads that depend on the provider cursor", async () => {
|
||||
const [first] = await targets();
|
||||
const calls: string[] = [];
|
||||
const session = new BinarySession(cacheProvider(calls));
|
||||
expect((await session.open(first)).ok).toBe(true);
|
||||
expect((await session.execute("address_name", {})).ok).toBe(true);
|
||||
expect((await session.execute("address_name", {})).ok).toBe(true);
|
||||
expect(calls).toEqual(["health", "address_name", "address_name"]);
|
||||
expect(session.exportAnalysisSnapshot()).toMatchObject({
|
||||
ok: true,
|
||||
value: { entries: [] },
|
||||
});
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("publishes provider-health availability changes and resets on target switch", async () => {
|
||||
const [first, second] = await targets();
|
||||
const provider = cacheProvider([]);
|
||||
provider.createClient = () => ({
|
||||
execute: (operation) =>
|
||||
Promise.resolve(
|
||||
operation === "health"
|
||||
? ok(null)
|
||||
: err(new ProviderAdapterError("fixture", operation)),
|
||||
),
|
||||
close: () => Promise.resolve(),
|
||||
});
|
||||
const session = new BinarySession(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({
|
||||
operation: "address_name",
|
||||
available: false,
|
||||
reason: "Provider became unavailable during this session.",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
expect(changes).toBe(1);
|
||||
expect((await session.open(second)).ok).toBe(true);
|
||||
expect(session.status()).toMatchObject({
|
||||
capabilities: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
operation: "address_name",
|
||||
available: true,
|
||||
reason: null,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
expect(changes).toBe(2);
|
||||
});
|
||||
|
||||
it("isolates availability observers from execution results and session state", async () => {
|
||||
const [first] = await targets();
|
||||
const provider = cacheProvider([]);
|
||||
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(),
|
||||
});
|
||||
const session = new BinarySession(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({
|
||||
operation: "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 targets();
|
||||
const calls: string[] = [];
|
||||
const session = new BinarySession(cacheProvider(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(["health", "address_name", "address_name"]);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("returns typed unavailability without dispatching a partial provider", async () => {
|
||||
const [first] = await targets();
|
||||
const operations: string[] = [];
|
||||
const provider: AnalysisProvider = {
|
||||
identity: () => ({ id: "partial", name: "Partial", version: "1" }),
|
||||
capabilities: () => [
|
||||
{
|
||||
provider: { id: "partial", name: "Partial", version: "1" },
|
||||
operation: "address_name",
|
||||
inputContractVersion: 1,
|
||||
outputContractVersion: 1,
|
||||
available: false,
|
||||
reason: "fixture intentionally omits symbol lookup",
|
||||
pagination: "none",
|
||||
exhaustive: false,
|
||||
effects: {
|
||||
mutatesArtifact: false,
|
||||
launchesProcess: false,
|
||||
mayShowUi: false,
|
||||
mayAccessNetwork: false,
|
||||
mayWriteFilesystem: false,
|
||||
changesPermissions: false,
|
||||
requiresRoot: false,
|
||||
},
|
||||
limits: {
|
||||
maxResults: null,
|
||||
maxPayloadBytes: null,
|
||||
timeoutMs: null,
|
||||
},
|
||||
limitations: ["No symbol lookup implementation."],
|
||||
},
|
||||
],
|
||||
createClient: () => ({
|
||||
execute: (operation) => {
|
||||
operations.push(operation);
|
||||
return Promise.resolve(ok(operation));
|
||||
},
|
||||
close: () => Promise.resolve(),
|
||||
}),
|
||||
};
|
||||
const session = new BinarySession(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("requires an open binary and closes idempotently", async () => {
|
||||
const session = new BinarySession(() => client());
|
||||
expect((await session.execute("binary_overview", {})).ok).toBe(false);
|
||||
expect(await session.close()).toEqual({ ok: true, value: null });
|
||||
});
|
||||
|
||||
it("keeps the active client when a switch fails", async () => {
|
||||
directory = await createTestTempDirectory("bb-session-");
|
||||
const first = join(directory, "first.hop");
|
||||
const second = join(directory, "second.hop");
|
||||
await writeFile(first, "one");
|
||||
await writeFile(second, "two");
|
||||
let created = 0;
|
||||
const session = new BinarySession(() => 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("serializes concurrent opens and leaves the last target active", async () => {
|
||||
const [first, second] = await targets();
|
||||
const clients: TestClient[] = [];
|
||||
const session = new BinarySession(() => {
|
||||
const value = new TestClient();
|
||||
clients.push(value);
|
||||
return value;
|
||||
});
|
||||
const one = session.open(first);
|
||||
const two = session.open(second);
|
||||
expect((await one).ok).toBe(true);
|
||||
expect((await two).ok).toBe(true);
|
||||
expect(session.status()).toMatchObject({ open: true });
|
||||
expect(JSON.stringify(session.status())).toContain("second.hop");
|
||||
expect(clients[0]?.closed).toBe(1);
|
||||
});
|
||||
|
||||
it("replaces the active client when a canonical path changes contents", async () => {
|
||||
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 = new BinarySession((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();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
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 targets();
|
||||
const active = deferred<ReturnType<typeof ok>>();
|
||||
const clients: TestClient[] = [];
|
||||
const session = new BinarySession(() => {
|
||||
const value = new TestClient(
|
||||
undefined,
|
||||
false,
|
||||
clients.length === 0 ? active.promise : undefined,
|
||||
);
|
||||
clients.push(value);
|
||||
return value;
|
||||
});
|
||||
await session.open(first);
|
||||
const call = session.execute("procedure_pseudo_code", {});
|
||||
const switching = session.open(second);
|
||||
await Promise.resolve();
|
||||
expect(clients[0]?.closed).toBe(0);
|
||||
active.resolve(ok(null));
|
||||
await call;
|
||||
await switching;
|
||||
expect(clients[0]?.closed).toBe(1);
|
||||
});
|
||||
|
||||
it("cancels an open queued behind another transition without creating a client", async () => {
|
||||
const [first, second] = await targets();
|
||||
const health = deferred<ReturnType<typeof ok>>();
|
||||
let created = 0;
|
||||
const session = new BinarySession(() => {
|
||||
created += 1;
|
||||
return new TestClient(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);
|
||||
});
|
||||
|
||||
it("cancels legacy profile resolution even when the provider ignores its signal", async () => {
|
||||
const [first] = await targets();
|
||||
const provider = cacheProvider([]);
|
||||
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 TestClient();
|
||||
};
|
||||
const session = new BinarySession(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 targets();
|
||||
const health = deferred<ReturnType<typeof ok>>();
|
||||
const session = new BinarySession(() => new TestClient(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 targets();
|
||||
const clients: TestClient[] = [];
|
||||
const session = new BinarySession(() => {
|
||||
const value = new TestClient(undefined, clients.length === 1);
|
||||
clients.push(value);
|
||||
return value;
|
||||
});
|
||||
await session.open(first);
|
||||
await session.open(second);
|
||||
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 targets();
|
||||
let liveClients = 0;
|
||||
let overlapped = false;
|
||||
const session = new BinarySession(() => ({
|
||||
execute: () => {
|
||||
if (liveClients > 0) overlapped = true;
|
||||
liveClients += 1;
|
||||
return Promise.resolve(ok(null));
|
||||
},
|
||||
close: () => {
|
||||
liveClients -= 1;
|
||||
return Promise.resolve();
|
||||
},
|
||||
}));
|
||||
await session.open(first);
|
||||
await session.open(second);
|
||||
expect(overlapped).toBe(false);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("clears session state while preserving a typed provider cleanup failure", async () => {
|
||||
const [first] = await targets();
|
||||
const cleanupError = new ProviderCleanupError(
|
||||
"fixture",
|
||||
["fixture-document"],
|
||||
{ reason: "shutdown acknowledgement missing" },
|
||||
);
|
||||
const session = new BinarySession(() => ({
|
||||
execute: () => Promise.resolve(ok(null)),
|
||||
closeWithOutcome: () => Promise.resolve(err(cleanupError)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
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: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not start a replacement after the active provider cleanup is unconfirmed", async () => {
|
||||
const [first, second] = await targets();
|
||||
const cleanupError = new ProviderCleanupError(
|
||||
"fixture",
|
||||
["fixture-document"],
|
||||
{ reason: "shutdown acknowledgement missing" },
|
||||
);
|
||||
let created = 0;
|
||||
const session = new BinarySession(() => {
|
||||
created += 1;
|
||||
return {
|
||||
execute: () => Promise.resolve(ok(null)),
|
||||
closeWithOutcome: () => Promise.resolve(err(cleanupError)),
|
||||
close: () => Promise.resolve(),
|
||||
};
|
||||
});
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
const client = (fail = false): AnalysisClient => ({
|
||||
execute: () => Promise.resolve(fail ? err(new HopperStartError()) : ok(null)),
|
||||
close: () => Promise.resolve(),
|
||||
});
|
||||
|
||||
const cacheProvider = (
|
||||
calls: string[],
|
||||
mayWriteFilesystem = false,
|
||||
): AnalysisProvider => {
|
||||
const identity = {
|
||||
id: "fixture",
|
||||
name: "Fixture analysis provider",
|
||||
version: "1",
|
||||
} as const;
|
||||
return {
|
||||
identity: () => identity,
|
||||
resolveAnalysisProfile: () =>
|
||||
Promise.resolve(
|
||||
resultOk({
|
||||
profile: createAnalysisProfile(identity, 1, { fixture: true }),
|
||||
compatibility: {},
|
||||
}),
|
||||
),
|
||||
capabilities: () => [
|
||||
cacheCapability(identity, "address_name", false, mayWriteFilesystem),
|
||||
cacheCapability(identity, "set_address_name", true),
|
||||
],
|
||||
createClient: () => ({
|
||||
execute: (operation) => {
|
||||
calls.push(operation);
|
||||
return Promise.resolve(ok(operation));
|
||||
},
|
||||
close: () => Promise.resolve(),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const cacheCapability = (
|
||||
provider: CapabilityDescriptor["provider"],
|
||||
operation: "address_name" | "set_address_name",
|
||||
mutatesArtifact: boolean,
|
||||
mayWriteFilesystem = false,
|
||||
): CapabilityDescriptor => ({
|
||||
provider,
|
||||
operation,
|
||||
inputContractVersion: 1,
|
||||
outputContractVersion: 1,
|
||||
available: true,
|
||||
reason: null,
|
||||
pagination: "none",
|
||||
exhaustive: true,
|
||||
effects: {
|
||||
mutatesArtifact,
|
||||
launchesProcess: false,
|
||||
mayShowUi: false,
|
||||
mayAccessNetwork: false,
|
||||
mayWriteFilesystem,
|
||||
changesPermissions: false,
|
||||
requiresRoot: false,
|
||||
},
|
||||
limits: {
|
||||
maxResults: null,
|
||||
maxPayloadBytes: null,
|
||||
timeoutMs: null,
|
||||
},
|
||||
limitations: [],
|
||||
});
|
||||
|
||||
class TestClient implements AnalysisClient {
|
||||
closed = 0;
|
||||
constructor(
|
||||
readonly pendingHealth?: Promise<ReturnType<typeof ok>>,
|
||||
readonly failHealth = false,
|
||||
readonly pendingCall?: Promise<ReturnType<typeof ok>>,
|
||||
) {}
|
||||
execute(name: string) {
|
||||
if (name === "health")
|
||||
return this.failHealth
|
||||
? Promise.resolve(err(new HopperStartError()))
|
||||
: (this.pendingHealth ?? Promise.resolve(ok(null)));
|
||||
return this.pendingCall ?? Promise.resolve(ok(null));
|
||||
}
|
||||
close(): Promise<void> {
|
||||
this.closed += 1;
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
const targets = async (): Promise<readonly [string, string]> => {
|
||||
directory ??= await createTestTempDirectory("bb-session-");
|
||||
const first = join(directory, "first.hop");
|
||||
const second = join(directory, "second.hop");
|
||||
await writeFile(first, "one");
|
||||
await writeFile(second, "two");
|
||||
return [first, second];
|
||||
};
|
||||
|
||||
const deferred = <T>(): {
|
||||
readonly promise: Promise<T>;
|
||||
resolve(value: T): void;
|
||||
} => {
|
||||
let resolvePromise: ((value: T) => void) | undefined;
|
||||
const promise = new Promise<T>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
resolve(value) {
|
||||
resolvePromise?.(value);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -4,12 +4,12 @@ import { join } from "node:path";
|
|||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import {
|
||||
BrowserStartupError,
|
||||
waitForBrowserDevtoolsPort,
|
||||
} from "../src/browser/BrowserProcessStartup.js";
|
||||
} from "../../../src/browser/BrowserProcessStartup.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
import { expect, it } from "vitest";
|
||||
|
||||
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
|
||||
import {
|
||||
inspectWebPageInputSchema,
|
||||
listBrowserTargetsInputSchema,
|
||||
} from "../../../src/domain/browserObservation.js";
|
||||
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
|
||||
import { describeBrowser, trackBrowser } from "./cdpBrowserProvider.support.js";
|
||||
|
||||
describeBrowser("CdpBrowserProvider: discovery authorization 1", () => {
|
||||
it("lists only exact-origin pages and sanitizes URLs", async () => {
|
||||
const browser = await startFakeCdpBrowser();
|
||||
trackBrowser(browser);
|
||||
const provider = new CdpBrowserProvider();
|
||||
const result = await provider.listTargets(
|
||||
listBrowserTargetsInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
approved: true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.targets.items).toEqual([
|
||||
expect.objectContaining({
|
||||
target_id: "allowed-page",
|
||||
origin: browser.allowedOrigin,
|
||||
url: `${browser.allowedOrigin}/app?token=%5BREDACTED%5D`,
|
||||
}),
|
||||
]);
|
||||
expect(result.value.excluded).toEqual({
|
||||
disallowed_origin: 1,
|
||||
unsupported_url: 1,
|
||||
non_page: 1,
|
||||
});
|
||||
expect(JSON.stringify(result.value)).not.toContain("forbidden");
|
||||
expect(JSON.stringify(result.value)).not.toContain("page-secret");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["absolute", true, ""],
|
||||
["host/path", "host-path", ""],
|
||||
["root-relative", "root-relative", ""],
|
||||
["prefixed host/path", "prefixed", "Loading "],
|
||||
] as const)(
|
||||
"sanitizes %s transitional target titles",
|
||||
async (_label, urlShapedAllowedTitle, prefix) => {
|
||||
const browser = await startFakeCdpBrowser({ urlShapedAllowedTitle });
|
||||
trackBrowser(browser);
|
||||
const provider = new CdpBrowserProvider();
|
||||
const input = {
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
approved: true as const,
|
||||
target_id: "allowed-page",
|
||||
};
|
||||
const expected = `${prefix}${browser.allowedOrigin}/app?startup=%5BREDACTED%5D`;
|
||||
const listed = await provider.listTargets(
|
||||
listBrowserTargetsInputSchema.parse(input),
|
||||
);
|
||||
if (!listed.ok) throw listed.error;
|
||||
expect(listed.value.targets.items[0]?.title).toBe(expected);
|
||||
|
||||
const inspected = await provider.inspectPage(
|
||||
inspectWebPageInputSchema.parse({ ...input, observation_ms: 0 }),
|
||||
);
|
||||
if (!inspected.ok) throw inspected.error;
|
||||
expect(inspected.value.target.title).toBe(expected);
|
||||
expect(JSON.stringify({ listed, inspected })).not.toContain(
|
||||
"title-secret",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("uses page-scoped discovery sockets as direct target transports", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
pageScopedVersionWebSocket: true,
|
||||
omitTargetWebSocket: true,
|
||||
additionalPageWithWebSocket: true,
|
||||
additionalPageWithoutWebSocket: true,
|
||||
});
|
||||
trackBrowser(browser);
|
||||
const provider = new CdpBrowserProvider();
|
||||
const listed = await provider.listTargets(
|
||||
listBrowserTargetsInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
approved: true,
|
||||
}),
|
||||
);
|
||||
if (!listed.ok) throw listed.error;
|
||||
expect(
|
||||
listed.value.targets.items.map(({ target_id }) => target_id),
|
||||
).toEqual(["allowed-page", "allowed-page-with-socket"]);
|
||||
expect(listed.value.limitations).toContain(
|
||||
"1 otherwise allowed page target(s) lacked a validated direct CDP WebSocket and were excluded.",
|
||||
);
|
||||
|
||||
const inspected = await provider.inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
if (!inspected.ok) throw inspected.error;
|
||||
expect(inspected.value.target.target_id).toBe("allowed-page");
|
||||
expect(inspected.value.network.requests).toHaveLength(1);
|
||||
|
||||
const second = await provider.inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page-with-socket",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
if (!second.ok) throw second.error;
|
||||
expect(second.value.target.target_id).toBe("allowed-page-with-socket");
|
||||
const methods = browser.commands.map(({ method }) => method);
|
||||
expect(methods).not.toContain("Target.attachToTarget");
|
||||
expect(methods).not.toContain("Target.detachFromTarget");
|
||||
expect(
|
||||
browser.commands.every(({ sessionId }) => sessionId === undefined),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describeBrowser("CdpBrowserProvider: discovery authorization 2", () => {
|
||||
it("rejects empty browser attachment session identifiers", async () => {
|
||||
const browser = await startFakeCdpBrowser({ invalidAttachedSession: true });
|
||||
trackBrowser(browser);
|
||||
const result = await new CdpBrowserProvider().inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "BrowserObservationError", reason: "protocol_error" },
|
||||
});
|
||||
expect(browser.commands.map(({ method }) => method)).toEqual([
|
||||
"Target.attachToTarget",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects missing and disallowed page targets before attaching", async () => {
|
||||
const browser = await startFakeCdpBrowser();
|
||||
trackBrowser(browser);
|
||||
const provider = new CdpBrowserProvider();
|
||||
for (const targetId of ["missing", "disallowed-page"]) {
|
||||
const result = await provider.inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: targetId,
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "BrowserObservationError",
|
||||
reason:
|
||||
targetId === "missing" ? "target_not_found" : "target_not_allowed",
|
||||
});
|
||||
}
|
||||
expect(browser.commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns typed errors for malformed discovery and disconnects", async () => {
|
||||
const malformed = await startFakeCdpBrowser({ malformedDiscovery: true });
|
||||
trackBrowser(malformed);
|
||||
const malformedResult = await new CdpBrowserProvider().listTargets(
|
||||
listBrowserTargetsInputSchema.parse({
|
||||
cdp_endpoint: malformed.endpoint,
|
||||
allowed_origins: [malformed.allowedOrigin],
|
||||
approved: true,
|
||||
}),
|
||||
);
|
||||
expect(malformedResult).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "BrowserObservationError",
|
||||
reason: "invalid_endpoint_response",
|
||||
},
|
||||
});
|
||||
|
||||
const disconnecting = await startFakeCdpBrowser({
|
||||
closeOnMethod: "Page.getFrameTree",
|
||||
});
|
||||
trackBrowser(disconnecting);
|
||||
const disconnectedResult = await new CdpBrowserProvider().inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: disconnecting.endpoint,
|
||||
allowed_origins: [disconnecting.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
expect(disconnectedResult).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "BrowserObservationError", reason: "disconnected" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,441 @@
|
|||
import { expect, it } from "vitest";
|
||||
|
||||
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
|
||||
import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js";
|
||||
import { analyzeWebBundleInputSchema } from "../../../src/domain/webBundleAnalysis.js";
|
||||
import { discoverWebMcpToolsInputSchema } from "../../../src/domain/webMcpDiscovery.js";
|
||||
import { captureWebScreenshotInputSchema } from "../../../src/domain/webScreenshot.js";
|
||||
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
|
||||
import { describeBrowser, trackBrowser } from "./cdpBrowserProvider.support.js";
|
||||
|
||||
describeBrowser("CdpBrowserProvider: document script 1", () => {
|
||||
it("captures bounded accessibility text only after independent approval", async () => {
|
||||
const browser = await startFakeCdpBrowser();
|
||||
trackBrowser(browser);
|
||||
const result = await new CdpBrowserProvider().inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
include_accessibility_text: true,
|
||||
limits: {
|
||||
max_ax_text_field_bytes: 6,
|
||||
max_total_ax_text_bytes: 6,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.accessibility).toMatchObject({
|
||||
text_capture: {
|
||||
status: "truncated",
|
||||
retained_bytes: 6,
|
||||
truncated_fields: 2,
|
||||
},
|
||||
nodes: [expect.objectContaining({ name: "Submit" })],
|
||||
});
|
||||
expect(result.value.completeness.truncated_sections).toContain(
|
||||
"accessibility",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes allowed script source only after explicit approval", async () => {
|
||||
const browser = await startFakeCdpBrowser();
|
||||
trackBrowser(browser);
|
||||
const provider = new CdpBrowserProvider();
|
||||
const result = await provider.inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
include_script_sources: true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.scripts.items[0]?.source).toEqual(
|
||||
expect.objectContaining({
|
||||
included: true,
|
||||
artifact: expect.objectContaining({
|
||||
text: "export const observed = 'source-secret';",
|
||||
bytes: 40,
|
||||
uri: expect.stringMatching(/^rea:\/\/web-content\/sha256\//u),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(browser.commands.map((command) => command.method)).toContain(
|
||||
"Debugger.getScriptSource",
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches and validates source maps only after separate approval", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
sourceMapBody: JSON.stringify({
|
||||
version: 3,
|
||||
names: [],
|
||||
sources: ["src/main.ts"],
|
||||
sourcesContent: ["export const original = true;"],
|
||||
mappings: "AAAA",
|
||||
}),
|
||||
});
|
||||
trackBrowser(browser);
|
||||
const result = await new CdpBrowserProvider().analyzeBundle(
|
||||
analyzeWebBundleInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
source_capture_approved: true,
|
||||
observation_ms: 0,
|
||||
fetch_source_maps: true,
|
||||
source_map_fetch_approved: true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.observations.source_maps).toMatchObject({
|
||||
status: "included",
|
||||
items: [
|
||||
{
|
||||
status: "included",
|
||||
original_sources: [
|
||||
expect.objectContaining({
|
||||
source: `${browser.allowedOrigin}/src/main.ts`,
|
||||
}),
|
||||
],
|
||||
mappings: [expect.any(Object)],
|
||||
},
|
||||
],
|
||||
});
|
||||
const sourceMapRequest = browser.httpRequests.find(({ url }) =>
|
||||
url.startsWith("/app.js.map"),
|
||||
);
|
||||
expect(sourceMapRequest).toEqual({
|
||||
url: "/app.js.map?token=map-secret",
|
||||
authorization: undefined,
|
||||
cookie: undefined,
|
||||
referer: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describeBrowser("CdpBrowserProvider: document script 2", () => {
|
||||
it("discovers untrusted WebMCP declarations without registering or invoking them", async () => {
|
||||
const browser = await startFakeCdpBrowser({ webMcpTools: true });
|
||||
trackBrowser(browser);
|
||||
const result = await new CdpBrowserProvider().discoverWebMcpTools(
|
||||
discoverWebMcpToolsInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value).toMatchObject({
|
||||
status: "available",
|
||||
tools: {
|
||||
total: 1,
|
||||
items: [
|
||||
{
|
||||
name: "search_orders",
|
||||
description: "Search orders; authorization=[REDACTED]",
|
||||
declaration_kind: "declarative",
|
||||
owner_origin: browser.allowedOrigin,
|
||||
annotations: {
|
||||
read_only: true,
|
||||
untrusted_content: true,
|
||||
autosubmit: false,
|
||||
},
|
||||
trust: "page-declared-untrusted",
|
||||
registration_source: {
|
||||
url: `${browser.allowedOrigin}/app.js?token=%5BREDACTED%5D`,
|
||||
line: 12,
|
||||
column: 4,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const serialized = JSON.stringify(result.value);
|
||||
for (const secret of [
|
||||
"tool-secret",
|
||||
"schema-secret",
|
||||
"tool-source-secret",
|
||||
"private-tool-secret",
|
||||
])
|
||||
expect(serialized).not.toContain(secret);
|
||||
const methods = browser.commands.map(({ method }) => method);
|
||||
expect(methods).toContain("WebMCP.enable");
|
||||
expect(methods).not.toContain("WebMCP.invokeTool");
|
||||
expect(methods).not.toContain("Runtime.evaluate");
|
||||
});
|
||||
|
||||
it("reports WebMCP unavailable when the experimental domain is absent", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
unsupportedMethods: ["WebMCP.enable"],
|
||||
});
|
||||
trackBrowser(browser);
|
||||
const result = await new CdpBrowserProvider().discoverWebMcpTools(
|
||||
discoverWebMcpToolsInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.status).toBe("unavailable");
|
||||
expect(result.value.tools.items).toEqual([]);
|
||||
expect(result.value.completeness.unavailable_sections).toContain(
|
||||
"webmcp_tools",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects WebMCP evidence when the page leaves its approved origin", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
webMcpTools: true,
|
||||
frameUrlAfterFirstRead: "https://private.example.test/tools",
|
||||
});
|
||||
trackBrowser(browser);
|
||||
const result = await new CdpBrowserProvider().discoverWebMcpTools(
|
||||
discoverWebMcpToolsInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "BrowserObservationError",
|
||||
operation: "discover_webmcp_tools",
|
||||
reason: "target_not_allowed",
|
||||
},
|
||||
});
|
||||
expect(browser.commands.map(({ method }) => method)).not.toContain(
|
||||
"WebMCP.invokeTool",
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds WebMCP registration replay and reports dropped declarations", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
webMcpTools: true,
|
||||
extraCollections: true,
|
||||
});
|
||||
trackBrowser(browser);
|
||||
const result = await new CdpBrowserProvider().discoverWebMcpTools(
|
||||
discoverWebMcpToolsInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
max_tools: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.tools).toMatchObject({
|
||||
total: 2,
|
||||
items: [expect.any(Object)],
|
||||
});
|
||||
expect(result.value.completeness).toMatchObject({
|
||||
status: "truncated",
|
||||
dropped_events: { webmcp_tools: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describeBrowser("CdpBrowserProvider: document script 3", () => {
|
||||
it("removes WebMCP declarations after their child frame leaves scope", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
webMcpTools: true,
|
||||
extraCollections: true,
|
||||
webMcpChildLeavesScope: true,
|
||||
});
|
||||
trackBrowser(browser);
|
||||
const result = await new CdpBrowserProvider().discoverWebMcpTools(
|
||||
discoverWebMcpToolsInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
const names = result.value.tools.items.map(({ name }) => name);
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining(["search_orders", "update_order"]),
|
||||
);
|
||||
expect(names).not.toEqual(
|
||||
expect.arrayContaining(["child_tool", "escaped_child_tool"]),
|
||||
);
|
||||
expect(result.value.completeness.policy_filtered_sections).toContain(
|
||||
"webmcp_tools",
|
||||
);
|
||||
expect(JSON.stringify(result.value)).not.toContain(
|
||||
"cross-origin-child-secret",
|
||||
);
|
||||
});
|
||||
|
||||
it("captures an explicitly approved content-addressed viewport screenshot", async () => {
|
||||
const browser = await startFakeCdpBrowser();
|
||||
trackBrowser(browser);
|
||||
const result = await new CdpBrowserProvider().captureScreenshot(
|
||||
captureWebScreenshotInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
screenshot_approved: true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value).toMatchObject({
|
||||
viewport: { width: 1, height: 1 },
|
||||
artifact: {
|
||||
uri: expect.stringMatching(/^rea:\/\/web-screenshot\/sha256\//u),
|
||||
bytes: 70,
|
||||
media_type: "image/png",
|
||||
},
|
||||
});
|
||||
expect(browser.commands.map(({ method }) => method)).toContain(
|
||||
"Page.captureScreenshot",
|
||||
);
|
||||
});
|
||||
|
||||
it("discards screenshot pixels when the main frame navigates during capture", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
navigateDuringScreenshotUrl: "https://private.example.test/screenshot",
|
||||
});
|
||||
trackBrowser(browser);
|
||||
const result = await new CdpBrowserProvider().captureScreenshot(
|
||||
captureWebScreenshotInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
screenshot_approved: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "BrowserObservationError",
|
||||
operation: "capture_web_screenshot",
|
||||
reason: "target_not_allowed",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reports configured truncation without over-reading DOM or script source", async () => {
|
||||
const browser = await startFakeCdpBrowser();
|
||||
trackBrowser(browser);
|
||||
const result = await new CdpBrowserProvider().inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
include_script_sources: true,
|
||||
limits: {
|
||||
max_frames: 1,
|
||||
max_dom_nodes: 1,
|
||||
max_ax_nodes: 1,
|
||||
max_scripts: 1,
|
||||
max_resources: 1,
|
||||
max_workers: 1,
|
||||
max_storage_keys: 1,
|
||||
max_script_source_bytes: 10,
|
||||
max_total_script_source_bytes: 10,
|
||||
max_network_events: 1,
|
||||
max_console_events: 1,
|
||||
max_websocket_events: 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.completeness).toMatchObject({
|
||||
status: "truncated",
|
||||
truncated_sections: ["dom", "script_sources"],
|
||||
});
|
||||
expect(result.value.dom).toMatchObject({ total_nodes: 2 });
|
||||
expect(result.value.dom.nodes).toHaveLength(1);
|
||||
expect(result.value.scripts.items[0]?.source).toMatchObject({
|
||||
included: false,
|
||||
reason: "declared script length exceeds per-script limit",
|
||||
});
|
||||
expect(browser.commands.map(({ method }) => method)).not.toContain(
|
||||
"Debugger.getScriptSource",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describeBrowser("CdpBrowserProvider: document script 4", () => {
|
||||
it("bounds frames, resources, workers, accessibility, and storage inventories", async () => {
|
||||
const browser = await startFakeCdpBrowser({ extraCollections: true });
|
||||
trackBrowser(browser);
|
||||
const result = await new CdpBrowserProvider().inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
include_storage_keys: true,
|
||||
limits: {
|
||||
max_frames: 1,
|
||||
max_dom_nodes: 2,
|
||||
max_ax_nodes: 1,
|
||||
max_scripts: 1,
|
||||
max_resources: 1,
|
||||
max_workers: 1,
|
||||
max_storage_keys: 1,
|
||||
max_script_source_bytes: 10,
|
||||
max_total_script_source_bytes: 10,
|
||||
max_network_events: 1,
|
||||
max_console_events: 1,
|
||||
max_websocket_events: 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.completeness).toMatchObject({
|
||||
status: "truncated",
|
||||
truncated_sections: [
|
||||
"accessibility",
|
||||
"frames",
|
||||
"resources",
|
||||
"storage_keys",
|
||||
"workers",
|
||||
],
|
||||
});
|
||||
expect(result.value.frames).toHaveLength(1);
|
||||
expect(result.value.resources).toHaveLength(1);
|
||||
expect(result.value.workers).toHaveLength(1);
|
||||
expect(result.value.accessibility).toMatchObject({ total_nodes: 2 });
|
||||
expect(result.value.accessibility.nodes).toHaveLength(1);
|
||||
expect(result.value.storage.local_storage_keys).toHaveLength(1);
|
||||
expect(result.value.storage.session_storage_keys).toHaveLength(1);
|
||||
expect(result.value.storage.indexed_db_names).toHaveLength(1);
|
||||
expect(result.value.storage.cache_names).toHaveLength(1);
|
||||
expect(result.value.storage.content_fingerprints).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,484 @@
|
|||
import { expect, it } from "vitest";
|
||||
|
||||
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
|
||||
import {
|
||||
inspectWebPageInputSchema,
|
||||
listBrowserTargetsInputSchema,
|
||||
} from "../../../src/domain/browserObservation.js";
|
||||
import { analyzeWebBundleInputSchema } from "../../../src/domain/webBundleAnalysis.js";
|
||||
import { observeWebSessionInputSchema } from "../../../src/domain/browserSession.js";
|
||||
import {
|
||||
startFakeCdpBrowser,
|
||||
type FakeCdpBrowser,
|
||||
} from "../../fixtures/fakeCdpBrowser.js";
|
||||
import { describeBrowser, trackBrowser } from "./cdpBrowserProvider.support.js";
|
||||
|
||||
describeBrowser("CdpBrowserProvider: lifecycle failures 1", () => {
|
||||
it("projects direct-session cancellation onto the requested operation", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
pageScopedVersionWebSocket: true,
|
||||
omitTargetWebSocket: true,
|
||||
});
|
||||
trackBrowser(browser);
|
||||
const controller = new AbortController();
|
||||
const result = await new CdpBrowserProvider().observeSession(
|
||||
observeWebSessionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 1_000,
|
||||
}),
|
||||
{
|
||||
signal: controller.signal,
|
||||
progress: {
|
||||
report(update) {
|
||||
if (update.completed === 1) controller.abort();
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
operation: "observe_web_session",
|
||||
},
|
||||
});
|
||||
const methods = browser.commands.map(({ method }) => method);
|
||||
expect(methods).not.toContain("Target.attachToTarget");
|
||||
expect(methods).not.toContain("Target.detachFromTarget");
|
||||
expect(methods).not.toContain("Target.closeTarget");
|
||||
expect(methods).not.toContain("Browser.close");
|
||||
});
|
||||
|
||||
it("projects capture-window cancellation onto bundle analysis", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
pageScopedVersionWebSocket: true,
|
||||
omitTargetWebSocket: true,
|
||||
});
|
||||
trackBrowser(browser);
|
||||
const controller = new AbortController();
|
||||
const result = await new CdpBrowserProvider().analyzeBundle(
|
||||
analyzeWebBundleInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
source_capture_approved: true,
|
||||
observation_ms: 1_000,
|
||||
}),
|
||||
{
|
||||
signal: controller.signal,
|
||||
progress: {
|
||||
report(update) {
|
||||
if (update.completed === 2) controller.abort();
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
operation: "analyze_web_bundle",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("observes user-driven reload, SPA, redirect, failure, and lifecycle events", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
sessionTimeline: "same_origin",
|
||||
});
|
||||
trackBrowser(browser);
|
||||
const result = await new CdpBrowserProvider().observeSession(
|
||||
observeWebSessionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 20,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.window.end_reason).toBe("window_elapsed");
|
||||
expect(result.value.timeline.map(({ type }) => type)).toEqual([
|
||||
"navigation_requested",
|
||||
"same_origin_reload",
|
||||
"same_document_navigation",
|
||||
"redirect",
|
||||
"load_failed",
|
||||
"lifecycle",
|
||||
]);
|
||||
expect(result.value.timeline).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "same_document_navigation",
|
||||
url: `${browser.allowedOrigin}/reloaded?token=%5BREDACTED%5D`,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "load_failed",
|
||||
detail: "net::ERR_CONNECTION_REFUSED",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(result.value.completeness.status).toBe("attach_limited");
|
||||
expect(JSON.stringify(result.value)).not.toContain("session-secret");
|
||||
expect(JSON.stringify(result.value)).not.toContain("redirect-secret");
|
||||
expect(browser.commands.map(({ method }) => method)).toContain(
|
||||
"Target.detachFromTarget",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describeBrowser("CdpBrowserProvider: lifecycle failures 2", () => {
|
||||
it("ends a browser session when its flat target session detaches", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
sessionTimeline: "target_detached",
|
||||
});
|
||||
trackBrowser(browser);
|
||||
const result = await new CdpBrowserProvider().observeSession(
|
||||
observeWebSessionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 1_000,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.window.end_reason).toBe("target_terminated");
|
||||
expect(result.value.timeline.at(-1)).toMatchObject({
|
||||
type: "target_terminated",
|
||||
detail: "target_terminated",
|
||||
});
|
||||
});
|
||||
|
||||
it("ends a direct page session when its transport disconnects", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
pageScopedVersionWebSocket: true,
|
||||
omitTargetWebSocket: true,
|
||||
closeAfterMethod: "Page.setLifecycleEventsEnabled",
|
||||
});
|
||||
trackBrowser(browser);
|
||||
const result = await new CdpBrowserProvider().observeSession(
|
||||
observeWebSessionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 1_000,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.window.end_reason).toBe("target_terminated");
|
||||
expect(result.value.timeline.at(-1)).toMatchObject({
|
||||
type: "target_terminated",
|
||||
detail: "target_terminated",
|
||||
});
|
||||
const methods = browser.commands.map(({ method }) => method);
|
||||
expect(methods).not.toContain("Target.attachToTarget");
|
||||
expect(methods).not.toContain("Target.detachFromTarget");
|
||||
expect(methods).not.toContain("Target.closeTarget");
|
||||
});
|
||||
|
||||
it("ends immediately when a redirect leaves policy without exposing its URL", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
sessionTimeline: "outside_policy",
|
||||
});
|
||||
trackBrowser(browser);
|
||||
const result = await new CdpBrowserProvider().observeSession(
|
||||
observeWebSessionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 1_000,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.window.end_reason).toBe("target_left_scope");
|
||||
expect(result.value.timeline.at(-1)).toMatchObject({
|
||||
type: "redirect",
|
||||
url: null,
|
||||
destination_scope: "outside_policy",
|
||||
});
|
||||
expect(result.value.completeness).toMatchObject({
|
||||
status: "policy_filtered",
|
||||
policy_filtered_sections: ["timeline"],
|
||||
excluded: [
|
||||
{
|
||||
section: "timeline",
|
||||
reason: "out_of_target_scope",
|
||||
count: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(result.value)).not.toContain("private.example.test");
|
||||
});
|
||||
|
||||
it("fails closed when the final session frame leaves policy without an event", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
frameUrlAfterFirstRead: "https://private.example.test/final",
|
||||
});
|
||||
trackBrowser(browser);
|
||||
const result = await new CdpBrowserProvider().observeSession(
|
||||
observeWebSessionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.window.end_reason).toBe("target_left_scope");
|
||||
expect(result.value.target.final_url).toBeNull();
|
||||
expect(result.value.completeness).toMatchObject({
|
||||
status: "policy_filtered",
|
||||
policy_filtered_sections: ["timeline"],
|
||||
});
|
||||
expect(JSON.stringify(result.value)).not.toContain("private.example.test");
|
||||
});
|
||||
});
|
||||
|
||||
describeBrowser("CdpBrowserProvider: lifecycle failures 3", () => {
|
||||
it("degrades optional domains but propagates protocol and payload failures", async () => {
|
||||
const optional = await startFakeCdpBrowser({
|
||||
unsupportedMethods: ["Accessibility.getFullAXTree"],
|
||||
});
|
||||
trackBrowser(optional);
|
||||
const degraded = await new CdpBrowserProvider().inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: optional.endpoint,
|
||||
allowed_origins: [optional.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
if (!degraded.ok) throw degraded.error;
|
||||
expect(degraded.value.accessibility.nodes).toEqual([]);
|
||||
expect(degraded.value.accessibility.text_capture.status).toBe(
|
||||
"unavailable",
|
||||
);
|
||||
expect(degraded.value.completeness.unavailable_sections).toContain(
|
||||
"accessibility",
|
||||
);
|
||||
expect(degraded.value.limitations).toContain(
|
||||
"Accessibility.getFullAXTree was unavailable from this browser target.",
|
||||
);
|
||||
|
||||
for (const [options, reason] of [
|
||||
[{ oversizedDiscovery: true }, "payload_limit"],
|
||||
[{ invalidBrowserWebSocket: true }, "invalid_endpoint_response"],
|
||||
] as const) {
|
||||
const failed = await startFakeCdpBrowser(options);
|
||||
trackBrowser(failed);
|
||||
const result = await new CdpBrowserProvider().listTargets(
|
||||
listBrowserTargetsInputSchema.parse({
|
||||
cdp_endpoint: failed.endpoint,
|
||||
allowed_origins: [failed.allowedOrigin],
|
||||
approved: true,
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "BrowserObservationError",
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
const malformed = await startFakeCdpBrowser({
|
||||
malformedMessageOnMethod: "Page.getFrameTree",
|
||||
});
|
||||
trackBrowser(malformed);
|
||||
const protocol = await new CdpBrowserProvider().inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: malformed.endpoint,
|
||||
allowed_origins: [malformed.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
expect(protocol).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "BrowserObservationError", reason: "protocol_error" },
|
||||
});
|
||||
|
||||
const malformedEvent = await startFakeCdpBrowser({
|
||||
malformedEventOnMethod: "Debugger.enable",
|
||||
});
|
||||
trackBrowser(malformedEvent);
|
||||
const eventProtocol = await new CdpBrowserProvider().inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: malformedEvent.endpoint,
|
||||
allowed_origins: [malformedEvent.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
expect(eventProtocol).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "BrowserObservationError", reason: "protocol_error" },
|
||||
});
|
||||
|
||||
const malformedEventShape = await startFakeCdpBrowser({
|
||||
malformedEventShapeOnMethod: "Debugger.enable",
|
||||
});
|
||||
trackBrowser(malformedEventShape);
|
||||
const eventShapeProtocol = await new CdpBrowserProvider().inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: malformedEventShape.endpoint,
|
||||
allowed_origins: [malformedEventShape.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
expect(eventShapeProtocol).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "BrowserObservationError", reason: "protocol_error" },
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels observation and still detaches without closing the page", async () => {
|
||||
const browser = await startFakeCdpBrowser();
|
||||
trackBrowser(browser);
|
||||
const controller = new AbortController();
|
||||
const pending = new CdpBrowserProvider().inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 1_000,
|
||||
}),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
// Wait until the attach response has been consumed and capture has begun.
|
||||
// Seeing the attach request alone races its response under worker load.
|
||||
await waitForCommand(browser, "Page.getFrameTree");
|
||||
controller.abort();
|
||||
const result = await pending;
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
operation: "inspect_web_page",
|
||||
},
|
||||
});
|
||||
const methods = browser.commands.map(({ method }) => method);
|
||||
expect(methods).toContain("Target.detachFromTarget");
|
||||
expect(methods).not.toContain("Target.closeTarget");
|
||||
expect(methods).not.toContain("Browser.close");
|
||||
});
|
||||
});
|
||||
|
||||
describeBrowser("CdpBrowserProvider: lifecycle failures 4", () => {
|
||||
it("waits for navigation commit and rechecks the attached main-frame origin", async () => {
|
||||
const transitioning = await startFakeCdpBrowser({
|
||||
transitionalFrameReads: 2,
|
||||
});
|
||||
trackBrowser(transitioning);
|
||||
const input = (browser: FakeCdpBrowser) =>
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
});
|
||||
const committed = await new CdpBrowserProvider().inspectPage(
|
||||
input(transitioning),
|
||||
);
|
||||
if (!committed.ok) throw committed.error;
|
||||
expect(committed.value.target.origin).toBe(transitioning.allowedOrigin);
|
||||
expect(
|
||||
transitioning.commands.filter(
|
||||
({ method }) => method === "Page.getFrameTree",
|
||||
),
|
||||
).toHaveLength(5);
|
||||
|
||||
const navigated = await startFakeCdpBrowser({
|
||||
attachedFrameUrl: "https://unapproved.example.test/after-attach",
|
||||
});
|
||||
trackBrowser(navigated);
|
||||
const denied = await new CdpBrowserProvider().inspectPage(input(navigated));
|
||||
expect(denied).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "BrowserObservationError", reason: "target_not_allowed" },
|
||||
});
|
||||
const methods = navigated.commands.map(({ method }) => method);
|
||||
expect(methods).toContain("Target.detachFromTarget");
|
||||
expect(methods).not.toContain("DOMSnapshot.captureSnapshot");
|
||||
expect(methods).not.toContain("Storage.getUsageAndQuota");
|
||||
|
||||
const duringObservation = await startFakeCdpBrowser({
|
||||
navigateDuringObservationUrl:
|
||||
"https://unapproved.example.test/during-observation",
|
||||
});
|
||||
trackBrowser(duringObservation);
|
||||
const interrupted = await new CdpBrowserProvider().inspectPage(
|
||||
input(duringObservation),
|
||||
);
|
||||
expect(interrupted).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "BrowserObservationError", reason: "target_not_allowed" },
|
||||
});
|
||||
expect(
|
||||
duringObservation.commands.map(({ method }) => method),
|
||||
).not.toContain("Accessibility.getFullAXTree");
|
||||
|
||||
const captureOptions: { navigateDuringCaptureUrl?: string } = {};
|
||||
const duringCapture = await startFakeCdpBrowser(captureOptions);
|
||||
captureOptions.navigateDuringCaptureUrl = `${duringCapture.allowedOrigin}/changed`;
|
||||
trackBrowser(duringCapture);
|
||||
const discarded = await new CdpBrowserProvider().inspectPage(
|
||||
input(duringCapture),
|
||||
);
|
||||
expect(discarded).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "BrowserObservationError", reason: "target_changed" },
|
||||
});
|
||||
expect(duringCapture.commands.map(({ method }) => method)).toContain(
|
||||
"Target.detachFromTarget",
|
||||
);
|
||||
|
||||
const crossOriginCapture = await startFakeCdpBrowser({
|
||||
navigateDuringCaptureUrl:
|
||||
"https://unapproved.example.test/during-capture",
|
||||
});
|
||||
trackBrowser(crossOriginCapture);
|
||||
const rejectedCapture = await new CdpBrowserProvider().inspectPage(
|
||||
input(crossOriginCapture),
|
||||
);
|
||||
expect(rejectedCapture).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "BrowserObservationError", reason: "target_not_allowed" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const waitForCommand = async (
|
||||
browser: FakeCdpBrowser,
|
||||
method: string,
|
||||
): Promise<void> => {
|
||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||
if (browser.commands.some((command) => command.method === method)) return;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
throw new Error(`Timed out waiting for fake CDP command ${method}`);
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { expect, it } from "vitest";
|
||||
|
||||
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
|
||||
import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js";
|
||||
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
|
||||
import { describeBrowser, trackBrowser } from "./cdpBrowserProvider.support.js";
|
||||
|
||||
describeBrowser("CdpBrowserProvider: network 1", () => {
|
||||
it("drops network evidence when a request redirects outside the approved origin", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
redirectToDisallowedOrigin: true,
|
||||
});
|
||||
trackBrowser(browser);
|
||||
const result = await new CdpBrowserProvider().inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.network.requests).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,387 @@
|
|||
import { expect, it } from "vitest";
|
||||
|
||||
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
|
||||
import {
|
||||
inspectWebPageInputSchema,
|
||||
webPageInspectionSchema,
|
||||
} from "../../../src/domain/browserObservation.js";
|
||||
import {
|
||||
startFakeCdpBrowser,
|
||||
type FakeCdpBrowser,
|
||||
} from "../../fixtures/fakeCdpBrowser.js";
|
||||
import { describeBrowser, trackBrowser } from "./cdpBrowserProvider.support.js";
|
||||
|
||||
describeBrowser("CdpBrowserProvider: sensitive data 1", () => {
|
||||
it("captures bounded passive evidence without retaining sensitive values", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
binaryWebSocketEvent: true,
|
||||
foreignSessionEvents: true,
|
||||
unrelatedWorker: true,
|
||||
});
|
||||
trackBrowser(browser);
|
||||
const provider = new CdpBrowserProvider();
|
||||
const result = await provider.inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
include_storage_keys: true,
|
||||
include_storage_fingerprints: true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(() => webPageInspectionSchema.parse(result.value)).not.toThrow();
|
||||
expect(result.value.frames).toHaveLength(1);
|
||||
expect(result.value.dom.nodes).toHaveLength(2);
|
||||
expect(result.value.dom.nodes[1]?.attribute_names).toEqual([
|
||||
"token",
|
||||
"href",
|
||||
"rel",
|
||||
]);
|
||||
expect(result.value.accessibility).toMatchObject({
|
||||
text_capture: {
|
||||
status: "not_approved",
|
||||
retained_bytes: 0,
|
||||
},
|
||||
nodes: [expect.objectContaining({ name: null, description: null })],
|
||||
});
|
||||
expect(result.value.scripts.items).toHaveLength(1);
|
||||
expect(result.value.scripts.items[0]?.script_key).toMatch(
|
||||
/^scr_[a-f0-9]{64}$/u,
|
||||
);
|
||||
expect(result.value.scripts.items[0]?.frame_id).toBe("frame-main");
|
||||
expect(result.value.scripts.items[0]?.source_map_url).toBe(
|
||||
`${browser.allowedOrigin}/app.js.map?token=%5BREDACTED%5D`,
|
||||
);
|
||||
expect(result.value.scripts.items[0]?.resource_reconciliation).toEqual({
|
||||
status: "exact",
|
||||
resource_key: result.value.resources[0]?.resource_key,
|
||||
});
|
||||
expect(result.value.scripts.items[0]?.source).toEqual({
|
||||
included: false,
|
||||
reason: "source capture was not approved",
|
||||
});
|
||||
expect(result.value.resources).toHaveLength(1);
|
||||
expect(result.value.network.requests).toHaveLength(1);
|
||||
expect(result.value.network.requests[0]?.initiator).toEqual({
|
||||
type: "script",
|
||||
url: `${browser.allowedOrigin}/app.js?caller=%5BREDACTED%5D`,
|
||||
line: 3,
|
||||
column: 5,
|
||||
});
|
||||
expect(result.value.network.websocket_events).toEqual([
|
||||
{
|
||||
request_id: "websocket-1",
|
||||
direction: "sent",
|
||||
opcode: 1,
|
||||
payload_bytes: Buffer.byteLength("websocket-secret"),
|
||||
payload_shape: null,
|
||||
},
|
||||
{
|
||||
request_id: "websocket-1",
|
||||
direction: "received",
|
||||
opcode: 2,
|
||||
payload_bytes: 3,
|
||||
payload_shape: null,
|
||||
},
|
||||
]);
|
||||
expect(result.value.console.events[0]?.argument_types).toEqual(["string"]);
|
||||
expect(result.value.workers).toHaveLength(1);
|
||||
expect(result.value.workers[0]).toMatchObject({
|
||||
opener_target_id: "allowed-page",
|
||||
parent_frame_id: null,
|
||||
});
|
||||
expect(result.value.metadata).toMatchObject({
|
||||
headers_allowlisted: true,
|
||||
responses: [
|
||||
{
|
||||
content_length: 321,
|
||||
content_encoding: "br",
|
||||
csp: { nonce_count: 1, hash_count: 1 },
|
||||
policies: {
|
||||
coop: "same-origin",
|
||||
coep: "require-corp",
|
||||
permissions_policy_features: ["camera", "geolocation"],
|
||||
},
|
||||
},
|
||||
],
|
||||
dom_urls: [
|
||||
{
|
||||
attribute: "href",
|
||||
url: `${browser.allowedOrigin}/agent?token=%5BREDACTED%5D`,
|
||||
destination_scope: "approved",
|
||||
},
|
||||
],
|
||||
agent_hints: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
mechanism: "link_rel",
|
||||
declaration: "mcp service-desc",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
mechanism: "dom_link_rel",
|
||||
declaration: "mcp",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
mechanism: "response_header",
|
||||
declaration: "x-model-context",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
expect(result.value.storage).toEqual(
|
||||
expect.objectContaining({
|
||||
local_storage_keys: ["public-key"],
|
||||
session_storage_keys: ["public-key"],
|
||||
indexed_db_names: ["app-db"],
|
||||
cache_names: ["assets-v1"],
|
||||
content_fingerprints: expect.arrayContaining([
|
||||
expect.objectContaining({ scope: "cookie", complete: true }),
|
||||
expect.objectContaining({ scope: "local_storage", complete: true }),
|
||||
expect.objectContaining({ scope: "session_storage", complete: true }),
|
||||
expect.objectContaining({
|
||||
scope: "indexed_db_schema",
|
||||
complete: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
scope: "indexed_db_record",
|
||||
complete: true,
|
||||
}),
|
||||
expect.objectContaining({ scope: "cache_entry", complete: true }),
|
||||
]),
|
||||
fingerprint_algorithm: "sha256",
|
||||
fingerprints_complete: true,
|
||||
values_redacted: true,
|
||||
}),
|
||||
);
|
||||
expectSensitiveValuesAbsent(result.value, browser);
|
||||
});
|
||||
});
|
||||
|
||||
describeBrowser("CdpBrowserProvider: sensitive data 2", () => {
|
||||
it("captures only approved redacted console text and value-free payload shapes", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
sensitiveShapes: true,
|
||||
binaryWebSocketEvent: true,
|
||||
});
|
||||
trackBrowser(browser);
|
||||
const result = await new CdpBrowserProvider().inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
include_console_text: true,
|
||||
console_text_approved: true,
|
||||
include_json_body_shapes: true,
|
||||
json_body_schema_approved: true,
|
||||
include_websocket_shapes: true,
|
||||
websocket_shape_approved: true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.console.events[0]?.text_capture).toEqual({
|
||||
status: "included",
|
||||
values: [
|
||||
{ argument_index: 0, type: "string", text: "authorization=[REDACTED]" },
|
||||
{ argument_index: 1, type: "number", text: "42" },
|
||||
],
|
||||
retained_bytes: 26,
|
||||
truncated_values: 0,
|
||||
});
|
||||
expect(result.value.network.requests[0]?.body_shapes).toMatchObject({
|
||||
status: "included",
|
||||
request: {
|
||||
root_type: "object",
|
||||
properties: expect.arrayContaining([
|
||||
expect.objectContaining({ path: "/token", types: ["string"] }),
|
||||
expect.objectContaining({
|
||||
path: "/filters/active",
|
||||
types: ["boolean"],
|
||||
}),
|
||||
]),
|
||||
},
|
||||
response: {
|
||||
root_type: "object",
|
||||
properties: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: "/result/token",
|
||||
types: ["string"],
|
||||
}),
|
||||
expect.objectContaining({ path: "/items/*/id", types: ["number"] }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
expect(result.value.network.websocket_events).toEqual([
|
||||
expect.objectContaining({
|
||||
opcode: 1,
|
||||
payload_shape: expect.objectContaining({
|
||||
format: "json",
|
||||
json_shape: expect.objectContaining({
|
||||
properties: expect.arrayContaining([
|
||||
expect.objectContaining({ path: "/token", types: ["string"] }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
opcode: 2,
|
||||
payload_shape: {
|
||||
format: "binary",
|
||||
json_shape: null,
|
||||
truncated: false,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const serialized = JSON.stringify(result.value);
|
||||
for (const secret of [
|
||||
"request-body-secret",
|
||||
"response-body-secret",
|
||||
"websocket-secret",
|
||||
"console-secret",
|
||||
"object-secret",
|
||||
])
|
||||
expect(serialized).not.toContain(secret);
|
||||
const methods = browser.commands.map(({ method }) => method);
|
||||
expect(methods).toContain("Network.getResponseBody");
|
||||
expect(methods).not.toContain("Runtime.getProperties");
|
||||
expect(methods).not.toContain("Runtime.callFunctionOn");
|
||||
});
|
||||
});
|
||||
|
||||
describeBrowser("CdpBrowserProvider: sensitive data 3", () => {
|
||||
it("reports independent sensitive-capture truncation at aggregate byte limits", async () => {
|
||||
const browser = await startFakeCdpBrowser({ sensitiveShapes: true });
|
||||
trackBrowser(browser);
|
||||
const result = await new CdpBrowserProvider().inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
include_console_text: true,
|
||||
console_text_approved: true,
|
||||
include_json_body_shapes: true,
|
||||
json_body_schema_approved: true,
|
||||
include_websocket_shapes: true,
|
||||
websocket_shape_approved: true,
|
||||
limits: {
|
||||
max_console_text_field_bytes: 5,
|
||||
max_total_console_text_bytes: 5,
|
||||
max_json_body_bytes: 10,
|
||||
max_total_json_body_bytes: 10,
|
||||
max_websocket_shape_bytes: 5,
|
||||
max_total_websocket_shape_bytes: 5,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.console.events[0]?.text_capture).toEqual({
|
||||
status: "truncated",
|
||||
values: [{ argument_index: 0, type: "string", text: "autho" }],
|
||||
retained_bytes: 5,
|
||||
truncated_values: 2,
|
||||
});
|
||||
expect(result.value.network.requests[0]?.body_shapes.status).toBe(
|
||||
"truncated",
|
||||
);
|
||||
expect(result.value.network.websocket_events[0]?.payload_shape).toEqual({
|
||||
format: "text",
|
||||
json_shape: null,
|
||||
truncated: true,
|
||||
});
|
||||
expect(result.value.completeness.truncated_sections).toEqual(
|
||||
expect.arrayContaining([
|
||||
"console_text",
|
||||
"json_body_shapes",
|
||||
"websocket_shapes",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed on malformed approved response and binary payload encodings", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
invalidResponseBodyBase64: true,
|
||||
binaryWebSocketEvent: true,
|
||||
invalidBinaryWebSocketEvent: true,
|
||||
});
|
||||
trackBrowser(browser);
|
||||
const result = await new CdpBrowserProvider().inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
include_json_body_shapes: true,
|
||||
json_body_schema_approved: true,
|
||||
include_websocket_shapes: true,
|
||||
websocket_shape_approved: true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.network.requests[0]?.body_shapes).toMatchObject({
|
||||
status: "partial",
|
||||
request: expect.any(Object),
|
||||
response: null,
|
||||
});
|
||||
expect(result.value.network.websocket_events[1]).toMatchObject({
|
||||
opcode: 2,
|
||||
payload_bytes: 0,
|
||||
payload_shape: { format: "binary", json_shape: null },
|
||||
});
|
||||
expect(result.value.completeness.unavailable_sections).toEqual(
|
||||
expect.arrayContaining(["json_body_shapes", "websocket_frames"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const expectSensitiveValuesAbsent = (
|
||||
inspection: ReturnType<typeof webPageInspectionSchema.parse>,
|
||||
browser: FakeCdpBrowser,
|
||||
): void => {
|
||||
const serialized = JSON.stringify(inspection);
|
||||
for (const secret of [
|
||||
"page-secret",
|
||||
"frame-secret",
|
||||
"dom-secret",
|
||||
"forbidden",
|
||||
"resource-secret",
|
||||
"script-secret",
|
||||
"inline-secret",
|
||||
"map-secret",
|
||||
"network-secret",
|
||||
"request-secret",
|
||||
"request-body-secret",
|
||||
"response-secret",
|
||||
"response-body-secret",
|
||||
"websocket-secret",
|
||||
"websocket-url-secret",
|
||||
"console-secret",
|
||||
"unknown-origin-console-secret",
|
||||
"unknown-console-value-secret",
|
||||
"storage-secret",
|
||||
"cookie-secret",
|
||||
"indexed-db-secret",
|
||||
"cache-body-secret",
|
||||
"secret-id",
|
||||
"dom-url-secret",
|
||||
"link-secret",
|
||||
"csp-secret",
|
||||
"hash-secret",
|
||||
"header-secret",
|
||||
])
|
||||
expect(serialized).not.toContain(secret);
|
||||
const methods = browser.commands.map((command) => command.method);
|
||||
expect(methods).toContain("Target.detachFromTarget");
|
||||
expect(methods).not.toContain("Browser.close");
|
||||
expect(methods).not.toContain("Target.closeTarget");
|
||||
expect(methods).not.toContain("Runtime.evaluate");
|
||||
expect(methods).not.toContain("Network.getResponseBody");
|
||||
};
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { afterEach, describe } from "vitest";
|
||||
|
||||
import type { FakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
|
||||
|
||||
const browsers: FakeCdpBrowser[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(browsers.splice(0).map(async (browser) => browser.close()));
|
||||
});
|
||||
|
||||
export const trackBrowser = (browser: FakeCdpBrowser): void => {
|
||||
browsers.push(browser);
|
||||
};
|
||||
|
||||
export const describeBrowser = describe;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { CdpCaptureCompleteness } from "../src/browser/CdpCaptureCompleteness.js";
|
||||
import { CdpCaptureCompleteness } from "../../../src/browser/CdpCaptureCompleteness.js";
|
||||
|
||||
describe("CdpCaptureCompleteness", () => {
|
||||
it("reports attach-window limitations without claiming a complete history", () => {
|
||||
|
|
@ -5,8 +5,8 @@ import {
|
|||
captureDom,
|
||||
captureFrames,
|
||||
captureResources,
|
||||
} from "../src/browser/CdpCaptureDocuments.js";
|
||||
import { inspectWebPageInputSchema } from "../src/domain/browserObservation.js";
|
||||
} from "../../../src/browser/CdpCaptureDocuments.js";
|
||||
import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js";
|
||||
|
||||
const origin = "https://app.example.test";
|
||||
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CdpConnection } from "../src/browser/CdpConnection.js";
|
||||
import { CdpConnection } from "../../../src/browser/CdpConnection.js";
|
||||
import {
|
||||
startFakeCdpBrowser,
|
||||
type FakeCdpBrowser,
|
||||
} from "./fixtures/fakeCdpBrowser.js";
|
||||
} from "../../fixtures/fakeCdpBrowser.js";
|
||||
|
||||
describe("CDP connection", () => {
|
||||
const browsers: FakeCdpBrowser[] = [];
|
||||
|
|
@ -4,33 +4,31 @@ import { pathToFileURL } from "node:url";
|
|||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { CdpElectronProvider } from "../src/browser/CdpElectronProvider.js";
|
||||
import { CdpElectronProvider } from "../../../src/browser/CdpElectronProvider.js";
|
||||
import {
|
||||
inspectElectronPageInputSchema,
|
||||
listElectronTargetsInputSchema,
|
||||
} from "../src/domain/electronObservation.js";
|
||||
} from "../../../src/domain/electronObservation.js";
|
||||
import {
|
||||
startFakeCdpBrowser,
|
||||
type FakeCdpBrowser,
|
||||
} from "./fixtures/fakeCdpBrowser.js";
|
||||
} from "../../fixtures/fakeCdpBrowser.js";
|
||||
|
||||
describe("CdpElectronProvider", () => {
|
||||
const browsers: FakeCdpBrowser[] = [];
|
||||
const temporary: string[] = [];
|
||||
const browsers: FakeCdpBrowser[] = [];
|
||||
const temporary: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
browsers.splice(0).map(async (browser) => browser.close()),
|
||||
);
|
||||
await Promise.all(
|
||||
temporary
|
||||
.splice(0)
|
||||
.map(async (path) => rm(path, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await Promise.all(browsers.splice(0).map(async (browser) => browser.close()));
|
||||
await Promise.all(
|
||||
temporary
|
||||
.splice(0)
|
||||
.map(async (path) => rm(path, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("CdpElectronProvider target access", () => {
|
||||
it("lists and inspects only canonical file targets beneath approved roots", async () => {
|
||||
const root = await electronFixture();
|
||||
const index = join(root, "index.html");
|
||||
|
|
@ -164,7 +162,9 @@ describe("CdpElectronProvider", () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CdpElectronProvider capture", () => {
|
||||
it("captures Electron script source only after separate approval", async () => {
|
||||
const root = await electronFixture();
|
||||
const browser = await startFakeCdpBrowser({
|
||||
|
|
@ -250,13 +250,13 @@ describe("CdpElectronProvider", () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
const electronFixture = async (): Promise<string> => {
|
||||
const root = await createTestTempDirectory("rea-electron-provider-");
|
||||
temporary.push(root);
|
||||
await writeFile(join(root, "index.html"), "<script src='app.js'></script>");
|
||||
await writeFile(join(root, "app.js"), "export const app = true;");
|
||||
await writeFile(join(root, "child.html"), "<div>child</div>");
|
||||
return root;
|
||||
};
|
||||
});
|
||||
|
||||
const electronFixture = async (): Promise<string> => {
|
||||
const root = await createTestTempDirectory("rea-electron-provider-");
|
||||
temporary.push(root);
|
||||
await writeFile(join(root, "index.html"), "<script src='app.js'></script>");
|
||||
await writeFile(join(root, "app.js"), "export const app = true;");
|
||||
await writeFile(join(root, "child.html"), "<div>child</div>");
|
||||
return root;
|
||||
};
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { safeResponseMetadata } from "../src/browser/CdpSafeMetadata.js";
|
||||
import { safeResponseMetadata } from "../../../src/browser/CdpSafeMetadata.js";
|
||||
|
||||
const origin = "https://app.example.test";
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ import {
|
|||
type TokenLifecycleEvent,
|
||||
type CustomProtocolCapture,
|
||||
type ProtocolFrame,
|
||||
} from "../src/domain/customProtocolCapture.js";
|
||||
} from "../../../src/domain/customProtocolCapture.js";
|
||||
|
||||
const sampleFrames: ProtocolFrame[] = [
|
||||
{
|
||||
|
|
@ -1,38 +1,30 @@
|
|||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createJavaScriptRuntimeObservationEvidence } from "../src/application/JavaScriptRuntimeObservationEvidence.js";
|
||||
import { createJavaScriptRuntimeObservationEvidence } from "../../../src/application/JavaScriptRuntimeObservationEvidence.js";
|
||||
import {
|
||||
listJavaScriptRuntimeTargets,
|
||||
observeJavaScriptRuntime,
|
||||
} from "../src/application/JavaScriptRuntimeObservationService.js";
|
||||
import { createPermissionAuthority } from "../src/application/PermissionAuthority.js";
|
||||
import { reconcileJavaScriptRuntimeEvidence } from "../src/application/JavaScriptRuntimeReconciliationService.js";
|
||||
import { V8_INSPECTOR_PROVIDER_IDENTITY } from "../src/browser/V8InspectorProvider.js";
|
||||
import { V8InspectorProvider } from "../src/browser/V8InspectorProvider.js";
|
||||
import { JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE } from "../src/contracts/javascriptRuntimeReconciliationExample.js";
|
||||
} from "../../../src/application/JavaScriptRuntimeObservationService.js";
|
||||
import { createPermissionAuthority } from "../../../src/application/PermissionAuthority.js";
|
||||
import { reconcileJavaScriptRuntimeEvidence } from "../../../src/application/JavaScriptRuntimeReconciliationService.js";
|
||||
import { V8_INSPECTOR_PROVIDER_IDENTITY } from "../../../src/browser/V8InspectorProvider.js";
|
||||
import { V8InspectorProvider } from "../../../src/browser/V8InspectorProvider.js";
|
||||
import { JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE } from "../../../src/contracts/javascriptRuntimeReconciliationExample.js";
|
||||
import type {
|
||||
JavaScriptRuntimeObservation,
|
||||
ObserveJavaScriptRuntimeInput,
|
||||
} from "../src/domain/javascriptRuntimeObservation.js";
|
||||
import { javascriptRuntimeReconciliationResultSchema } from "../src/domain/javascriptRuntimeReconciliationSchemas.js";
|
||||
} from "../../../src/domain/javascriptRuntimeObservation.js";
|
||||
import { javascriptRuntimeReconciliationResultSchema } from "../../../src/domain/javascriptRuntimeReconciliationSchemas.js";
|
||||
import type {
|
||||
PermissionCeiling,
|
||||
PermissionGrant,
|
||||
} from "../src/domain/permissionPolicy.js";
|
||||
import { startFakeV8Inspector } from "./fixtures/fakeV8Inspector.js";
|
||||
|
||||
const temporaryRoots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryRoots.splice(0).map((path) => rm(path, { recursive: true })),
|
||||
);
|
||||
});
|
||||
} from "../../../src/domain/permissionPolicy.js";
|
||||
import { startFakeV8Inspector } from "../../fixtures/fakeV8Inspector.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
describe("passive V8 Inspector provider", () => {
|
||||
test("filters target locations and never retains the excluded path", async () => {
|
||||
|
|
@ -124,7 +116,9 @@ describe("passive V8 Inspector provider", () => {
|
|||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("passive V8 Inspector evidence", () => {
|
||||
test("marks capture truncation instead of claiming complete coverage", async () => {
|
||||
const fixture = await runtimeFixture();
|
||||
const second = join(fixture.root, "second.js");
|
||||
|
|
@ -246,16 +240,14 @@ describe("passive V8 Inspector provider", () => {
|
|||
});
|
||||
|
||||
const runtimeFixture = async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-v8-runtime-"));
|
||||
temporaryRoots.push(root);
|
||||
const root = await createTestTempDirectory("rea-v8-runtime-");
|
||||
const entry = join(root, "entry.js");
|
||||
await writeFile(entry, "export const value = 1;\n");
|
||||
return { root, entry };
|
||||
};
|
||||
|
||||
const temporaryFile = async (name: string): Promise<string> => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-v8-outside-"));
|
||||
temporaryRoots.push(root);
|
||||
const root = await createTestTempDirectory("rea-v8-outside-");
|
||||
const path = join(root, name);
|
||||
await writeFile(path, "export {};\n");
|
||||
return path;
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { BrowserScenarioSessionPort } from "../src/browser/BrowserScenarioSessionPort.js";
|
||||
import { PlaywrightBrowserScenarioProvider } from "../src/browser/PlaywrightBrowserScenarioProvider.js";
|
||||
import { BrowserScenarioCaptureBudget } from "../src/browser/PlaywrightScenarioArtifacts.js";
|
||||
import { sanitizeBrowserUrl } from "../src/domain/browserObservation.js";
|
||||
import type { BrowserScenarioSessionPort } from "../../../src/browser/BrowserScenarioSessionPort.js";
|
||||
import { PlaywrightBrowserScenarioProvider } from "../../../src/browser/PlaywrightBrowserScenarioProvider.js";
|
||||
import { BrowserScenarioCaptureBudget } from "../../../src/browser/PlaywrightScenarioArtifacts.js";
|
||||
import { sanitizeBrowserUrl } from "../../../src/domain/browserObservation.js";
|
||||
import {
|
||||
browserScenarioSchema,
|
||||
type BrowserScenario,
|
||||
type BrowserScenarioAction,
|
||||
} from "../src/domain/browserScenario.js";
|
||||
} from "../../../src/domain/browserScenario.js";
|
||||
import {
|
||||
browserStepArtifactsSchema,
|
||||
type BrowserScenarioEvent,
|
||||
type BrowserStepArtifacts,
|
||||
} from "../src/domain/browserScenarioCapture.js";
|
||||
} from "../../../src/domain/browserScenarioCapture.js";
|
||||
|
||||
const scenario = (
|
||||
options: {
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { withPlaywrightExecutionBoundary } from "../src/browser/PlaywrightExecutionBoundary.js";
|
||||
import { withPlaywrightExecutionBoundary } from "../../../src/browser/PlaywrightExecutionBoundary.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../src/domain/errors.js";
|
||||
} from "../../../src/domain/errors.js";
|
||||
|
||||
describe("withPlaywrightExecutionBoundary", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { Page } from "playwright-core";
|
||||
|
||||
import { BrowserScenarioSecrets } from "../src/browser/BrowserScenarioSecrets.js";
|
||||
import { BrowserScenarioCaptureBudget } from "../src/browser/PlaywrightScenarioArtifacts.js";
|
||||
import { PlaywrightScenarioEvents } from "../src/browser/PlaywrightScenarioEvents.js";
|
||||
import { browserScenarioSchema } from "../src/domain/browserScenario.js";
|
||||
import { BrowserScenarioSecrets } from "../../../src/browser/BrowserScenarioSecrets.js";
|
||||
import { BrowserScenarioCaptureBudget } from "../../../src/browser/PlaywrightScenarioArtifacts.js";
|
||||
import { PlaywrightScenarioEvents } from "../../../src/browser/PlaywrightScenarioEvents.js";
|
||||
import { browserScenarioSchema } from "../../../src/domain/browserScenario.js";
|
||||
|
||||
describe("PlaywrightScenarioEvents", () => {
|
||||
it("bounds oversized page errors before validating captured events", () => {
|
||||
|
|
@ -10,7 +10,7 @@ import {
|
|||
protocolCaptureSchema,
|
||||
type ProtocolMessage,
|
||||
type ProtocolCapture,
|
||||
} from "../src/domain/protocolCapture.js";
|
||||
} from "../../../src/domain/protocolCapture.js";
|
||||
|
||||
describe("protocol capture", () => {
|
||||
it("classifies gRPC from content type", () => {
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { fetchWebSourceMaps } from "../src/browser/WebSourceMapFetcher.js";
|
||||
import { analyzeWebBundleInputSchema } from "../src/domain/webBundleAnalysis.js";
|
||||
import { fetchWebSourceMaps } from "../../../src/browser/WebSourceMapFetcher.js";
|
||||
import { analyzeWebBundleInputSchema } from "../../../src/domain/webBundleAnalysis.js";
|
||||
|
||||
const origin = "https://app.example.test";
|
||||
const request = {
|
||||
|
|
@ -10,7 +10,7 @@ const request = {
|
|||
fetchUrl: `${origin}/assets/app.js.map?token=secret`,
|
||||
};
|
||||
|
||||
describe("web source-map fetcher", () => {
|
||||
describe("web source-map fetching and validation", () => {
|
||||
it("fetches without credentials and derives mappings and original modules", async () => {
|
||||
const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
|
||||
const map = JSON.stringify({
|
||||
|
|
@ -157,7 +157,9 @@ describe("web source-map fetcher", () => {
|
|||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("web source-map aggregate limits", () => {
|
||||
it("bounds aggregate map count and reports omitted requests", async () => {
|
||||
const calls: string[] = [];
|
||||
const requests = [0, 1, 2].map((index) => ({
|
||||
|
|
@ -6,7 +6,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
|||
import {
|
||||
startFakeCdpBrowser,
|
||||
type FakeCdpBrowser,
|
||||
} from "./fixtures/fakeCdpBrowser.js";
|
||||
} from "../../fixtures/fakeCdpBrowser.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const browsers: FakeCdpBrowser[] = [];
|
||||
|
|
@ -117,6 +117,29 @@ describe("browser CLI parity", () => {
|
|||
alignment: { status: "aligned", aligned_steps: 2 },
|
||||
},
|
||||
});
|
||||
},
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe("browser CLI capture parity", () => {
|
||||
it(
|
||||
"returns the same analysis, session, WebMCP, screenshot, and policy contracts",
|
||||
async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
sessionTimeline: "same_origin",
|
||||
webMcpTools: true,
|
||||
sensitiveShapes: true,
|
||||
});
|
||||
browsers.push(browser);
|
||||
const environment = {
|
||||
...process.env,
|
||||
REA_BROWSER_OBSERVE_ENABLED: "true",
|
||||
REA_BROWSER_CDP_ENDPOINTS_JSON: JSON.stringify([browser.endpoint]),
|
||||
REA_BROWSER_ALLOWED_ORIGINS_JSON: JSON.stringify([
|
||||
browser.allowedOrigin,
|
||||
]),
|
||||
};
|
||||
const analyzed = await runCli(
|
||||
[
|
||||
"analyze-web-bundle",
|
||||
|
|
@ -226,7 +249,9 @@ describe("browser CLI parity", () => {
|
|||
},
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe("browser CLI approval", () => {
|
||||
it(
|
||||
"requires explicit approval before opening the CDP endpoint",
|
||||
async () => {
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { isCliOperationFailure, logCliCommand } from "../src/cliLogging.js";
|
||||
import { silentLogger } from "../src/logger.js";
|
||||
import {
|
||||
isCliOperationFailure,
|
||||
logCliCommand,
|
||||
} from "../../../src/cliLogging.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
|
||||
const originalExitCode = process.exitCode;
|
||||
|
||||
|
|
@ -1,24 +1,21 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { createPackageWithOptions } from "@electron/asar";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
import { fixtureTest } from "../../support/fixtures.js";
|
||||
|
||||
import {
|
||||
renderCliOutputArgumentError,
|
||||
sanitizeCliOutput,
|
||||
validateCliOutputArguments,
|
||||
} from "../src/cliOutput.js";
|
||||
} from "../../../src/cliOutput.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const CLI_INTEGRATION_TIMEOUT_MS = 60_000;
|
||||
const CLI_VARIANT_TIMEOUT_MS = 120_000;
|
||||
|
||||
describe("CLI output boundary", () => {
|
||||
describe("CLI output argument and sanitization boundary", () => {
|
||||
it("rejects token windows that would corrupt structured output", () => {
|
||||
for (const format of ["json", "jsonl", "yaml"] as const) {
|
||||
const validation = validateCliOutputArguments([
|
||||
|
|
@ -61,20 +58,20 @@ describe("CLI output boundary", () => {
|
|||
).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("fails before emitting a truncated JSON document", async () => {
|
||||
const execution = execFileAsync(
|
||||
process.execPath,
|
||||
["scripts/rea.mjs", "providers", "--token-limit", "5", "--json"],
|
||||
{ cwd: process.cwd() },
|
||||
);
|
||||
const failure = await execution.catch((cause: unknown) => cause);
|
||||
expect(failure).toMatchObject({ code: 1 });
|
||||
const { stdout } = failure as { readonly stdout: string };
|
||||
expect(JSON.parse(stdout)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "UNSUPPORTED_OUTPUT_COMBINATION" },
|
||||
});
|
||||
});
|
||||
fixtureTest(
|
||||
"fails before emitting a truncated JSON document",
|
||||
async ({ cli }) => {
|
||||
const result = await cli.run({
|
||||
arguments: ["providers", "--token-limit", "5", "--json"],
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "UNSUPPORTED_OUTPUT_COMBINATION" },
|
||||
});
|
||||
},
|
||||
CLI_INTEGRATION_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it("preserves normal output and sanitizes text and JSON validation errors", () => {
|
||||
expect(sanitizeCliOutput("result: ok\n")).toBe("result: ok\n");
|
||||
|
|
@ -125,34 +122,30 @@ describe("CLI output boundary", () => {
|
|||
meta: { command: "analyze" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it(
|
||||
describe("compiled CLI output boundary", () => {
|
||||
fixtureTest(
|
||||
"sanitizes a real missing-argument dispatcher failure",
|
||||
async () => {
|
||||
await expect(
|
||||
execFileAsync(process.execPath, ["scripts/rea.mjs", "analyze"], {
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
async ({ cli }) => {
|
||||
const ordinary = await cli.run({ arguments: ["analyze"] });
|
||||
expect(ordinary).toMatchObject({
|
||||
exitCode: 1,
|
||||
stdout:
|
||||
'code: VALIDATION_ERROR\nmessage: "REA could not read the command arguments. Run `rea --help`, correct the arguments, then try again."\n',
|
||||
});
|
||||
await expect(
|
||||
execFileAsync(
|
||||
process.execPath,
|
||||
["scripts/rea.mjs", "--full-output", "--json", "analyze"],
|
||||
{ cwd: process.cwd() },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
const json = await cli.run({
|
||||
arguments: ["--full-output", "--json", "analyze"],
|
||||
});
|
||||
expect(json).toMatchObject({
|
||||
exitCode: 1,
|
||||
stdout: expect.not.stringContaining("fieldErrors"),
|
||||
});
|
||||
await expect(
|
||||
execFileAsync(
|
||||
process.execPath,
|
||||
["scripts/rea.mjs", "--full-output", "analyze"],
|
||||
{ cwd: process.cwd() },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
const yaml = await cli.run({
|
||||
arguments: ["--full-output", "analyze"],
|
||||
});
|
||||
expect(yaml).toMatchObject({
|
||||
exitCode: 1,
|
||||
stdout:
|
||||
'ok: false\nerror:\n code: VALIDATION_ERROR\n message: "REA could not read the command arguments. Run `rea --help`, correct the arguments, then try again."\n',
|
||||
});
|
||||
|
|
@ -160,28 +153,22 @@ describe("CLI output boundary", () => {
|
|||
CLI_INTEGRATION_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
fixtureTest(
|
||||
"preserves artifact diagnostics in ordinary and full JSON output",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-cli-diagnostics-");
|
||||
const source = join(root, "source");
|
||||
async ({ cli, workspace }) => {
|
||||
const source = workspace.path("source");
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "main.js"), "console.log('ok');\n");
|
||||
const archive = join(root, "fixture.asar");
|
||||
const archive = workspace.path("fixture.asar");
|
||||
await createPackageWithOptions(source, archive, { unpack: "*.js" });
|
||||
await writeFile(join(`${archive}.unpacked`, "main.js"), "changed();\n");
|
||||
|
||||
for (const flags of [["--json"], ["--full-output", "--json"]]) {
|
||||
const execution = execFileAsync(process.execPath, [
|
||||
"scripts/rea.mjs",
|
||||
...flags,
|
||||
"inventory-artifact",
|
||||
archive,
|
||||
]);
|
||||
const failure = await execution.catch((cause: unknown) => cause);
|
||||
expect(failure).toMatchObject({ code: 1 });
|
||||
const { stdout } = failure as { readonly stdout: string };
|
||||
const output = JSON.stringify(JSON.parse(stdout) as unknown);
|
||||
const result = await cli.run({
|
||||
arguments: [...flags, "inventory-artifact", archive],
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = JSON.stringify(result.json);
|
||||
expect(output).toContain('"logical_path":"main.js"');
|
||||
expect(output).toMatch(/"declared_sha256":"[a-f0-9]{64}"/u);
|
||||
expect(output).toMatch(/"calculated_sha256":"[a-f0-9]{64}"/u);
|
||||
|
|
@ -191,9 +178,9 @@ describe("CLI output boundary", () => {
|
|||
CLI_INTEGRATION_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
fixtureTest(
|
||||
"keeps operation failure status independent of output controls",
|
||||
async () => {
|
||||
async ({ cli }) => {
|
||||
const variants = [
|
||||
["--format", "toon"],
|
||||
["--format", "json"],
|
||||
|
|
@ -207,43 +194,42 @@ describe("CLI output boundary", () => {
|
|||
];
|
||||
|
||||
for (const flags of variants) {
|
||||
await expect(
|
||||
execFileAsync(process.execPath, [
|
||||
"scripts/rea.mjs",
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
...flags,
|
||||
"investigate-versions",
|
||||
"/tmp/left",
|
||||
"/tmp/right",
|
||||
"/tmp/workspace.json",
|
||||
]),
|
||||
).rejects.toMatchObject({ code: 1, stdout: expect.any(String) });
|
||||
],
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
exitCode: 1,
|
||||
stdout: expect.any(String),
|
||||
});
|
||||
}
|
||||
},
|
||||
CLI_VARIANT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
fixtureTest(
|
||||
"keeps failure logs out of structured stdout",
|
||||
async () => {
|
||||
const execution = execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
"scripts/rea.mjs",
|
||||
async ({ cli }) => {
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"--json",
|
||||
"investigate-versions",
|
||||
"/tmp/left",
|
||||
"/tmp/right",
|
||||
"/tmp/workspace.json",
|
||||
],
|
||||
{ env: { ...process.env, REA_LOG_LEVEL: "error" } },
|
||||
);
|
||||
const failure = await execution.catch((cause: unknown) => cause);
|
||||
expect(failure).toMatchObject({
|
||||
code: 1,
|
||||
environment: { REA_LOG_LEVEL: "error" },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
exitCode: 1,
|
||||
stderr: expect.stringContaining('"status":"error"'),
|
||||
});
|
||||
const { stdout } = failure as { readonly stdout: string };
|
||||
expect(JSON.parse(stdout)).toMatchObject({ error: "ApprovalRequired" });
|
||||
expect(result.json).toMatchObject({ error: "ApprovalRequired" });
|
||||
},
|
||||
CLI_INTEGRATION_TIMEOUT_MS,
|
||||
);
|
||||
|
|
@ -5,13 +5,13 @@ import { promisify } from "node:util";
|
|||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import {
|
||||
readProjectPermissionStore,
|
||||
writeProjectPermissionStore,
|
||||
} from "../src/application/ProjectPermissionStore.js";
|
||||
import { approvePolicyRevocation } from "../src/cliPolicyCommands.js";
|
||||
} from "../../../src/application/ProjectPermissionStore.js";
|
||||
import { approvePolicyRevocation } from "../../../src/cliPolicyCommands.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const roots: string[] = [];
|
||||
|
|
@ -5,7 +5,7 @@ import { promisify } from "node:util";
|
|||
import { spawn } from "@lydell/node-pty";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const decisionMarker = "REA_SETUP_DECISION:";
|
||||
|
|
@ -136,7 +136,9 @@ describe("interactive setup journey", () => {
|
|||
expect(result.output).toContain("Cursor (detected)");
|
||||
expect(result.decision.approved).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("interactive setup selection and confirmation", () => {
|
||||
it("retains the bundled skill even when all agents are deselected (#399)", async () => {
|
||||
const result = await runJourney([
|
||||
step("What should REA set up?", "\r"),
|
||||
|
|
@ -241,7 +243,9 @@ describe("interactive setup journey", () => {
|
|||
expect(result.output).toContain("Nothing selected. No changes were made.");
|
||||
expect(result.decision.selectedActionIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("interactive setup completion", () => {
|
||||
it("frames completion around the capabilities that are now ready", async () => {
|
||||
const { stderr } = await execute(
|
||||
process.execPath,
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import { copyFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { fixtureTest } from "../../support/fixtures.js";
|
||||
|
||||
describe("executable dispatcher", () => {
|
||||
fixtureTest(
|
||||
"reserves production MCP routing for bare mode arguments",
|
||||
async ({ processes, workspace }) => {
|
||||
const scripts = await workspace.mkdir("scripts");
|
||||
await workspace.mkdir("dist");
|
||||
await Promise.all([
|
||||
copyFile("scripts/rea.mjs", join(scripts, "rea.mjs")),
|
||||
copyFile(
|
||||
"scripts/package-runner-bootstrap.mjs",
|
||||
join(scripts, "package-runner-bootstrap.mjs"),
|
||||
),
|
||||
workspace.write(
|
||||
"package.json",
|
||||
JSON.stringify({ type: "module", version: "1.0.0" }),
|
||||
),
|
||||
workspace.write(
|
||||
"dist/main.js",
|
||||
'export const runEntrypoint = () => process.stdout.write(JSON.stringify({ adapter: "mcp" }));\n',
|
||||
),
|
||||
workspace.write(
|
||||
"dist/cli.js",
|
||||
'export const createCli = () => ({ serve: (args) => process.stdout.write(JSON.stringify({ adapter: "cli", args })) });\n',
|
||||
),
|
||||
workspace.write(
|
||||
"dist/cliOutput.js",
|
||||
"export const sanitizeCliOutput = (output) => output; export const validateCliOutputArguments = () => ({ ok: true }); export const renderCliOutputArgumentError = () => '';\n",
|
||||
),
|
||||
workspace.write(
|
||||
"dist/mcpDoctor.js",
|
||||
'export const runProductionMcpDoctorCli = (args) => Promise.resolve({ output: JSON.stringify({ adapter: "mcp-doctor", args }), exitCode: 0 });\n',
|
||||
),
|
||||
]);
|
||||
|
||||
const invoke = async (args: readonly string[]): Promise<unknown> => {
|
||||
const { exitCode, stdout } = await processes.run(
|
||||
process.execPath,
|
||||
[join(scripts, "rea.mjs"), ...args],
|
||||
{ cwd: workspace.root },
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
return JSON.parse(stdout) as unknown;
|
||||
};
|
||||
|
||||
await expect(invoke(["mcp"])).resolves.toEqual({ adapter: "mcp" });
|
||||
await expect(invoke(["--mcp"])).resolves.toEqual({ adapter: "mcp" });
|
||||
await expect(invoke(["mcp", "doctor", "--json"])).resolves.toEqual({
|
||||
adapter: "mcp-doctor",
|
||||
args: ["--json"],
|
||||
});
|
||||
await expect(invoke(["mcp", "add"])).resolves.toEqual({
|
||||
adapter: "cli",
|
||||
args: ["mcp", "add"],
|
||||
});
|
||||
await expect(invoke(["--mcp", "extra"])).resolves.toEqual({
|
||||
adapter: "cli",
|
||||
args: ["--mcp", "extra"],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fixtureTest(
|
||||
"explains how to restore a missing compiled runtime",
|
||||
async ({ processes, workspace }) => {
|
||||
const scripts = await workspace.mkdir("scripts");
|
||||
await Promise.all([
|
||||
copyFile("scripts/rea.mjs", join(scripts, "rea.mjs")),
|
||||
copyFile(
|
||||
"scripts/package-runner-bootstrap.mjs",
|
||||
join(scripts, "package-runner-bootstrap.mjs"),
|
||||
),
|
||||
workspace.write(
|
||||
"package.json",
|
||||
JSON.stringify({ type: "module", version: "1.0.0" }),
|
||||
),
|
||||
]);
|
||||
|
||||
for (const args of [["mcp"], ["mcp", "doctor"], ["--help"]]) {
|
||||
const result = await processes.run(
|
||||
process.execPath,
|
||||
[join(scripts, "rea.mjs"), ...args],
|
||||
{ cwd: workspace.root },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
exitCode: 1,
|
||||
stderr: expect.stringContaining(
|
||||
"REA's compiled runtime is missing. Run `npm ci` in",
|
||||
),
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
stderr: expect.not.stringContaining("ERR_MODULE_NOT_FOUND"),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { runDoctor, type DoctorHost } from "../src/application/Doctor.js";
|
||||
import { CATALOG_IDENTITY } from "../src/catalogIdentity.js";
|
||||
import { PRODUCT_IDENTITY } from "../src/identity.js";
|
||||
import { runDoctor, type DoctorHost } from "../../../src/application/Doctor.js";
|
||||
import { CATALOG_IDENTITY } from "../../../src/catalogIdentity.js";
|
||||
import { PRODUCT_IDENTITY } from "../../../src/identity.js";
|
||||
|
||||
const host = (overrides: Partial<DoctorHost> = {}): DoctorHost => ({
|
||||
platform: "darwin",
|
||||
|
|
@ -167,7 +167,9 @@ describe("doctor", () => {
|
|||
"Unset or update HOPPER_LAUNCHER_PATH to an executable Hopper launcher.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("doctor installation identity", () => {
|
||||
it("distinguishes stale skills, path shadowing, and unobservable live state", async () => {
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
|
|
@ -242,7 +244,9 @@ describe("doctor", () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("doctor scoped readiness", () => {
|
||||
it("scopes effective readiness to selected clients without hiding drift", async () => {
|
||||
const registrations = [
|
||||
{
|
||||
|
|
@ -322,6 +326,9 @@ describe("doctor", () => {
|
|||
});
|
||||
expect(selectedGhidra.healthy).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("doctor Linux and Hopper discovery", () => {
|
||||
it("accepts an officially supported Linux distribution", async () => {
|
||||
const path = "/home/user/.local/share/rea/hopper/bin/Hopper";
|
||||
const result = await runDoctor(
|
||||
|
|
@ -445,64 +452,4 @@ describe("doctor", () => {
|
|||
expect(result.hopperPath).toBeUndefined();
|
||||
expect(result.checks.find(({ name }) => name === "hopper")?.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a broken shadowed Node candidate without hiding the healthy launcher", async () => {
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
host({
|
||||
runtimeExecutables: () =>
|
||||
Promise.resolve({
|
||||
launcher_node: "/healthy/node",
|
||||
candidates: [
|
||||
{
|
||||
tool: "node",
|
||||
lexical_path: "/healthy/node",
|
||||
canonical_path: "/healthy/node",
|
||||
path_index: null,
|
||||
selection: "rea-launcher",
|
||||
version: "v24.18.0",
|
||||
healthy: true,
|
||||
failure: null,
|
||||
},
|
||||
{
|
||||
tool: "node",
|
||||
lexical_path: "/opt/homebrew/bin/node",
|
||||
canonical_path: "/opt/homebrew/Cellar/node/25.2.1/bin/node",
|
||||
path_index: 2,
|
||||
selection: "path-shadowed",
|
||||
version: null,
|
||||
healthy: false,
|
||||
failure: {
|
||||
code: "runtime_dynamic_library_missing",
|
||||
exit_code: 134,
|
||||
signal: null,
|
||||
dependency:
|
||||
"/opt/homebrew/opt/simdjson/lib/libsimdjson.29.dylib",
|
||||
stderr: "dyld: Library not loaded",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.healthy).toBe(false);
|
||||
expect(result.identity?.runtime_executables).toMatchObject({
|
||||
launcher_node: "/healthy/node",
|
||||
candidates: [
|
||||
{ healthy: true, selection: "rea-launcher" },
|
||||
{
|
||||
healthy: false,
|
||||
failure: { code: "runtime_dynamic_library_missing" },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(
|
||||
result.checks.find(({ name }) => name === "node-toolchains"),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
classification: "missing_dependency",
|
||||
remediation: expect.stringContaining("Do not create"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { runDoctor, type DoctorHost } from "../../../src/application/Doctor.js";
|
||||
import { CATALOG_IDENTITY } from "../../../src/catalogIdentity.js";
|
||||
import { PRODUCT_IDENTITY } from "../../../src/identity.js";
|
||||
|
||||
const host = (overrides: Partial<DoctorHost> = {}): DoctorHost => ({
|
||||
platform: "darwin",
|
||||
architecture: "x64",
|
||||
nodeVersion: "24.18.0",
|
||||
macosVersion: () => Promise.resolve("12.0"),
|
||||
linuxDistribution: () => Promise.resolve(undefined),
|
||||
validTarget: (path) => Promise.resolve(path.includes("Hopper")),
|
||||
executable: (path) => Promise.resolve(path.includes("Hopper")),
|
||||
supportedLinuxHopper: () => Promise.resolve(true),
|
||||
linuxDemoRuntimeCheck: () =>
|
||||
Promise.resolve({
|
||||
name: "hopper-demo-runtime",
|
||||
ok: true,
|
||||
classification: "healthy",
|
||||
}),
|
||||
brewHopperPath: () => Promise.resolve(undefined),
|
||||
manualHopperPaths: () => Promise.resolve([]),
|
||||
installedSkillIdentity: () =>
|
||||
Promise.resolve({
|
||||
version: PRODUCT_IDENTITY.skillVersion,
|
||||
toolCount: CATALOG_IDENTITY.counts.mcp_tools,
|
||||
catalogDigest: CATALOG_IDENTITY.digests.combined_sha256,
|
||||
}),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("doctor runtime executable diagnostics", () => {
|
||||
it("reports a broken shadowed Node candidate without hiding the healthy launcher", async () => {
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
host({
|
||||
runtimeExecutables: () =>
|
||||
Promise.resolve({
|
||||
launcher_node: "/healthy/node",
|
||||
candidates: [
|
||||
{
|
||||
tool: "node",
|
||||
lexical_path: "/healthy/node",
|
||||
canonical_path: "/healthy/node",
|
||||
path_index: null,
|
||||
selection: "rea-launcher",
|
||||
version: "v24.18.0",
|
||||
healthy: true,
|
||||
failure: null,
|
||||
},
|
||||
{
|
||||
tool: "node",
|
||||
lexical_path: "/opt/homebrew/bin/node",
|
||||
canonical_path: "/opt/homebrew/Cellar/node/25.2.1/bin/node",
|
||||
path_index: 2,
|
||||
selection: "path-shadowed",
|
||||
version: null,
|
||||
healthy: false,
|
||||
failure: {
|
||||
code: "runtime_dynamic_library_missing",
|
||||
exit_code: 134,
|
||||
signal: null,
|
||||
dependency:
|
||||
"/opt/homebrew/opt/simdjson/lib/libsimdjson.29.dylib",
|
||||
stderr: "dyld: Library not loaded",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.healthy).toBe(false);
|
||||
expect(result.identity?.runtime_executables).toMatchObject({
|
||||
launcher_node: "/healthy/node",
|
||||
candidates: [
|
||||
{ healthy: true, selection: "rea-launcher" },
|
||||
{
|
||||
healthy: false,
|
||||
failure: { code: "runtime_dynamic_library_missing" },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(
|
||||
result.checks.find(({ name }) => name === "node-toolchains"),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
classification: "missing_dependency",
|
||||
remediation: expect.stringContaining("Do not create"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -6,33 +6,30 @@ import { promisify } from "node:util";
|
|||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import {
|
||||
startFakeCdpBrowser,
|
||||
type FakeCdpBrowser,
|
||||
} from "./fixtures/fakeCdpBrowser.js";
|
||||
import { writeElectronBoundaryFixture } from "./fixtures/electronBoundaryApplication.js";
|
||||
import { JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE } from "../src/contracts/javascriptRuntimeReconciliationExample.js";
|
||||
} from "../../fixtures/fakeCdpBrowser.js";
|
||||
import { writeElectronBoundaryFixture } from "../../fixtures/electronBoundaryApplication.js";
|
||||
import { JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE } from "../../../src/contracts/javascriptRuntimeReconciliationExample.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const INTEGRATION_TEST_TIMEOUT_MS = 20_000;
|
||||
const browsers: FakeCdpBrowser[] = [];
|
||||
const temporary: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(browsers.splice(0).map(async (browser) => browser.close()));
|
||||
await Promise.all(
|
||||
temporary
|
||||
.splice(0)
|
||||
.map(async (path) => rm(path, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("Electron CLI parity", () => {
|
||||
const browsers: FakeCdpBrowser[] = [];
|
||||
const temporary: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
browsers.splice(0).map(async (browser) => browser.close()),
|
||||
);
|
||||
await Promise.all(
|
||||
temporary
|
||||
.splice(0)
|
||||
.map(async (path) => rm(path, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
it(
|
||||
"returns the same root-confined Electron Evidence contracts",
|
||||
async () => {
|
||||
|
|
@ -117,7 +114,9 @@ describe("Electron CLI parity", () => {
|
|||
},
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe("Electron CLI artifact behavior", () => {
|
||||
it(
|
||||
"keeps artifact format separate from structured output format",
|
||||
async () => {
|
||||
|
|
@ -211,7 +210,9 @@ describe("Electron CLI parity", () => {
|
|||
},
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe("Electron CLI runtime reconciliation", () => {
|
||||
it(
|
||||
"reads a file and returns the derived static/runtime reconciliation contract",
|
||||
async () => {
|
||||
|
|
@ -6,12 +6,12 @@ import { promisify } from "node:util";
|
|||
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { loadConfiguredPermissionAuthority } from "../src/application/PermissionConfiguration.js";
|
||||
import { observeJavaScriptRuntime } from "../src/application/JavaScriptRuntimeObservationService.js";
|
||||
import { V8InspectorProvider } from "../src/browser/V8InspectorProvider.js";
|
||||
import { parseConfig } from "../src/config.js";
|
||||
import { startFakeV8Inspector } from "./fixtures/fakeV8Inspector.js";
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
import { loadConfiguredPermissionAuthority } from "../../../src/application/PermissionConfiguration.js";
|
||||
import { observeJavaScriptRuntime } from "../../../src/application/JavaScriptRuntimeObservationService.js";
|
||||
import { V8InspectorProvider } from "../../../src/browser/V8InspectorProvider.js";
|
||||
import { parseConfig } from "../../../src/config.js";
|
||||
import { startFakeV8Inspector } from "../../fixtures/fakeV8Inspector.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const TIMEOUT_MS = 20_000;
|
||||
|
|
@ -6,8 +6,8 @@ import { describe, expect, it } from "vitest";
|
|||
import {
|
||||
packageRunnerSetupPlan,
|
||||
runPackageRunnerSetupBootstrap,
|
||||
} from "../scripts/package-runner-bootstrap.mjs";
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
} from "../../../scripts/package-runner-bootstrap.mjs";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
describe("package-runner setup bootstrap", () => {
|
||||
it("reruns bare setup through the current release when npm selected a local package", () => {
|
||||
|
|
@ -6,15 +6,15 @@ import { promisify } from "node:util";
|
|||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import {
|
||||
captureProcessScenarioFile,
|
||||
compareProcessEvidenceFiles,
|
||||
projectProcessCliError,
|
||||
} from "../src/application/ProcessCli.js";
|
||||
import { createEvidence } from "../src/domain/evidence.js";
|
||||
import { PROCESS_PROVIDER } from "../src/application/ProcessEvidence.js";
|
||||
} from "../../../src/application/ProcessCli.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { PROCESS_PROVIDER } from "../../../src/application/ProcessEvidence.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
|
@ -5,9 +5,9 @@ import { fileURLToPath } from "node:url";
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { SUPPORTED_CLIENT_DEFINITIONS } from "../src/application/SupportedClients.js";
|
||||
import { SUPPORTED_CLIENT_DEFINITIONS } from "../../../src/application/SupportedClients.js";
|
||||
import {
|
||||
ARTIFACT_GRAPH_PROVIDER,
|
||||
JAVASCRIPT_APPLICATION_PROVIDER,
|
||||
|
|
@ -15,30 +15,30 @@ import {
|
|||
JAVASCRIPT_RUNTIME_RECONCILIATION_PROVIDER,
|
||||
MANAGED_STATIC_PROVIDER,
|
||||
MANAGED_WORKFLOW_PROVIDER,
|
||||
} from "../src/application/InvestigationProviders.js";
|
||||
import { CDP_BROWSER_PROVIDER_IDENTITY } from "../src/browser/CdpBrowserProvider.js";
|
||||
import { CDP_ELECTRON_PROVIDER_IDENTITY } from "../src/browser/CdpElectronProvider.js";
|
||||
import { PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY } from "../src/browser/PlaywrightBrowserScenarioProvider.js";
|
||||
import { V8_INSPECTOR_PROVIDER_IDENTITY } from "../src/browser/V8InspectorProvider.js";
|
||||
import { CLI_COMMAND_NAMES } from "../src/cliCommandNames.js";
|
||||
import { createCli } from "../src/cli.js";
|
||||
import { TOOL_CONTRACTS } from "../src/contracts/toolContracts.js";
|
||||
import { analysisSnapshotSchema } from "../src/domain/analysisSnapshot.js";
|
||||
import { investigationWorkspaceSchema } from "../src/domain/investigationWorkspace.js";
|
||||
} from "../../../src/application/InvestigationProviders.js";
|
||||
import { CDP_BROWSER_PROVIDER_IDENTITY } from "../../../src/browser/CdpBrowserProvider.js";
|
||||
import { CDP_ELECTRON_PROVIDER_IDENTITY } from "../../../src/browser/CdpElectronProvider.js";
|
||||
import { PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY } from "../../../src/browser/PlaywrightBrowserScenarioProvider.js";
|
||||
import { V8_INSPECTOR_PROVIDER_IDENTITY } from "../../../src/browser/V8InspectorProvider.js";
|
||||
import { CLI_COMMAND_NAMES } from "../../../src/cliCommandNames.js";
|
||||
import { createCli } from "../../../src/cli.js";
|
||||
import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js";
|
||||
import { analysisSnapshotSchema } from "../../../src/domain/analysisSnapshot.js";
|
||||
import { investigationWorkspaceSchema } from "../../../src/domain/investigationWorkspace.js";
|
||||
import {
|
||||
HOPPER_PROVIDER_IDENTITY,
|
||||
HOPPER_PROVIDER_TOOL_CONTRACTS,
|
||||
} from "../src/hopper/HopperProvider.js";
|
||||
} from "../../../src/hopper/HopperProvider.js";
|
||||
import {
|
||||
GHIDRA_PROVIDER_IDENTITY,
|
||||
GHIDRA_PROVIDER_TOOL_CONTRACTS,
|
||||
} from "../src/ghidra/GhidraProvider.js";
|
||||
import { NATIVE_MACOS_PROVIDER_IDENTITY } from "../src/native/NativeMacOSProvider.js";
|
||||
} from "../../../src/ghidra/GhidraProvider.js";
|
||||
import { NATIVE_MACOS_PROVIDER_IDENTITY } from "../../../src/native/NativeMacOSProvider.js";
|
||||
import {
|
||||
assertDocumentationFacts,
|
||||
documentationFactIssues,
|
||||
} from "../scripts/lib/docs-facts.mjs";
|
||||
import { ensureGeneratedFile } from "../scripts/lib/generated-file.mjs";
|
||||
} from "../../../scripts/lib/docs-facts.mjs";
|
||||
import { ensureGeneratedFile } from "../../../scripts/lib/generated-file.mjs";
|
||||
import {
|
||||
createCliInventory,
|
||||
cliCommandDescriptionIssues,
|
||||
|
|
@ -46,9 +46,9 @@ import {
|
|||
createProductCatalog,
|
||||
providerCatalogDigest,
|
||||
serializeProductCatalog,
|
||||
} from "../scripts/lib/product-catalog.mjs";
|
||||
} from "../../../scripts/lib/product-catalog.mjs";
|
||||
|
||||
const root = fileURLToPath(new URL("..", import.meta.url));
|
||||
const root = fileURLToPath(new URL("../../..", import.meta.url));
|
||||
const temporaryRoots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -166,15 +166,17 @@ describe("canonical product catalog", () => {
|
|||
assertDocumentationFacts(root, catalog),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(documentationFactIssues(root, catalog)).resolves.toEqual([]);
|
||||
}, 15_000);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe("canonical CLI catalog", () => {
|
||||
it("uses the same primary command names as the actual Incur router", () => {
|
||||
const inventory = createCliInventory(createCli());
|
||||
expect(inventory.primary).toEqual([...CLI_COMMAND_NAMES].sort());
|
||||
expect(inventory.aliases).toEqual([
|
||||
{ name: "compare-bundles", target: "compare" },
|
||||
]);
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it("describes every primary command argument and option", () => {
|
||||
expect(cliCommandDescriptionIssues(createCli())).toEqual([]);
|
||||
|
|
@ -195,7 +197,9 @@ describe("canonical product catalog", () => {
|
|||
expect(cliCommandOptionNames(cli, name)).toContain("provider");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonical product catalog drift", () => {
|
||||
it("reports tool-family, setup-client, and schema fact drift", async () => {
|
||||
const catalog = await createProductCatalog(root);
|
||||
const firstFamily = catalog.tools.families[0];
|
||||
|
|
@ -3,7 +3,7 @@ import { promisify } from "node:util";
|
|||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createEvidenceBundle } from "../src/domain/evidenceBundle.js";
|
||||
import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
|
||||
|
|
@ -3,7 +3,7 @@ import { promisify } from "node:util";
|
|||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { RECONSTRUCTION_READINESS_EXAMPLE } from "../src/contracts/reconstructionReadinessExample.js";
|
||||
import { RECONSTRUCTION_READINESS_EXAMPLE } from "../../../src/contracts/reconstructionReadinessExample.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseConfig } from "../../src/config.js";
|
||||
import { parseConfig } from "../../../src/config.js";
|
||||
|
||||
describe("configuration parsing integration", () => {
|
||||
it("returns defaults when no environment variables are set", () => {
|
||||
|
|
@ -4,7 +4,7 @@ import { spawnSync } from "node:child_process";
|
|||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
const script = join(process.cwd(), "scripts", "check-dependency-install.mjs");
|
||||
|
|
@ -3,19 +3,19 @@ import { join } from "node:path";
|
|||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import {
|
||||
readEvidenceBundle,
|
||||
writeEvidenceBundle,
|
||||
} from "../src/application/EvidenceBundleFiles.js";
|
||||
import { compareEvidenceBundlesCommand } from "../src/application/EvidenceBundleCommands.js";
|
||||
import { createEvidence } from "../src/domain/evidence.js";
|
||||
} from "../../../src/application/EvidenceBundleFiles.js";
|
||||
import { compareEvidenceBundlesCommand } from "../../../src/application/EvidenceBundleCommands.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import {
|
||||
createEvidenceBundle,
|
||||
serializeEvidenceBundle,
|
||||
type EvidenceFilePolicy,
|
||||
} from "../src/domain/evidenceBundle.js";
|
||||
} from "../../../src/domain/evidenceBundle.js";
|
||||
|
||||
let directory: string | undefined;
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ import { promisify } from "node:util";
|
|||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const roots: string[] = [];
|
||||
|
|
@ -5,18 +5,18 @@ import { join } from "node:path";
|
|||
import { add, commit, init } from "isomorphic-git";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import {
|
||||
importReferenceSource,
|
||||
normalizeHistoricalSourceParseFailures,
|
||||
} from "../src/application/ReferenceSourceImport.js";
|
||||
import { projectReferenceSourceEntryFailure } from "../src/application/ReferenceSourceImportEntries.js";
|
||||
} from "../../../src/application/ReferenceSourceImport.js";
|
||||
import { projectReferenceSourceEntryFailure } from "../../../src/application/ReferenceSourceImportEntries.js";
|
||||
import {
|
||||
projectReferenceSourceImportError,
|
||||
type ReferenceSourceImportError,
|
||||
} from "../src/application/ReferenceSourceImportTypes.js";
|
||||
import { createHistoricalSourceManifest } from "../src/domain/referenceSourceGraph.js";
|
||||
} from "../../../src/application/ReferenceSourceImportTypes.js";
|
||||
import { createHistoricalSourceManifest } from "../../../src/domain/referenceSourceGraph.js";
|
||||
|
||||
const limits = {
|
||||
maxBytes: 1024 * 1024,
|
||||
|
|
@ -55,7 +55,7 @@ const importTree = (
|
|||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
|
||||
describe("reference source import", () => {
|
||||
describe("reference source import error projection", () => {
|
||||
it("deduplicates exact parse failures without collapsing distinct reasons", () => {
|
||||
const malformed = {
|
||||
path: "src/main.ts",
|
||||
|
|
@ -122,7 +122,9 @@ describe("reference source import", () => {
|
|||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("reference source import behavior", () => {
|
||||
it("is relocation-stable, resolves imports, and excludes secrets before capture", async () => {
|
||||
const parent = await createTestTempDirectory("rea-reference-import-");
|
||||
try {
|
||||
|
|
@ -3,9 +3,9 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { readReferenceSource } from "../src/reference/ReferenceSourceReader.js";
|
||||
import { readReferenceSource } from "../../../src/reference/ReferenceSourceReader.js";
|
||||
|
||||
const limits = {
|
||||
maxBytes: 1_024,
|
||||
|
|
@ -14,7 +14,7 @@ const limits = {
|
|||
maxPathBytes: 100,
|
||||
} as const;
|
||||
|
||||
describe("readReferenceSource", () => {
|
||||
describe("readReferenceSource entries", () => {
|
||||
it("returns explicit entries in canonical code-point path order", async () => {
|
||||
const root = await createTestTempDirectory("rea-reference-");
|
||||
await mkdir(join(root, "nested"));
|
||||
|
|
@ -148,7 +148,9 @@ describe("readReferenceSource", () => {
|
|||
expect(result.value.bytesRead).toBe(1);
|
||||
expect(result.value.truncated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readReferenceSource failures and exclusions", () => {
|
||||
it("applies exclusions to normalized paths before reading entries", async () => {
|
||||
const root = await createTestTempDirectory("rea-reference-");
|
||||
await mkdir(join(root, "ignored"));
|
||||
|
|
@ -5,9 +5,9 @@ import { Readable } from "node:stream";
|
|||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { SafeOutputTree } from "../src/artifacts/SafeOutputTree.js";
|
||||
import { SafeOutputTree } from "../../../src/artifacts/SafeOutputTree.js";
|
||||
|
||||
const LIMITS = {
|
||||
maxEntries: 10,
|
||||
|
|
@ -4,7 +4,7 @@ import { dirname, join } from "node:path";
|
|||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
describe.sequential("test temporary directory ownership", () => {
|
||||
let completedDirectory: string | undefined;
|
||||
|
|
@ -0,0 +1,467 @@
|
|||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { createPackageWithOptions } from "@electron/asar";
|
||||
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import {
|
||||
composeBinarySessionFromFactory,
|
||||
composeBinarySessionFromProvider,
|
||||
} from "../../../src/application/BinarySessionComposition.js";
|
||||
import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
|
||||
import { ARTIFACT_COMPARISON_EXAMPLE } from "../../../src/contracts/artifactComparisonExample.js";
|
||||
import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { observed } from "../../fixtures/analysisExecution.js";
|
||||
|
||||
it.each([
|
||||
{ unpacked: false, label: "embedded" },
|
||||
{ unpacked: true, label: "unpacked" },
|
||||
])(
|
||||
"returns actionable local details for an $label ASAR integrity error",
|
||||
async ({ unpacked }) => {
|
||||
const root = await createTestTempDirectory("rea-asar-integrity-mcp-");
|
||||
const source = join(root, "source");
|
||||
const original = "console.log('ok');\n";
|
||||
const changed = "console.log('no');\n";
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "main.js"), original);
|
||||
const archive = join(root, "fixture.asar");
|
||||
await createPackageWithOptions(
|
||||
source,
|
||||
archive,
|
||||
unpacked ? { unpack: "*.js" } : {},
|
||||
);
|
||||
if (unpacked) {
|
||||
await writeFile(join(`${archive}.unpacked`, "main.js"), changed);
|
||||
} else {
|
||||
const bytes = await readFile(archive);
|
||||
const contentOffset = bytes.indexOf(original);
|
||||
expect(contentOffset).toBeGreaterThanOrEqual(0);
|
||||
bytes.write(changed, contentOffset, "utf8");
|
||||
await writeFile(archive, bytes);
|
||||
}
|
||||
|
||||
const session = composeBinarySessionFromProvider(new ArtifactProvider());
|
||||
const server = createServer(session, session);
|
||||
const client = new Client({ name: "asar-integrity-test", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: archive },
|
||||
});
|
||||
expect(opened.isError).not.toBe(true);
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "inventory_artifact",
|
||||
arguments: {},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.structuredContent).toEqual({
|
||||
error: {
|
||||
code: "artifact_integrity_mismatch",
|
||||
category: "integrity_mismatch",
|
||||
message:
|
||||
"Artifact is invalid or has changed. Get a fresh copy and try again.",
|
||||
details: {
|
||||
logical_path: "main.js",
|
||||
declared_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
|
||||
calculated_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
|
||||
unpacked,
|
||||
},
|
||||
retryable: false,
|
||||
remediation: {
|
||||
action:
|
||||
"Artifact is invalid or has changed. Get a fresh copy and try again.",
|
||||
restart_required: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
client.close(),
|
||||
server.close(),
|
||||
session.close(),
|
||||
]);
|
||||
}
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
|
||||
const artifactInventoryResultSchema = z.object({
|
||||
occurrences: z.object({
|
||||
items: z.array(
|
||||
z.object({ logical_path: z.string(), hash_status: z.string() }),
|
||||
),
|
||||
}),
|
||||
integrity_contradictions: z.array(
|
||||
z.object({
|
||||
logical_path: z.string(),
|
||||
declared_sha256: z.string(),
|
||||
observed_sha256: z.string(),
|
||||
trust: z.literal("observed-untrusted"),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
it("records an approved mismatch, preserves verified siblings, and never reports equivalence", async () => {
|
||||
const root = await createTestTempDirectory("rea-asar-continue-mcp-");
|
||||
const source = join(root, "source");
|
||||
await mkdir(source);
|
||||
const original = "console.log('ok');\n",
|
||||
changed = "console.log('no');\n",
|
||||
secondOriginal = "console.log('up');\n",
|
||||
secondChanged = "console.log('dn');\n";
|
||||
await writeFile(join(source, "main.js"), original);
|
||||
await writeFile(join(source, "second.js"), secondOriginal);
|
||||
await writeFile(join(source, "sibling.js"), "console.log('sibling');\n");
|
||||
const archive = join(root, "fixture.asar");
|
||||
await createPackageWithOptions(source, archive, {});
|
||||
const bytes = await readFile(archive);
|
||||
const contentOffset = bytes.indexOf(original);
|
||||
const secondOffset = bytes.indexOf(secondOriginal);
|
||||
expect(contentOffset).toBeGreaterThanOrEqual(0);
|
||||
expect(secondOffset).toBeGreaterThanOrEqual(0);
|
||||
bytes.write(changed, contentOffset, "utf8");
|
||||
bytes.write(secondChanged, secondOffset, "utf8");
|
||||
await writeFile(archive, bytes);
|
||||
|
||||
const session = composeBinarySessionFromProvider(
|
||||
new ArtifactProvider(false, true),
|
||||
);
|
||||
const server = createServer(session, session);
|
||||
const client = new Client({ name: "asar-continue-test", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: archive },
|
||||
});
|
||||
const limited = await client.callTool({
|
||||
name: "inventory_artifact",
|
||||
arguments: {
|
||||
integrity_policy: "record-and-continue",
|
||||
integrity_continue_approved: true,
|
||||
max_integrity_mismatches: 1,
|
||||
},
|
||||
});
|
||||
expect(limited).toMatchObject({
|
||||
isError: true,
|
||||
structuredContent: { error: { code: "truncated" } },
|
||||
});
|
||||
const result = await client.callTool({
|
||||
name: "inventory_artifact",
|
||||
arguments: {
|
||||
integrity_policy: "record-and-continue",
|
||||
integrity_continue_approved: true,
|
||||
},
|
||||
});
|
||||
expect(result.isError).not.toBe(true);
|
||||
const compact = compactResult(result.structuredContent);
|
||||
const evidence = session.evidenceById(compact.evidence_id);
|
||||
if (evidence === undefined) throw new Error("missing inventory Evidence");
|
||||
const inventory = artifactInventoryResultSchema.parse(compact.result);
|
||||
expect(inventory.integrity_contradictions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
logical_path: "main.js",
|
||||
trust: "observed-untrusted",
|
||||
declared_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
|
||||
observed_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
|
||||
}),
|
||||
expect.objectContaining({ logical_path: "second.js" }),
|
||||
]),
|
||||
);
|
||||
expect(inventory.integrity_contradictions).toHaveLength(2);
|
||||
expect(inventory.occurrences.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
logical_path: "main.js",
|
||||
hash_status: "mismatched",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
logical_path: "sibling.js",
|
||||
hash_status: "verified",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
logical_path: "second.js",
|
||||
hash_status: "mismatched",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const inspected = await client.callTool({
|
||||
name: "inspect_artifact",
|
||||
arguments: {
|
||||
integrity_policy: "record-and-continue",
|
||||
integrity_continue_approved: true,
|
||||
},
|
||||
});
|
||||
expect(inspected.isError).not.toBe(true);
|
||||
expect(compactResult(inspected.structuredContent).result).toMatchObject({
|
||||
contradictions: [
|
||||
expect.objectContaining({ occurrence_id: expect.any(String) }),
|
||||
expect.objectContaining({ occurrence_id: expect.any(String) }),
|
||||
],
|
||||
substeps: [
|
||||
expect.objectContaining({
|
||||
operation: "inventory_artifact",
|
||||
status: "completed",
|
||||
}),
|
||||
],
|
||||
});
|
||||
const compared = await client.callTool({
|
||||
name: "compare_artifacts",
|
||||
arguments: {
|
||||
left_evidence_ids: [evidence.evidence_id],
|
||||
right_evidence_ids: [evidence.evidence_id],
|
||||
},
|
||||
});
|
||||
expect(compared.isError).not.toBe(true);
|
||||
expect(compactResult(compared.structuredContent).result).toMatchObject({
|
||||
status: "contradiction",
|
||||
summary: { contradiction: 2 },
|
||||
changes: {
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
logical_path: "main.js",
|
||||
classification: "contradiction",
|
||||
dimensions: ["integrity"],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
logical_path: "second.js",
|
||||
classification: "contradiction",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await Promise.allSettled([client.close(), server.close(), session.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects altered payloads that reuse session Evidence IDs", async () => {
|
||||
const session = composeBinarySessionFromFactory(() => ({
|
||||
health: () => Promise.resolve(),
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
session.recordEvidence(ARTIFACT_COMPARISON_EXAMPLE.left);
|
||||
session.recordEvidence(ARTIFACT_COMPARISON_EXAMPLE.right);
|
||||
const server = createServer(session, session);
|
||||
const client = new Client({
|
||||
name: "artifact-authority-test",
|
||||
version: "1",
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const result = await client.callTool({
|
||||
name: "compare_artifacts",
|
||||
arguments: {
|
||||
...ARTIFACT_COMPARISON_EXAMPLE,
|
||||
left: {
|
||||
...ARTIFACT_COMPARISON_EXAMPLE.left,
|
||||
limitations: ["caller altered this record"],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(session.exportEvidenceBundle().records).toHaveLength(2);
|
||||
} finally {
|
||||
await Promise.allSettled([client.close(), server.close(), session.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects comparison Evidence that is not owned by the session", async () => {
|
||||
const session = composeBinarySessionFromFactory(() => ({
|
||||
health: () => Promise.resolve(),
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
const server = createServer(session, session);
|
||||
const client = new Client({
|
||||
name: "artifact-ownership-test",
|
||||
version: "1",
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const result = await client.callTool({
|
||||
name: "compare_artifacts",
|
||||
arguments: ARTIFACT_COMPARISON_EXAMPLE,
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(session.exportEvidenceBundle().records).toEqual([]);
|
||||
} finally {
|
||||
await Promise.allSettled([client.close(), server.close(), session.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
it("opens archives without Hopper, compares them, and exports linked evidence", async () => {
|
||||
const directory = await createTestTempDirectory("rea-artifact-mcp-");
|
||||
const archive = join(directory, "fixture.ipa");
|
||||
const changedArchive = join(directory, "changed.ipa");
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add("Payload/Fixture.app/main.js", new TextReader("main();"));
|
||||
await writeFile(archive, await writer.close());
|
||||
const changedWriter = new ZipWriter(new Uint8ArrayWriter());
|
||||
await changedWriter.add(
|
||||
"Payload/Fixture.app/main.js",
|
||||
new TextReader("changed();"),
|
||||
);
|
||||
await writeFile(changedArchive, await changedWriter.close());
|
||||
const session = composeBinarySessionFromProvider(new ArtifactProvider());
|
||||
const server = createServer(session, session);
|
||||
const client = new Client({ name: "artifact-mcp-test", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: archive },
|
||||
});
|
||||
expect(opened.isError).not.toBe(true);
|
||||
const inspected = await client.callTool({
|
||||
name: "inspect_artifact",
|
||||
arguments: { max_observations: 1, max_relationships: 1 },
|
||||
});
|
||||
expect(inspected.isError).not.toBe(true);
|
||||
const inspectionResult = compactResult(inspected.structuredContent);
|
||||
const inspectionEvidence = session.evidenceById(
|
||||
inspectionResult.evidence_id,
|
||||
);
|
||||
if (inspectionEvidence === undefined)
|
||||
throw new Error("missing inspection Evidence");
|
||||
const inspection = z
|
||||
.object({
|
||||
substeps: z.array(
|
||||
z.object({
|
||||
evidence_id: z.string(),
|
||||
status: z.literal("completed"),
|
||||
}),
|
||||
),
|
||||
coverage: z.object({
|
||||
status: z.literal("truncated"),
|
||||
substeps_completed: z.literal(1),
|
||||
}),
|
||||
unexplored_branches: z.array(z.object({ reason: z.string() })),
|
||||
})
|
||||
.parse(inspectionResult.result);
|
||||
expect(inspection.substeps).toHaveLength(1);
|
||||
expect(
|
||||
session.evidenceById(inspection.substeps[0]?.evidence_id ?? ""),
|
||||
).toMatchObject({ operation: "inventory_artifact" });
|
||||
expect(inspectionEvidence.evidence_links).toEqual([
|
||||
inspection.substeps[0]?.evidence_id,
|
||||
]);
|
||||
expect(inspection.unexplored_branches).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ reason: "page-limit" }),
|
||||
]),
|
||||
);
|
||||
const inventory = await client.callTool({
|
||||
name: "inventory_artifact",
|
||||
arguments: {},
|
||||
});
|
||||
expect(inventory.isError).not.toBe(true);
|
||||
const inventoryResult = compactResult(inventory.structuredContent);
|
||||
const evidence = session.evidenceById(inventoryResult.evidence_id);
|
||||
if (evidence === undefined) throw new Error("missing inventory Evidence");
|
||||
expect(evidence).toMatchObject({
|
||||
provider: { id: "rea-artifact-graph" },
|
||||
subject: { format: "ipa" },
|
||||
});
|
||||
expect(inventoryResult.result).toMatchObject({
|
||||
manifest: { root_format: "ipa" },
|
||||
});
|
||||
const openedChanged = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: changedArchive },
|
||||
});
|
||||
expect(openedChanged.isError).not.toBe(true);
|
||||
const changedInventory = await client.callTool({
|
||||
name: "inventory_artifact",
|
||||
arguments: {},
|
||||
});
|
||||
const changedResult = compactResult(changedInventory.structuredContent);
|
||||
const changedEvidence = session.evidenceById(changedResult.evidence_id);
|
||||
if (changedEvidence === undefined)
|
||||
throw new Error("missing changed Evidence");
|
||||
const compared = await client.callTool({
|
||||
name: "compare_artifacts",
|
||||
arguments: {
|
||||
left_evidence_ids: [evidence.evidence_id],
|
||||
right_evidence_ids: [changedEvidence.evidence_id],
|
||||
unknown_registry_approved: true,
|
||||
},
|
||||
});
|
||||
expect(compared.isError).not.toBe(true);
|
||||
expect(compactResult(compared.structuredContent)).toMatchObject({
|
||||
result: { status: "changed" },
|
||||
evidence_id: expect.stringMatching(/^ev_/u),
|
||||
});
|
||||
const unknowns = await client.callTool({
|
||||
name: "list_unknowns",
|
||||
arguments: { domain: "artifact-comparison" },
|
||||
});
|
||||
expect(unknowns.structuredContent).toMatchObject({
|
||||
result: {
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
unknown: expect.objectContaining({
|
||||
domain: "artifact-comparison",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
const snapshotted = await client.callTool({
|
||||
name: "snapshot_evidence_bundle",
|
||||
arguments: {},
|
||||
});
|
||||
const bundleUri = z
|
||||
.object({ result: z.object({ bundle_uri: z.string() }) })
|
||||
.parse(snapshotted.structuredContent).result.bundle_uri;
|
||||
const bundleResource = await client.readResource({ uri: bundleUri });
|
||||
const bundle = evidenceBundleSchema.parse(
|
||||
JSON.parse(
|
||||
z.object({ text: z.string() }).parse(bundleResource.contents[0]).text,
|
||||
),
|
||||
);
|
||||
expect(bundle.records).toHaveLength(6);
|
||||
expect(bundle.artifacts).toContainEqual({
|
||||
digest: { sha256: evidence.subject?.digest.sha256 },
|
||||
format: "ipa",
|
||||
architecture: null,
|
||||
});
|
||||
} finally {
|
||||
await Promise.allSettled([client.close(), server.close(), session.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
const compactResult = (value: unknown) =>
|
||||
z
|
||||
.object({
|
||||
result: z.unknown(),
|
||||
evidence_id: z.string(),
|
||||
evidence_uri: z.string(),
|
||||
})
|
||||
.parse(value);
|
||||
|
|
@ -0,0 +1,330 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
|
||||
import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js";
|
||||
import type { BinarySession } from "../../../src/application/BinarySession.js";
|
||||
import { loadConfiguredPermissionAuthority } from "../../../src/application/PermissionConfiguration.js";
|
||||
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
|
||||
import { parseConfig } from "../../../src/config.js";
|
||||
import { JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE } from "../../../src/contracts/javascriptRuntimeReconciliationExample.js";
|
||||
import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { observed } from "../../fixtures/analysisExecution.js";
|
||||
import {
|
||||
startFakeCdpBrowser,
|
||||
type FakeCdpBrowser,
|
||||
} from "../../fixtures/fakeCdpBrowser.js";
|
||||
|
||||
const resources: Array<{ close(): Promise<unknown> }> = [];
|
||||
const browsers: FakeCdpBrowser[] = [];
|
||||
const INTEGRATION_TEST_TIMEOUT_MS = 20_000;
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
resources.splice(0).map(async (resource) => resource.close()),
|
||||
);
|
||||
await Promise.all(browsers.splice(0).map(async (browser) => browser.close()));
|
||||
});
|
||||
|
||||
it(
|
||||
"exposes CLI-equivalent Evidence v2 and session resources",
|
||||
async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
sessionTimeline: "same_origin",
|
||||
webMcpTools: true,
|
||||
sensitiveShapes: true,
|
||||
});
|
||||
browsers.push(browser);
|
||||
const connected = await connectBrowser(browser);
|
||||
|
||||
const tools = await connected.client.listTools();
|
||||
expect(tools.tools.length).toBeLessThan(TOOL_CONTRACTS.length);
|
||||
expect(tools.tools.map(({ name }) => name)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"list_browser_targets",
|
||||
"inspect_web_page",
|
||||
"analyze_web_bundle",
|
||||
"observe_web_session",
|
||||
"discover_webmcp_tools",
|
||||
"compare_web_captures",
|
||||
"capture_web_screenshot",
|
||||
"compare_web_screenshots",
|
||||
]),
|
||||
);
|
||||
const status = await connected.client.callTool({
|
||||
name: "binary_session",
|
||||
arguments: { detail: "full" },
|
||||
});
|
||||
expect(status.structuredContent).toMatchObject({
|
||||
result: {
|
||||
tool_availability: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "inspect_web_page",
|
||||
available: true,
|
||||
reason: "available",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
const listed = await connected.client.callTool({
|
||||
name: "list_browser_targets",
|
||||
arguments: {
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
approved: true,
|
||||
},
|
||||
});
|
||||
expect(listed.isError).not.toBe(true);
|
||||
expect(listed.structuredContent).toMatchObject({
|
||||
result: {
|
||||
targets: { items: [{ target_id: "allowed-page" }] },
|
||||
},
|
||||
});
|
||||
const inspected = await connected.client.callTool({
|
||||
name: "inspect_web_page",
|
||||
arguments: {
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
include_console_text: true,
|
||||
console_text_approved: true,
|
||||
include_json_body_shapes: true,
|
||||
json_body_schema_approved: true,
|
||||
include_websocket_shapes: true,
|
||||
websocket_shape_approved: true,
|
||||
include_script_sources: true,
|
||||
},
|
||||
});
|
||||
expect(inspected.isError).not.toBe(true);
|
||||
expect(inspected.structuredContent).toMatchObject({
|
||||
result: {
|
||||
target: { target_id: "allowed-page" },
|
||||
console: { prior_activity_available: false },
|
||||
network: {
|
||||
prior_activity_available: false,
|
||||
requests: [
|
||||
expect.objectContaining({
|
||||
body_shapes: expect.objectContaining({ status: "included" }),
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const reconciled = await connected.client.callTool({
|
||||
name: "reconcile_javascript_runtime",
|
||||
arguments: {
|
||||
static_layers: JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE.static_layers,
|
||||
runtime_observations: [
|
||||
evidenceFor(connected.session, inspected.structuredContent),
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(reconciled.isError).not.toBe(true);
|
||||
expect(reconciled.structuredContent).toMatchObject({
|
||||
result: { summary: { runtime_scripts: 1 } },
|
||||
});
|
||||
expect(JSON.stringify(reconciled.structuredContent)).not.toContain(
|
||||
"source-secret",
|
||||
);
|
||||
const analyzed = await connected.client.callTool({
|
||||
name: "analyze_web_bundle",
|
||||
arguments: {
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
source_capture_approved: true,
|
||||
observation_ms: 0,
|
||||
},
|
||||
});
|
||||
expect(analyzed.isError).not.toBe(true);
|
||||
expect(analyzed.structuredContent).toMatchObject({
|
||||
result: {
|
||||
capture: { scripts_analyzed: 1 },
|
||||
observations: { source_maps: { status: "not_requested" } },
|
||||
},
|
||||
});
|
||||
await verifySessionAndComparisonTools(connected, browser, inspected);
|
||||
},
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
const verifySessionAndComparisonTools = async (
|
||||
connected: Awaited<ReturnType<typeof connectBrowser>>,
|
||||
browser: FakeCdpBrowser,
|
||||
inspected: Awaited<ReturnType<Client["callTool"]>>,
|
||||
): Promise<void> => {
|
||||
const observedSession = await connected.client.callTool({
|
||||
name: "observe_web_session",
|
||||
arguments: {
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 5,
|
||||
},
|
||||
});
|
||||
expect(observedSession.isError).not.toBe(true);
|
||||
expect(observedSession.structuredContent).toMatchObject({
|
||||
result: {
|
||||
window: { end_reason: "window_elapsed" },
|
||||
timeline: expect.arrayContaining([
|
||||
expect.objectContaining({ type: "same_origin_reload" }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
const webMcp = await connected.client.callTool({
|
||||
name: "discover_webmcp_tools",
|
||||
arguments: {
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
},
|
||||
});
|
||||
expect(webMcp.isError).not.toBe(true);
|
||||
expect(webMcp.structuredContent).toMatchObject({
|
||||
result: {
|
||||
tools: {
|
||||
items: [expect.objectContaining({ name: "search_orders" })],
|
||||
},
|
||||
},
|
||||
});
|
||||
const capture = normalizedResultOf(inspected.structuredContent);
|
||||
const compared = await connected.client.callTool({
|
||||
name: "compare_web_captures",
|
||||
arguments: {
|
||||
before: { inspection: capture },
|
||||
after: { inspection: capture },
|
||||
},
|
||||
});
|
||||
expect(compared.isError).not.toBe(true);
|
||||
expect(compared.structuredContent).toMatchObject({
|
||||
result: { overall_status: "unknown" },
|
||||
});
|
||||
const screenshot = await connected.client.callTool({
|
||||
name: "capture_web_screenshot",
|
||||
arguments: {
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
screenshot_approved: true,
|
||||
},
|
||||
});
|
||||
expect(screenshot.isError).not.toBe(true);
|
||||
const screenshotArtifact = artifactOf(screenshot.structuredContent);
|
||||
const visual = await connected.client.callTool({
|
||||
name: "compare_web_screenshots",
|
||||
arguments: {
|
||||
before: screenshotArtifact,
|
||||
after: screenshotArtifact,
|
||||
},
|
||||
});
|
||||
expect(visual.isError).not.toBe(true);
|
||||
expect(visual.structuredContent).toMatchObject({
|
||||
result: { status: "identical", changed_pixels: 0 },
|
||||
});
|
||||
const evidenceId = evidenceIdOf(inspected.structuredContent);
|
||||
const evidence = await connected.client.readResource({
|
||||
uri: `rea://evidence/${evidenceId}`,
|
||||
});
|
||||
expect(evidence.contents[0]).toEqual(
|
||||
expect.objectContaining({ text: expect.stringContaining(evidenceId) }),
|
||||
);
|
||||
};
|
||||
|
||||
it("denies origins outside the administrator ceiling before CDP attach", async () => {
|
||||
const browser = await startFakeCdpBrowser();
|
||||
browsers.push(browser);
|
||||
const connected = await connectBrowser(browser);
|
||||
const denied = await connected.client.callTool({
|
||||
name: "inspect_web_page",
|
||||
arguments: {
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: ["https://unapproved.example.test"],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
},
|
||||
});
|
||||
expect(denied.isError).toBe(true);
|
||||
expect(denied.structuredContent).toMatchObject({
|
||||
error: {
|
||||
code: "permission_required",
|
||||
details: {
|
||||
missing: { origins: ["https://unapproved.example.test"] },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(browser.commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
const connectBrowser = async (browser: FakeCdpBrowser) => {
|
||||
const config = parseConfig({
|
||||
REA_BROWSER_OBSERVE_ENABLED: "true",
|
||||
REA_BROWSER_CDP_ENDPOINTS_JSON: JSON.stringify([browser.endpoint]),
|
||||
REA_BROWSER_ALLOWED_ORIGINS_JSON: JSON.stringify([browser.allowedOrigin]),
|
||||
});
|
||||
if (!config.ok) throw config.error;
|
||||
const authority = await loadConfiguredPermissionAuthority(config.value);
|
||||
if (!authority.ok) throw authority.error;
|
||||
const session = composeBinarySessionFromFactory(() => ({
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
const server = createServer(session, session, {
|
||||
browserObservation: new CdpBrowserProvider(),
|
||||
permissionAuthority: authority.value,
|
||||
availabilityPolicy: () => ({
|
||||
processCaptureEnabled: false,
|
||||
evidenceFileRoots: 0,
|
||||
investigationInputRoots: 0,
|
||||
browserObservationEnabled: true,
|
||||
}),
|
||||
});
|
||||
const client = new Client({ name: "browser-mcp-test", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push(client, server, session);
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
return { client, session };
|
||||
};
|
||||
|
||||
const evidenceIdOf = (value: unknown): string => {
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
!("evidence_id" in value) ||
|
||||
typeof value.evidence_id !== "string"
|
||||
)
|
||||
throw new TypeError("Missing browser evidence ID");
|
||||
return value.evidence_id;
|
||||
};
|
||||
|
||||
const normalizedResultOf = (value: unknown): unknown => {
|
||||
if (typeof value !== "object" || value === null || !("result" in value))
|
||||
throw new TypeError("Missing normalized browser result");
|
||||
return value.result;
|
||||
};
|
||||
|
||||
const evidenceFor = (session: BinarySession, value: unknown) => {
|
||||
const evidence = session.evidenceById(evidenceIdOf(value));
|
||||
if (evidence === undefined) throw new TypeError("Missing session Evidence");
|
||||
return evidence;
|
||||
};
|
||||
|
||||
const artifactOf = (value: unknown): unknown => {
|
||||
const normalized = normalizedResultOf(value);
|
||||
if (
|
||||
typeof normalized !== "object" ||
|
||||
normalized === null ||
|
||||
!("artifact" in normalized)
|
||||
)
|
||||
throw new TypeError("Missing screenshot artifact");
|
||||
return normalized.artifact;
|
||||
};
|
||||
|
|
@ -2,25 +2,25 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
|||
import { dirname } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import type { BrowserScenarioCapturePort } from "../src/application/BrowserScenarioCapturePort.js";
|
||||
import { BinarySession } from "../src/application/BinarySession.js";
|
||||
import { loadConfiguredPermissionAuthority } from "../src/application/PermissionConfiguration.js";
|
||||
import type { BrowserScenarioCapturePort } from "../../../src/application/BrowserScenarioCapturePort.js";
|
||||
import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js";
|
||||
import { loadConfiguredPermissionAuthority } from "../../../src/application/PermissionConfiguration.js";
|
||||
import type {
|
||||
ExecutionOptions,
|
||||
ProviderIdentity,
|
||||
} from "../src/application/AnalysisProvider.js";
|
||||
import { CdpBrowserProvider } from "../src/browser/CdpBrowserProvider.js";
|
||||
import { parseConfig } from "../src/config.js";
|
||||
import { sanitizeBrowserUrl } from "../src/domain/browserObservation.js";
|
||||
import type { BrowserScenario } from "../src/domain/browserScenario.js";
|
||||
} from "../../../src/application/AnalysisProvider.js";
|
||||
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
|
||||
import { parseConfig } from "../../../src/config.js";
|
||||
import { sanitizeBrowserUrl } from "../../../src/domain/browserObservation.js";
|
||||
import type { BrowserScenario } from "../../../src/domain/browserScenario.js";
|
||||
import {
|
||||
browserScenarioCaptureSchema,
|
||||
type BrowserScenarioCapture,
|
||||
} from "../src/domain/browserScenarioCapture.js";
|
||||
import type { AnalysisError } from "../src/domain/errors.js";
|
||||
import { ok, type Result } from "../src/domain/result.js";
|
||||
import { createServer } from "../src/server/createServer.js";
|
||||
import { observed } from "./fixtures/analysisExecution.js";
|
||||
} from "../../../src/domain/browserScenarioCapture.js";
|
||||
import type { AnalysisError } from "../../../src/domain/errors.js";
|
||||
import { ok, type Result } from "../../../src/domain/result.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { observed } from "../../fixtures/analysisExecution.js";
|
||||
|
||||
const providerIdentity: ProviderIdentity = {
|
||||
id: "test-browser-scenario",
|
||||
|
|
@ -189,7 +189,7 @@ describe("browser scenario MCP tool", () => {
|
|||
const authority = await loadConfiguredPermissionAuthority(configured.value);
|
||||
if (!authority.ok) throw authority.error;
|
||||
const provider = new FakeBrowserScenarioProvider();
|
||||
const session = new BinarySession(() => ({
|
||||
const session = composeBinarySessionFromFactory(() => ({
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
|
|
@ -4,16 +4,16 @@ import { join } from "node:path";
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { BinarySession } from "../src/application/BinarySession.js";
|
||||
import { createEvidence } from "../src/domain/evidence.js";
|
||||
import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import {
|
||||
createEvidenceBundle,
|
||||
serializeEvidenceBundle,
|
||||
} from "../src/domain/evidenceBundle.js";
|
||||
import { createServer } from "../src/server/createServer.js";
|
||||
import { observed } from "./fixtures/analysisExecution.js";
|
||||
} from "../../../src/domain/evidenceBundle.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { observed } from "../../fixtures/analysisExecution.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
|
|
@ -137,7 +137,7 @@ describe("bundle comparison MCP integration", () => {
|
|||
});
|
||||
|
||||
const connect = async (root: string, maxBytes = 1024 * 1024) => {
|
||||
const session = new BinarySession(() => ({
|
||||
const session = composeBinarySessionFromFactory(() => ({
|
||||
health: () => Promise.resolve(),
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { observed as ok } from "./fixtures/analysisExecution.js";
|
||||
import { createServer } from "../src/server/createServer.js";
|
||||
import { observed as ok } from "../../fixtures/analysisExecution.js";
|
||||
import { mcpTest } from "../../support/mcp/mcpFixture.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
|
||||
const analysis = {
|
||||
execute: () => Promise.resolve(ok(null)),
|
||||
|
|
@ -13,18 +13,15 @@ describe("MCP server composition", () => {
|
|||
expect(createServer(analysis)).not.toBe(createServer(analysis));
|
||||
});
|
||||
|
||||
it("does not advertise unavailable session tools", async () => {
|
||||
mcpTest("does not advertise unavailable session tools", async ({ mcp }) => {
|
||||
const server = createServer(analysis);
|
||||
const client = new Client({ name: "composition-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const client = await mcp.connect(server, {
|
||||
name: "composition-test",
|
||||
version: "1.0.0",
|
||||
});
|
||||
expect(client.getInstructions()).not.toContain("open_binary");
|
||||
const names = (await client.listTools()).tools.map(({ name }) => name);
|
||||
expect(names).not.toContain("compare_managed_members");
|
||||
expect(names).not.toContain("plan_managed_runtime_correlation");
|
||||
await client.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { runDerivedOperation } from "../src/server/runDerivedOperation.js";
|
||||
import { runDerivedOperation } from "../../../src/server/runDerivedOperation.js";
|
||||
|
||||
const context = (
|
||||
controller: AbortController,
|
||||
|
|
@ -0,0 +1,351 @@
|
|||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js";
|
||||
import type { BinarySession } from "../../../src/application/BinarySession.js";
|
||||
import { loadConfiguredPermissionAuthority } from "../../../src/application/PermissionConfiguration.js";
|
||||
import { CdpElectronProvider } from "../../../src/browser/CdpElectronProvider.js";
|
||||
import { parseConfig } from "../../../src/config.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { observed } from "../../fixtures/analysisExecution.js";
|
||||
import {
|
||||
startFakeCdpBrowser,
|
||||
type FakeCdpBrowser,
|
||||
} from "../../fixtures/fakeCdpBrowser.js";
|
||||
import { writeElectronBoundaryFixture } from "../../fixtures/electronBoundaryApplication.js";
|
||||
|
||||
const browsers: FakeCdpBrowser[] = [];
|
||||
const resources: Array<{ close(): Promise<unknown> }> = [];
|
||||
const temporary: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(resources.splice(0).map(async (item) => item.close()));
|
||||
await Promise.all(browsers.splice(0).map(async (browser) => browser.close()));
|
||||
await Promise.all(
|
||||
temporary
|
||||
.splice(0)
|
||||
.map(async (path) => rm(path, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes root-confined Electron discovery and inspection as Evidence v2", async () => {
|
||||
const root = await createTestTempDirectory("rea-electron-mcp-");
|
||||
temporary.push(root);
|
||||
await writeFile(join(root, "index.html"), "<script src='app.js'></script>");
|
||||
await writeFile(
|
||||
join(root, "app.js"),
|
||||
"export const observed = 'source-secret';",
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({ name: "runtime-fixture", renderer: "index.html" }),
|
||||
);
|
||||
await writeFile(join(root, "worker.js"), "self.onmessage = () => {};\n");
|
||||
const browser = await startFakeCdpBrowser({
|
||||
electronFileUrl: pathToFileURL(join(root, "index.html")).href,
|
||||
});
|
||||
browsers.push(browser);
|
||||
const config = parseConfig({
|
||||
REA_ELECTRON_OBSERVE_ENABLED: "true",
|
||||
REA_ELECTRON_CDP_ENDPOINTS_JSON: JSON.stringify([browser.endpoint]),
|
||||
REA_ELECTRON_FILE_ROOTS_JSON: JSON.stringify([root]),
|
||||
REA_INVESTIGATION_INPUT_ROOTS_JSON: JSON.stringify([root]),
|
||||
});
|
||||
if (!config.ok) throw config.error;
|
||||
const authority = await loadConfiguredPermissionAuthority(config.value);
|
||||
if (!authority.ok) throw authority.error;
|
||||
const session = composeBinarySessionFromFactory(() => ({
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
const server = createServer(session, session, {
|
||||
electronObservation: new CdpElectronProvider(),
|
||||
permissionAuthority: authority.value,
|
||||
availabilityPolicy: () => ({
|
||||
processCaptureEnabled: false,
|
||||
evidenceFileRoots: 0,
|
||||
investigationInputRoots: 1,
|
||||
electronObservationEnabled: true,
|
||||
}),
|
||||
});
|
||||
const client = new Client({ name: "electron-mcp-test", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push(client, server, session);
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const listed = await client.callTool({
|
||||
name: "list_electron_targets",
|
||||
arguments: {
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_file_roots: [root],
|
||||
approved: true,
|
||||
},
|
||||
});
|
||||
expect(listed.isError).not.toBe(true);
|
||||
expect(listed.structuredContent).toMatchObject({
|
||||
result: {
|
||||
targets: { items: [{ target_id: "electron-page" }] },
|
||||
},
|
||||
});
|
||||
const inspected = await client.callTool({
|
||||
name: "inspect_electron_page",
|
||||
arguments: {
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_file_roots: [root],
|
||||
target_id: "electron-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
include_script_sources: true,
|
||||
source_capture_approved: true,
|
||||
},
|
||||
});
|
||||
const analyzed = await client.callTool({
|
||||
name: "analyze_javascript_application",
|
||||
arguments: { input_path: root, approved: true, detail: "full" },
|
||||
});
|
||||
expect(analyzed.isError).not.toBe(true);
|
||||
expect(analyzed.structuredContent).toMatchObject({
|
||||
result: { graph: { nodes: expect.any(Array), edges: expect.any(Array) } },
|
||||
});
|
||||
const analysisText = analyzed.content.find(({ type }) => type === "text");
|
||||
expect(analysisText).toBeDefined();
|
||||
if (analysisText?.type !== "text")
|
||||
throw new TypeError("Missing JavaScript analysis text projection");
|
||||
expect(analysisText.text).not.toContain('"nodes":[');
|
||||
const reconciled = await client.callTool({
|
||||
name: "reconcile_javascript_runtime",
|
||||
arguments: {
|
||||
static_layers: [
|
||||
{
|
||||
role: "application",
|
||||
analysis: evidenceFor(session, analyzed.structuredContent),
|
||||
},
|
||||
],
|
||||
runtime_observations: [evidenceFor(session, inspected.structuredContent)],
|
||||
},
|
||||
});
|
||||
expect(reconciled.isError).not.toBe(true);
|
||||
expect(reconciled.structuredContent).toMatchObject({
|
||||
result: {
|
||||
summary: { runtime_scripts: 1, matched: expect.any(Number) },
|
||||
source_map_authority: { used_for_primary_matching: false },
|
||||
},
|
||||
});
|
||||
expect(inspected.isError).not.toBe(true);
|
||||
expect(inspected.structuredContent).toMatchObject({
|
||||
result: {
|
||||
target: { file_path: expect.stringMatching(/\/index\.html$/u) },
|
||||
scripts: {
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
frame_id: "frame-main",
|
||||
file_path: expect.stringMatching(/\/app\.js$/u),
|
||||
}),
|
||||
],
|
||||
},
|
||||
workers: [
|
||||
expect.objectContaining({
|
||||
target_id: "electron-worker",
|
||||
opener_target_id: "electron-page",
|
||||
file_path: expect.stringMatching(/\/worker\.js$/u),
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
const outside = join(root, "..", `outside-${Date.now().toString(16)}`);
|
||||
await mkdir(outside);
|
||||
temporary.push(outside);
|
||||
const commandsBeforeDenial = browser.commands.length;
|
||||
const denied = await client.callTool({
|
||||
name: "list_electron_targets",
|
||||
arguments: {
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_file_roots: [outside],
|
||||
approved: true,
|
||||
},
|
||||
});
|
||||
expect(denied.isError).toBe(true);
|
||||
expect(denied.structuredContent).toMatchObject({
|
||||
error: {
|
||||
code: "permission_required",
|
||||
details: { missing: { roots: [expect.stringMatching(/outside-/u)] } },
|
||||
},
|
||||
});
|
||||
expect(browser.commands).toHaveLength(commandsBeforeDenial);
|
||||
}, 20_000);
|
||||
|
||||
it("exposes the target-free static JavaScript application workflow", async () => {
|
||||
const root = await createTestTempDirectory("rea-electron-static-mcp-");
|
||||
temporary.push(root);
|
||||
await writeElectronBoundaryFixture(root);
|
||||
const config = parseConfig({
|
||||
REA_INVESTIGATION_INPUT_ROOTS_JSON: JSON.stringify([root]),
|
||||
});
|
||||
if (!config.ok) throw config.error;
|
||||
const authority = await loadConfiguredPermissionAuthority(config.value);
|
||||
if (!authority.ok) throw authority.error;
|
||||
const session = composeBinarySessionFromFactory(() => ({
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
const server = createServer(session, session, {
|
||||
permissionAuthority: authority.value,
|
||||
availabilityPolicy: () => ({
|
||||
processCaptureEnabled: false,
|
||||
evidenceFileRoots: 0,
|
||||
investigationInputRoots: 1,
|
||||
}),
|
||||
});
|
||||
const client = new Client({
|
||||
name: "electron-static-mcp-test",
|
||||
version: "1",
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push(client, server, session);
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const analyzed = await client.callTool({
|
||||
name: "analyze_javascript_application",
|
||||
arguments: { input_path: root, approved: true },
|
||||
});
|
||||
|
||||
expect(analyzed.isError).not.toBe(true);
|
||||
const projected = z
|
||||
.object({
|
||||
evidence_id: z.string(),
|
||||
result: z.object({
|
||||
schema_version: z.literal(2),
|
||||
input_path: z.string(),
|
||||
unknowns: z.array(z.string()),
|
||||
summary: z.object({
|
||||
browser_windows: z.number(),
|
||||
context_bridge_apis: z.number(),
|
||||
ipc: z.object({ paired_renderer_transmissions: z.number() }),
|
||||
}),
|
||||
graph: z.object({
|
||||
graph_id: z.string(),
|
||||
node_count: z.number(),
|
||||
edge_count: z.number(),
|
||||
top_findings: z.array(z.unknown()),
|
||||
pages: z.object({ nodes: z.string(), edges: z.string() }),
|
||||
}),
|
||||
semantic_graph: z.object({
|
||||
graph_id: z.string(),
|
||||
nodes: z.number(),
|
||||
relations: z.number(),
|
||||
unknown_frontiers: z.number(),
|
||||
query_tool: z.literal("trace_javascript_semantics"),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.parse(analyzed.structuredContent);
|
||||
expect(projected.result).toMatchObject({
|
||||
input_path: expect.stringMatching(/rea-electron-static-mcp-/u),
|
||||
summary: {
|
||||
browser_windows: 3,
|
||||
context_bridge_apis: 2,
|
||||
ipc: { paired_renderer_transmissions: 4 },
|
||||
},
|
||||
});
|
||||
expect(projected.result.graph.node_count).toBeGreaterThan(0);
|
||||
expect(projected.result.semantic_graph.nodes).toBeGreaterThan(0);
|
||||
expect(JSON.stringify(analyzed.structuredContent)).not.toContain('"nodes":[');
|
||||
|
||||
const nodePage = await client.readResource({
|
||||
uri: projected.result.graph.pages.nodes,
|
||||
});
|
||||
const nodeContent = nodePage.contents[0];
|
||||
if (nodeContent === undefined || !("text" in nodeContent))
|
||||
throw new TypeError("Missing JavaScript application graph node page");
|
||||
expect(JSON.parse(nodeContent.text)).toMatchObject({
|
||||
evidence_id: projected.evidence_id,
|
||||
graph_id: projected.result.graph.graph_id,
|
||||
collection: "nodes",
|
||||
items: expect.any(Array),
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an unconfigured investigation ceiling before static analysis", async () => {
|
||||
const root = await createTestTempDirectory("rea-electron-denied-mcp-");
|
||||
temporary.push(root);
|
||||
await writeElectronBoundaryFixture(root);
|
||||
const config = parseConfig({});
|
||||
if (!config.ok) throw config.error;
|
||||
const authority = await loadConfiguredPermissionAuthority(config.value);
|
||||
if (!authority.ok) throw authority.error;
|
||||
const session = composeBinarySessionFromFactory(() => ({
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
const server = createServer(session, session, {
|
||||
permissionAuthority: authority.value,
|
||||
});
|
||||
const client = new Client({
|
||||
name: "electron-denied-mcp-test",
|
||||
version: "1",
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push(client, server, session);
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const status = await client.callTool({
|
||||
name: "binary_session",
|
||||
arguments: {
|
||||
detail: "capabilities",
|
||||
capability_family: "electron-provider",
|
||||
limit: 100,
|
||||
},
|
||||
});
|
||||
expect(status.structuredContent).toMatchObject({
|
||||
result: {
|
||||
capabilities: {
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "analyze_javascript_application",
|
||||
available: false,
|
||||
reason: "policy_disabled",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "reconcile_javascript_runtime",
|
||||
available: true,
|
||||
reason: "available",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
(await client.listTools()).tools.some(
|
||||
({ name }) => name === "analyze_javascript_application",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
const evidenceFor = (session: BinarySession, value: unknown) => {
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
!("evidence_id" in value) ||
|
||||
typeof value.evidence_id !== "string"
|
||||
)
|
||||
throw new TypeError("Missing compact Evidence ID");
|
||||
const evidence = session.evidenceById(value.evidence_id);
|
||||
if (evidence === undefined) throw new TypeError("Missing session Evidence");
|
||||
return evidence;
|
||||
};
|
||||
|
|
@ -0,0 +1,315 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/server";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js";
|
||||
import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "../../../src/contracts/managedWorkflowToolContracts.js";
|
||||
import {
|
||||
ENHANCED_TOOL_CONTRACTS,
|
||||
SESSION_TOOL_CONTRACTS,
|
||||
TOOL_CONTRACTS,
|
||||
} from "../../../src/contracts/toolContracts.js";
|
||||
import {
|
||||
jsonValueSchema,
|
||||
type JsonValue,
|
||||
} from "../../../src/domain/jsonValue.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { observed as ok } from "../../fixtures/analysisExecution.js";
|
||||
|
||||
const PROCEDURES = {
|
||||
"0x1": "_TtC7Fixture5Class",
|
||||
"0x2": "_TtV7Fixture6Struct",
|
||||
"0x3": "_TtP7Fixture8Protocol",
|
||||
"0x4": "_TtO7Fixture4Enum",
|
||||
"0x5": "_TtE7Fixture9Extension",
|
||||
"0x6": "prefix_TtOther",
|
||||
};
|
||||
|
||||
const page = (values: Readonly<Record<string, string>>) => ({
|
||||
items: Object.entries(values).map(([address, value]) => ({ address, value })),
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
total: Object.keys(values).length,
|
||||
next_offset: null,
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const fixturePort = (): AnalysisOperationPort => ({
|
||||
execute: (name, arguments_) => {
|
||||
switch (name) {
|
||||
case "list_procedures":
|
||||
return Promise.resolve(ok(page(PROCEDURES)));
|
||||
case "list_names":
|
||||
return Promise.resolve(
|
||||
ok(
|
||||
page({
|
||||
"0x10": "_OBJC_CLASS_$_Fixture",
|
||||
"0x11": "_OBJC_CLASS_$_Fixture",
|
||||
"0x12": "_OBJC_PROTOCOL_$_FixtureDelegate",
|
||||
"0x13": "entry",
|
||||
}),
|
||||
),
|
||||
);
|
||||
case "procedure_pseudo_code": {
|
||||
const procedure = arguments_.procedure;
|
||||
return Promise.resolve(
|
||||
ok(typeof procedure === "string" ? `pseudo:${procedure}` : "invalid"),
|
||||
);
|
||||
}
|
||||
case "procedure_callees": {
|
||||
const procedure = arguments_.procedure;
|
||||
return Promise.resolve(
|
||||
ok(
|
||||
procedure === "0x1"
|
||||
? ["0x2", "0x3"]
|
||||
: procedure === "0x2"
|
||||
? ["0x1"]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
case "procedure_callers":
|
||||
return Promise.resolve(ok(["0x9"]));
|
||||
case "address_name":
|
||||
return Promise.resolve(ok(arguments_.address ?? null));
|
||||
case "xrefs":
|
||||
return Promise.resolve(ok(["0x20", "0x21"]));
|
||||
case "resolve_containing_procedure":
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
query_address:
|
||||
typeof arguments_.address === "string"
|
||||
? arguments_.address
|
||||
: "0x0",
|
||||
found: false,
|
||||
procedure: null,
|
||||
reason: "not_in_procedure",
|
||||
}),
|
||||
);
|
||||
case "list_segments":
|
||||
return Promise.resolve(
|
||||
ok([{ name: "__TEXT", start: "0x1000", end: "0x2000" }]),
|
||||
);
|
||||
case "list_documents":
|
||||
return Promise.resolve(ok(["fixture"]));
|
||||
case "list_strings":
|
||||
return Promise.resolve(ok(page({ "0x30": "hello" })));
|
||||
case "analyze_function":
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
procedure: {
|
||||
address: "0x1",
|
||||
name: "entry",
|
||||
signature: null,
|
||||
locals: [],
|
||||
},
|
||||
pseudocode: {
|
||||
text: "return 0;",
|
||||
total_chars: 9,
|
||||
returned_chars: 9,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
},
|
||||
assembly: {
|
||||
items: [],
|
||||
total: 0,
|
||||
returned: 0,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
},
|
||||
comments: emptyBounded(),
|
||||
callers: emptyBounded(),
|
||||
callees: emptyBounded(),
|
||||
incoming_references: emptyBounded(),
|
||||
outgoing_references: emptyBounded(),
|
||||
referenced_strings: emptyBounded(),
|
||||
referenced_names: emptyBounded(),
|
||||
basic_blocks: emptyBounded(),
|
||||
instruction_scan: { scanned: 0, truncated: false },
|
||||
}),
|
||||
);
|
||||
default:
|
||||
return Promise.resolve(ok(null));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const emptyBounded = () => ({
|
||||
items: [],
|
||||
total: 0,
|
||||
returned: 0,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
});
|
||||
|
||||
const resources: Array<{ close(): Promise<void> }> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
resources.splice(0).map(async (resource) => resource.close()),
|
||||
);
|
||||
});
|
||||
|
||||
const connect = async (analysis: AnalysisOperationPort = fixturePort()) => {
|
||||
const server = createServer(analysis);
|
||||
const client = new Client({ name: "enhanced-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push(client, server);
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
return client;
|
||||
};
|
||||
|
||||
const jsonResult = (result: CallToolResult): JsonValue => {
|
||||
if (result.structuredContent === undefined)
|
||||
throw new Error("Tool result omitted structured content");
|
||||
const structured = jsonValueSchema.safeParse(result.structuredContent);
|
||||
if (!structured.success)
|
||||
throw new Error("Tool structured result was not JSON");
|
||||
if (
|
||||
typeof structured.data === "object" &&
|
||||
structured.data !== null &&
|
||||
!Array.isArray(structured.data) &&
|
||||
"normalized_result" in structured.data
|
||||
) {
|
||||
return structured.data.normalized_result ?? null;
|
||||
}
|
||||
if (
|
||||
typeof structured.data === "object" &&
|
||||
structured.data !== null &&
|
||||
!Array.isArray(structured.data) &&
|
||||
"evidence_id" in structured.data &&
|
||||
"result" in structured.data
|
||||
)
|
||||
return structured.data.result ?? null;
|
||||
const text = result.content.find((item) => item.type === "text");
|
||||
if (text?.type !== "text")
|
||||
throw new Error("Tool result omitted text content");
|
||||
const decoded: unknown = JSON.parse(text.text);
|
||||
const parsed = jsonValueSchema.safeParse(decoded);
|
||||
if (!parsed.success) throw new Error("Tool result was not JSON");
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
describe("enhanced MCP tools", () => {
|
||||
it("lists the complete target-open analysis surface", async () => {
|
||||
const client = await connect();
|
||||
const listed = await client.listTools();
|
||||
expect(listed.tools).toHaveLength(
|
||||
TOOL_CONTRACTS.length -
|
||||
SESSION_TOOL_CONTRACTS.length -
|
||||
MANAGED_WORKFLOW_TOOL_CONTRACTS.length,
|
||||
);
|
||||
expect(
|
||||
listed.tools
|
||||
.map(({ name }) => name)
|
||||
.filter((name) =>
|
||||
ENHANCED_TOOL_CONTRACTS.some((tool) => tool.name === name),
|
||||
)
|
||||
.sort(),
|
||||
).toEqual(ENHANCED_TOOL_CONTRACTS.map(({ name }) => name).sort());
|
||||
});
|
||||
|
||||
it("executes all thirteen tools through production registration", async () => {
|
||||
const client = await connect();
|
||||
const calls = [
|
||||
["swift_classes", { pattern: "Fixture" }],
|
||||
["get_objc_classes", { pattern: "Fixture" }],
|
||||
["get_objc_protocols", {}],
|
||||
["batch_decompile", { addresses: ["0x1", "0x2"] }],
|
||||
["get_call_graph", { address: "0x1", direction: "forward", depth: 2 }],
|
||||
["analyze_swift_types", {}],
|
||||
["find_xrefs_to_name", { name: "entry" }],
|
||||
["binary_overview", {}],
|
||||
["analyze_function", { procedure: "0x1" }],
|
||||
["inspect_native_api", { procedure: "0x1" }],
|
||||
["trace_feature", { query: "hello", max_operations: 10 }],
|
||||
["find_code_for_string", { query: "hello", max_operations: 10 }],
|
||||
["trace_call_path", { start: "0x1", goal: "0x2", max_operations: 10 }],
|
||||
] as const;
|
||||
const results = await Promise.all(
|
||||
calls.map(async ([name, arguments_]) =>
|
||||
jsonResult(await client.callTool({ name, arguments: arguments_ })),
|
||||
),
|
||||
);
|
||||
expect(results[0]).toMatchObject({ count: 1 });
|
||||
expect(results[1]).toMatchObject({ count: 1 });
|
||||
expect(results[2]).toMatchObject({ count: 1 });
|
||||
expect(results[3]).toEqual({
|
||||
items: [
|
||||
{ address: "0x1", status: "ok", pseudocode: "pseudo:0x1" },
|
||||
{ address: "0x2", status: "ok", pseudocode: "pseudo:0x2" },
|
||||
],
|
||||
total: 2,
|
||||
succeeded: 2,
|
||||
failed: 0,
|
||||
});
|
||||
expect(results[4]).toEqual({
|
||||
"0": [{ address: "0x1", status: "ok", calls: ["0x2", "0x3"] }],
|
||||
"1": [
|
||||
{ address: "0x2", status: "ok", calls: ["0x1"] },
|
||||
{ address: "0x3", status: "ok", calls: [] },
|
||||
],
|
||||
});
|
||||
expect(results[5]).toMatchObject({ total: 6 });
|
||||
expect(results[6]).toEqual({
|
||||
status: "resolved",
|
||||
name: "entry",
|
||||
address: "0x13",
|
||||
xrefs: ["0x20", "0x21"],
|
||||
});
|
||||
expect(results[7]).toMatchObject({
|
||||
document: "fixture",
|
||||
segment_count: 1,
|
||||
procedure_count: 6,
|
||||
string_count: 1,
|
||||
});
|
||||
expect(results[8]).toMatchObject({
|
||||
procedure: { address: "0x1", name: "entry" },
|
||||
pseudocode: { text: "return 0;" },
|
||||
});
|
||||
expect(results[9]).toMatchObject({
|
||||
procedure: { address: "0x1", name: "entry" },
|
||||
boundary: { available: false },
|
||||
unsupported_branches: [
|
||||
"structured-boundary-types",
|
||||
"jump-table-data-mapping",
|
||||
],
|
||||
residual_unknowns: expect.arrayContaining([
|
||||
expect.stringContaining("boundary types"),
|
||||
]),
|
||||
substeps: [
|
||||
{ operation: "analyze_function", status: "completed" },
|
||||
{ operation: "project_native_api_boundary", status: "unsupported" },
|
||||
{ operation: "preserve_residual_unknowns", status: "completed" },
|
||||
],
|
||||
});
|
||||
expect(results[10]).toMatchObject({
|
||||
query: "hello",
|
||||
search_mode: "literal",
|
||||
truncated: false,
|
||||
references: [
|
||||
{ target_address: "0x30", source_address: "0x20" },
|
||||
{ target_address: "0x30", source_address: "0x21" },
|
||||
],
|
||||
});
|
||||
expect(results[11]).toMatchObject({
|
||||
query: "hello",
|
||||
matches: [{ type: "string", address: "0x30", value: "hello" }],
|
||||
truncated: false,
|
||||
});
|
||||
expect(results[12]).toMatchObject({
|
||||
start: "0x1",
|
||||
goal: "0x2",
|
||||
direction: "forward",
|
||||
goal_status: "reached",
|
||||
traversal_path: ["0x1", "0x2"],
|
||||
nodes: [
|
||||
{ address: "0x1", depth: 0 },
|
||||
{ address: "0x2", depth: 1 },
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js";
|
||||
import { EnhancedTools } from "../../../src/application/EnhancedTools.js";
|
||||
import { AnalysisOutputError } from "../../../src/domain/errors.js";
|
||||
import { err } from "../../../src/domain/result.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { observed as ok } from "../../fixtures/analysisExecution.js";
|
||||
|
||||
const PROCEDURES = {
|
||||
"0x1": "_TtC7Fixture5Class",
|
||||
"0x2": "_TtV7Fixture6Struct",
|
||||
"0x3": "_TtP7Fixture8Protocol",
|
||||
"0x4": "_TtO7Fixture4Enum",
|
||||
"0x5": "_TtE7Fixture9Extension",
|
||||
"0x6": "prefix_TtOther",
|
||||
};
|
||||
|
||||
const page = (values: Readonly<Record<string, string>>) => ({
|
||||
items: Object.entries(values).map(([address, value]) => ({ address, value })),
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
total: Object.keys(values).length,
|
||||
next_offset: null,
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const fixturePort = (): AnalysisOperationPort => ({
|
||||
execute: (name, arguments_) => {
|
||||
switch (name) {
|
||||
case "list_procedures":
|
||||
return Promise.resolve(ok(page(PROCEDURES)));
|
||||
case "list_names":
|
||||
return Promise.resolve(
|
||||
ok(
|
||||
page({
|
||||
"0x10": "_OBJC_CLASS_$_Fixture",
|
||||
"0x11": "_OBJC_CLASS_$_Fixture",
|
||||
"0x12": "_OBJC_PROTOCOL_$_FixtureDelegate",
|
||||
"0x13": "entry",
|
||||
}),
|
||||
),
|
||||
);
|
||||
case "procedure_pseudo_code": {
|
||||
const procedure = arguments_.procedure;
|
||||
return Promise.resolve(
|
||||
ok(typeof procedure === "string" ? `pseudo:${procedure}` : "invalid"),
|
||||
);
|
||||
}
|
||||
case "procedure_callees": {
|
||||
const procedure = arguments_.procedure;
|
||||
return Promise.resolve(
|
||||
ok(
|
||||
procedure === "0x1"
|
||||
? ["0x2", "0x3"]
|
||||
: procedure === "0x2"
|
||||
? ["0x1"]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
case "procedure_callers":
|
||||
return Promise.resolve(ok(["0x9"]));
|
||||
case "address_name":
|
||||
return Promise.resolve(ok(arguments_.address ?? null));
|
||||
case "xrefs":
|
||||
return Promise.resolve(ok(["0x20", "0x21"]));
|
||||
case "resolve_containing_procedure":
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
query_address:
|
||||
typeof arguments_.address === "string"
|
||||
? arguments_.address
|
||||
: "0x0",
|
||||
found: false,
|
||||
procedure: null,
|
||||
reason: "not_in_procedure",
|
||||
}),
|
||||
);
|
||||
case "list_segments":
|
||||
return Promise.resolve(
|
||||
ok([{ name: "__TEXT", start: "0x1000", end: "0x2000" }]),
|
||||
);
|
||||
case "list_documents":
|
||||
return Promise.resolve(ok(["fixture"]));
|
||||
case "list_strings":
|
||||
return Promise.resolve(ok(page({ "0x30": "hello" })));
|
||||
case "analyze_function":
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
procedure: {
|
||||
address: "0x1",
|
||||
name: "entry",
|
||||
signature: null,
|
||||
locals: [],
|
||||
},
|
||||
pseudocode: {
|
||||
text: "return 0;",
|
||||
total_chars: 9,
|
||||
returned_chars: 9,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
},
|
||||
assembly: {
|
||||
items: [],
|
||||
total: 0,
|
||||
returned: 0,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
},
|
||||
comments: emptyBounded(),
|
||||
callers: emptyBounded(),
|
||||
callees: emptyBounded(),
|
||||
incoming_references: emptyBounded(),
|
||||
outgoing_references: emptyBounded(),
|
||||
referenced_strings: emptyBounded(),
|
||||
referenced_names: emptyBounded(),
|
||||
basic_blocks: emptyBounded(),
|
||||
instruction_scan: { scanned: 0, truncated: false },
|
||||
}),
|
||||
);
|
||||
default:
|
||||
return Promise.resolve(ok(null));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const emptyBounded = () => ({
|
||||
items: [],
|
||||
total: 0,
|
||||
returned: 0,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
});
|
||||
|
||||
const resources: Array<{ close(): Promise<void> }> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
resources.splice(0).map(async (resource) => resource.close()),
|
||||
);
|
||||
});
|
||||
|
||||
const connect = async (analysis: AnalysisOperationPort = fixturePort()) => {
|
||||
const server = createServer(analysis);
|
||||
const client = new Client({ name: "enhanced-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push(client, server);
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
return client;
|
||||
};
|
||||
|
||||
describe("enhanced MCP tools", () => {
|
||||
it("returns Evidence IDs and explicit call-path frontiers at limits", async () => {
|
||||
const client = await connect();
|
||||
const result = await client.callTool({
|
||||
name: "trace_call_path",
|
||||
arguments: {
|
||||
start: "0x1",
|
||||
goal: "0x3",
|
||||
max_nodes: 1,
|
||||
max_operations: 10,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
evidence_id: expect.stringMatching(/^ev_[a-f0-9]{64}$/u),
|
||||
result: {
|
||||
goal_status: "not_reached",
|
||||
nodes: [{ address: "0x1", depth: 0 }],
|
||||
frontier: ["0x2"],
|
||||
truncated: true,
|
||||
residual_unknowns: expect.arrayContaining([
|
||||
expect.stringContaining("reached the node limit"),
|
||||
expect.stringContaining("reached the edge limit"),
|
||||
]),
|
||||
limits: {
|
||||
max_nodes: 1,
|
||||
nodes_visited: 1,
|
||||
operations_used: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds batch concurrency at the parsed maximum of 20", async () => {
|
||||
let active = 0;
|
||||
let maximum = 0;
|
||||
const analysis: AnalysisOperationPort = {
|
||||
execute: async (name) => {
|
||||
if (name !== "procedure_pseudo_code") return ok(null);
|
||||
active += 1;
|
||||
maximum = Math.max(maximum, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
active -= 1;
|
||||
return ok("pseudo");
|
||||
},
|
||||
};
|
||||
const client = await connect(analysis);
|
||||
const result = await client.callTool({
|
||||
name: "batch_decompile",
|
||||
arguments: {
|
||||
addresses: Array.from(
|
||||
{ length: 20 },
|
||||
(_, index) => `0x${String(index)}`,
|
||||
),
|
||||
},
|
||||
});
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(maximum).toBe(20);
|
||||
});
|
||||
|
||||
it("returns ordered typed batch failures and zero counts for empty input", async () => {
|
||||
const tools = new EnhancedTools({
|
||||
execute: (_name, arguments_) =>
|
||||
arguments_.procedure === "0x2"
|
||||
? Promise.resolve(err(new AnalysisOutputError("decompile", "failed")))
|
||||
: Promise.resolve(ok("pseudo")),
|
||||
});
|
||||
|
||||
const result = await tools.execute("batch_decompile", {
|
||||
addresses: ["0x1", "0x2"],
|
||||
});
|
||||
const empty = await tools.execute("batch_decompile", { addresses: [] });
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [
|
||||
{ address: "0x1", status: "ok", pseudocode: "pseudo" },
|
||||
{
|
||||
address: "0x2",
|
||||
status: "error",
|
||||
error: {
|
||||
code: "unreadable_output",
|
||||
category: "execution_failure",
|
||||
message:
|
||||
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
|
||||
retryable: false,
|
||||
remediation: {
|
||||
action:
|
||||
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
|
||||
restart_required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
succeeded: 1,
|
||||
failed: 1,
|
||||
},
|
||||
});
|
||||
expect(empty).toEqual({
|
||||
ok: true,
|
||||
value: { items: [], total: 0, succeeded: 0, failed: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/server";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js";
|
||||
import { EnhancedTools } from "../../../src/application/EnhancedTools.js";
|
||||
import { AnalysisOutputError } from "../../../src/domain/errors.js";
|
||||
import {
|
||||
jsonValueSchema,
|
||||
type JsonValue,
|
||||
} from "../../../src/domain/jsonValue.js";
|
||||
import { err } from "../../../src/domain/result.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { observed as ok } from "../../fixtures/analysisExecution.js";
|
||||
|
||||
const PROCEDURES = {
|
||||
"0x1": "_TtC7Fixture5Class",
|
||||
"0x2": "_TtV7Fixture6Struct",
|
||||
"0x3": "_TtP7Fixture8Protocol",
|
||||
"0x4": "_TtO7Fixture4Enum",
|
||||
"0x5": "_TtE7Fixture9Extension",
|
||||
"0x6": "prefix_TtOther",
|
||||
};
|
||||
|
||||
const page = (values: Readonly<Record<string, string>>) => ({
|
||||
items: Object.entries(values).map(([address, value]) => ({ address, value })),
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
total: Object.keys(values).length,
|
||||
next_offset: null,
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const fixturePort = (): AnalysisOperationPort => ({
|
||||
execute: (name, arguments_) => {
|
||||
switch (name) {
|
||||
case "list_procedures":
|
||||
return Promise.resolve(ok(page(PROCEDURES)));
|
||||
case "list_names":
|
||||
return Promise.resolve(
|
||||
ok(
|
||||
page({
|
||||
"0x10": "_OBJC_CLASS_$_Fixture",
|
||||
"0x11": "_OBJC_CLASS_$_Fixture",
|
||||
"0x12": "_OBJC_PROTOCOL_$_FixtureDelegate",
|
||||
"0x13": "entry",
|
||||
}),
|
||||
),
|
||||
);
|
||||
case "procedure_pseudo_code": {
|
||||
const procedure = arguments_.procedure;
|
||||
return Promise.resolve(
|
||||
ok(typeof procedure === "string" ? `pseudo:${procedure}` : "invalid"),
|
||||
);
|
||||
}
|
||||
case "procedure_callees": {
|
||||
const procedure = arguments_.procedure;
|
||||
return Promise.resolve(
|
||||
ok(
|
||||
procedure === "0x1"
|
||||
? ["0x2", "0x3"]
|
||||
: procedure === "0x2"
|
||||
? ["0x1"]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
case "procedure_callers":
|
||||
return Promise.resolve(ok(["0x9"]));
|
||||
case "address_name":
|
||||
return Promise.resolve(ok(arguments_.address ?? null));
|
||||
case "xrefs":
|
||||
return Promise.resolve(ok(["0x20", "0x21"]));
|
||||
case "resolve_containing_procedure":
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
query_address:
|
||||
typeof arguments_.address === "string"
|
||||
? arguments_.address
|
||||
: "0x0",
|
||||
found: false,
|
||||
procedure: null,
|
||||
reason: "not_in_procedure",
|
||||
}),
|
||||
);
|
||||
case "list_segments":
|
||||
return Promise.resolve(
|
||||
ok([{ name: "__TEXT", start: "0x1000", end: "0x2000" }]),
|
||||
);
|
||||
case "list_documents":
|
||||
return Promise.resolve(ok(["fixture"]));
|
||||
case "list_strings":
|
||||
return Promise.resolve(ok(page({ "0x30": "hello" })));
|
||||
case "analyze_function":
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
procedure: {
|
||||
address: "0x1",
|
||||
name: "entry",
|
||||
signature: null,
|
||||
locals: [],
|
||||
},
|
||||
pseudocode: {
|
||||
text: "return 0;",
|
||||
total_chars: 9,
|
||||
returned_chars: 9,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
},
|
||||
assembly: {
|
||||
items: [],
|
||||
total: 0,
|
||||
returned: 0,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
},
|
||||
comments: emptyBounded(),
|
||||
callers: emptyBounded(),
|
||||
callees: emptyBounded(),
|
||||
incoming_references: emptyBounded(),
|
||||
outgoing_references: emptyBounded(),
|
||||
referenced_strings: emptyBounded(),
|
||||
referenced_names: emptyBounded(),
|
||||
basic_blocks: emptyBounded(),
|
||||
instruction_scan: { scanned: 0, truncated: false },
|
||||
}),
|
||||
);
|
||||
default:
|
||||
return Promise.resolve(ok(null));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const emptyBounded = () => ({
|
||||
items: [],
|
||||
total: 0,
|
||||
returned: 0,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
});
|
||||
|
||||
const resources: Array<{ close(): Promise<void> }> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
resources.splice(0).map(async (resource) => resource.close()),
|
||||
);
|
||||
});
|
||||
|
||||
const connect = async (analysis: AnalysisOperationPort = fixturePort()) => {
|
||||
const server = createServer(analysis);
|
||||
const client = new Client({ name: "enhanced-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push(client, server);
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
return client;
|
||||
};
|
||||
|
||||
const jsonResult = (result: CallToolResult): JsonValue => {
|
||||
if (result.structuredContent === undefined)
|
||||
throw new Error("Tool result omitted structured content");
|
||||
const structured = jsonValueSchema.safeParse(result.structuredContent);
|
||||
if (!structured.success)
|
||||
throw new Error("Tool structured result was not JSON");
|
||||
if (
|
||||
typeof structured.data === "object" &&
|
||||
structured.data !== null &&
|
||||
!Array.isArray(structured.data) &&
|
||||
"normalized_result" in structured.data
|
||||
) {
|
||||
return structured.data.normalized_result ?? null;
|
||||
}
|
||||
if (
|
||||
typeof structured.data === "object" &&
|
||||
structured.data !== null &&
|
||||
!Array.isArray(structured.data) &&
|
||||
"evidence_id" in structured.data &&
|
||||
"result" in structured.data
|
||||
)
|
||||
return structured.data.result ?? null;
|
||||
const text = result.content.find((item) => item.type === "text");
|
||||
if (text?.type !== "text")
|
||||
throw new Error("Tool result omitted text content");
|
||||
const decoded: unknown = JSON.parse(text.text);
|
||||
const parsed = jsonValueSchema.safeParse(decoded);
|
||||
if (!parsed.success) throw new Error("Tool result was not JSON");
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
describe("enhanced MCP tools", () => {
|
||||
it("returns typed graph failures and stable unresolved-name results", async () => {
|
||||
const tools = new EnhancedTools({
|
||||
execute: (name) =>
|
||||
name === "procedure_callees"
|
||||
? Promise.resolve(err(new AnalysisOutputError(name, "failed")))
|
||||
: Promise.resolve(ok(page({}))),
|
||||
});
|
||||
|
||||
const graph = await tools.execute("get_call_graph", {
|
||||
address: "0x1",
|
||||
direction: "forward",
|
||||
depth: 1,
|
||||
});
|
||||
const unresolved = await tools.execute("find_xrefs_to_name", {
|
||||
name: "missing",
|
||||
});
|
||||
|
||||
expect(graph).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
"0": [
|
||||
{
|
||||
address: "0x1",
|
||||
status: "error",
|
||||
error: {
|
||||
category: "execution_failure",
|
||||
message:
|
||||
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(unresolved).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
status: "unresolved",
|
||||
name: "missing",
|
||||
reason: "name_not_found",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("follows procedure pagination for whole-binary workflows", async () => {
|
||||
const offsets: number[] = [];
|
||||
const client = await connect({
|
||||
execute: (_name, arguments_) => {
|
||||
const offset = arguments_.offset;
|
||||
offsets.push(typeof offset === "number" ? offset : 0);
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
items: [
|
||||
{
|
||||
address: offset === 500 ? "0x2" : "0x1",
|
||||
value: offset === 500 ? "_TtC4Last" : "_TtC5First",
|
||||
},
|
||||
],
|
||||
offset: typeof offset === "number" ? offset : 0,
|
||||
limit: 500,
|
||||
total: 2,
|
||||
next_offset: offset === 500 ? null : 500,
|
||||
has_more: offset !== 500,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const result = jsonResult(
|
||||
await client.callTool({ name: "swift_classes", arguments: {} }),
|
||||
);
|
||||
expect(offsets).toEqual([0, 500]);
|
||||
expect(result).toMatchObject({ count: 2 });
|
||||
});
|
||||
|
||||
it("does not start another page after cancellation", async () => {
|
||||
const controller = new AbortController();
|
||||
let calls = 0;
|
||||
const analysis: AnalysisOperationPort = {
|
||||
execute: () => {
|
||||
calls += 1;
|
||||
controller.abort();
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
items: [{ address: "0x1", value: "_TtC5First" }],
|
||||
offset: 0,
|
||||
limit: 500,
|
||||
total: 2,
|
||||
next_offset: 500,
|
||||
has_more: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const result = await new EnhancedTools(analysis).execute(
|
||||
"swift_classes",
|
||||
{},
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
expect(calls).toBe(1);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected cancellation");
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,316 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/server";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js";
|
||||
import {
|
||||
jsonValueSchema,
|
||||
type JsonValue,
|
||||
} from "../../../src/domain/jsonValue.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { observed as ok } from "../../fixtures/analysisExecution.js";
|
||||
|
||||
const PROCEDURES = {
|
||||
"0x1": "_TtC7Fixture5Class",
|
||||
"0x2": "_TtV7Fixture6Struct",
|
||||
"0x3": "_TtP7Fixture8Protocol",
|
||||
"0x4": "_TtO7Fixture4Enum",
|
||||
"0x5": "_TtE7Fixture9Extension",
|
||||
"0x6": "prefix_TtOther",
|
||||
};
|
||||
|
||||
const page = (values: Readonly<Record<string, string>>) => ({
|
||||
items: Object.entries(values).map(([address, value]) => ({ address, value })),
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
total: Object.keys(values).length,
|
||||
next_offset: null,
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const fixturePort = (): AnalysisOperationPort => ({
|
||||
execute: (name, arguments_) => {
|
||||
switch (name) {
|
||||
case "list_procedures":
|
||||
return Promise.resolve(ok(page(PROCEDURES)));
|
||||
case "list_names":
|
||||
return Promise.resolve(
|
||||
ok(
|
||||
page({
|
||||
"0x10": "_OBJC_CLASS_$_Fixture",
|
||||
"0x11": "_OBJC_CLASS_$_Fixture",
|
||||
"0x12": "_OBJC_PROTOCOL_$_FixtureDelegate",
|
||||
"0x13": "entry",
|
||||
}),
|
||||
),
|
||||
);
|
||||
case "procedure_pseudo_code": {
|
||||
const procedure = arguments_.procedure;
|
||||
return Promise.resolve(
|
||||
ok(typeof procedure === "string" ? `pseudo:${procedure}` : "invalid"),
|
||||
);
|
||||
}
|
||||
case "procedure_callees": {
|
||||
const procedure = arguments_.procedure;
|
||||
return Promise.resolve(
|
||||
ok(
|
||||
procedure === "0x1"
|
||||
? ["0x2", "0x3"]
|
||||
: procedure === "0x2"
|
||||
? ["0x1"]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
case "procedure_callers":
|
||||
return Promise.resolve(ok(["0x9"]));
|
||||
case "address_name":
|
||||
return Promise.resolve(ok(arguments_.address ?? null));
|
||||
case "xrefs":
|
||||
return Promise.resolve(ok(["0x20", "0x21"]));
|
||||
case "resolve_containing_procedure":
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
query_address:
|
||||
typeof arguments_.address === "string"
|
||||
? arguments_.address
|
||||
: "0x0",
|
||||
found: false,
|
||||
procedure: null,
|
||||
reason: "not_in_procedure",
|
||||
}),
|
||||
);
|
||||
case "list_segments":
|
||||
return Promise.resolve(
|
||||
ok([{ name: "__TEXT", start: "0x1000", end: "0x2000" }]),
|
||||
);
|
||||
case "list_documents":
|
||||
return Promise.resolve(ok(["fixture"]));
|
||||
case "list_strings":
|
||||
return Promise.resolve(ok(page({ "0x30": "hello" })));
|
||||
case "analyze_function":
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
procedure: {
|
||||
address: "0x1",
|
||||
name: "entry",
|
||||
signature: null,
|
||||
locals: [],
|
||||
},
|
||||
pseudocode: {
|
||||
text: "return 0;",
|
||||
total_chars: 9,
|
||||
returned_chars: 9,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
},
|
||||
assembly: {
|
||||
items: [],
|
||||
total: 0,
|
||||
returned: 0,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
},
|
||||
comments: emptyBounded(),
|
||||
callers: emptyBounded(),
|
||||
callees: emptyBounded(),
|
||||
incoming_references: emptyBounded(),
|
||||
outgoing_references: emptyBounded(),
|
||||
referenced_strings: emptyBounded(),
|
||||
referenced_names: emptyBounded(),
|
||||
basic_blocks: emptyBounded(),
|
||||
instruction_scan: { scanned: 0, truncated: false },
|
||||
}),
|
||||
);
|
||||
default:
|
||||
return Promise.resolve(ok(null));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const emptyBounded = () => ({
|
||||
items: [],
|
||||
total: 0,
|
||||
returned: 0,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
});
|
||||
|
||||
const resources: Array<{ close(): Promise<void> }> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
resources.splice(0).map(async (resource) => resource.close()),
|
||||
);
|
||||
});
|
||||
|
||||
const connect = async (analysis: AnalysisOperationPort = fixturePort()) => {
|
||||
const server = createServer(analysis);
|
||||
const client = new Client({ name: "enhanced-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push(client, server);
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
return client;
|
||||
};
|
||||
|
||||
const jsonResult = (result: CallToolResult): JsonValue => {
|
||||
if (result.structuredContent === undefined)
|
||||
throw new Error("Tool result omitted structured content");
|
||||
const structured = jsonValueSchema.safeParse(result.structuredContent);
|
||||
if (!structured.success)
|
||||
throw new Error("Tool structured result was not JSON");
|
||||
if (
|
||||
typeof structured.data === "object" &&
|
||||
structured.data !== null &&
|
||||
!Array.isArray(structured.data) &&
|
||||
"normalized_result" in structured.data
|
||||
) {
|
||||
return structured.data.normalized_result ?? null;
|
||||
}
|
||||
if (
|
||||
typeof structured.data === "object" &&
|
||||
structured.data !== null &&
|
||||
!Array.isArray(structured.data) &&
|
||||
"evidence_id" in structured.data &&
|
||||
"result" in structured.data
|
||||
)
|
||||
return structured.data.result ?? null;
|
||||
const text = result.content.find((item) => item.type === "text");
|
||||
if (text?.type !== "text")
|
||||
throw new Error("Tool result omitted text content");
|
||||
const decoded: unknown = JSON.parse(text.text);
|
||||
const parsed = jsonValueSchema.safeParse(decoded);
|
||||
if (!parsed.success) throw new Error("Tool result was not JSON");
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
describe("enhanced MCP tools", () => {
|
||||
it("follows name pagination for exhaustive Objective-C discovery", async () => {
|
||||
const offsets: number[] = [];
|
||||
const client = await connect({
|
||||
execute: (name, arguments_) => {
|
||||
expect(name).toBe("list_names");
|
||||
const offset =
|
||||
typeof arguments_.offset === "number" ? arguments_.offset : 0;
|
||||
offsets.push(offset);
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
items: [
|
||||
{
|
||||
address: offset === 500 ? "0x2" : "0x1",
|
||||
value:
|
||||
offset === 500 ? "_OBJC_CLASS_$_Last" : "_OBJC_CLASS_$_First",
|
||||
},
|
||||
],
|
||||
offset,
|
||||
limit: 500,
|
||||
total: 2,
|
||||
next_offset: offset === 500 ? null : 500,
|
||||
has_more: offset !== 500,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const result = jsonResult(
|
||||
await client.callTool({ name: "get_objc_classes", arguments: {} }),
|
||||
);
|
||||
expect(offsets).toEqual([0, 500]);
|
||||
expect(result).toMatchObject({ count: 2 });
|
||||
});
|
||||
|
||||
it("honors overview detail and limit while reporting exhaustive totals", async () => {
|
||||
const procedureOffsets: number[] = [];
|
||||
const client = await connect({
|
||||
execute: (name, arguments_) => {
|
||||
switch (name) {
|
||||
case "list_segments":
|
||||
return Promise.resolve(
|
||||
ok([
|
||||
{ name: "__TEXT", start: "0x1000", end: "0x1800" },
|
||||
{ name: "__DATA", start: "0x1800", end: "0x2000" },
|
||||
]),
|
||||
);
|
||||
case "list_documents":
|
||||
return Promise.resolve(ok(["fixture"]));
|
||||
case "list_strings":
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
items: [{ address: "0x30", value: "first page only" }],
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
total: 700,
|
||||
next_offset: 100,
|
||||
has_more: true,
|
||||
}),
|
||||
);
|
||||
case "list_procedures": {
|
||||
const offset =
|
||||
typeof arguments_.offset === "number" ? arguments_.offset : 0;
|
||||
procedureOffsets.push(offset);
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
items: [
|
||||
{
|
||||
address: offset === 500 ? "0x2" : "0x1",
|
||||
value: offset === 500 ? "last" : "first",
|
||||
},
|
||||
],
|
||||
offset,
|
||||
limit: 500,
|
||||
total: 2,
|
||||
next_offset: offset === 500 ? null : 500,
|
||||
has_more: offset !== 500,
|
||||
}),
|
||||
);
|
||||
}
|
||||
default:
|
||||
return Promise.resolve(ok(null));
|
||||
}
|
||||
},
|
||||
});
|
||||
const result = jsonResult(
|
||||
await client.callTool({
|
||||
name: "binary_overview",
|
||||
arguments: { detail: "detailed", limit: 1 },
|
||||
}),
|
||||
);
|
||||
expect(procedureOffsets).toEqual([0]);
|
||||
expect(result).toEqual({
|
||||
document: "fixture",
|
||||
detail: "detailed",
|
||||
segments: [
|
||||
{ name: "__TEXT", start: "0x1000", end: "0x1800", length: 2048 },
|
||||
],
|
||||
segment_count: 2,
|
||||
procedure_count: 2,
|
||||
string_count: 700,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-advancing pagination metadata", async () => {
|
||||
const client = await connect({
|
||||
execute: () =>
|
||||
Promise.resolve(
|
||||
ok({
|
||||
items: [{ address: "0x1", value: "_TtC5First" }],
|
||||
offset: 0,
|
||||
limit: 500,
|
||||
total: 2,
|
||||
next_offset: 0,
|
||||
has_more: true,
|
||||
}),
|
||||
),
|
||||
});
|
||||
const result = await client.callTool({
|
||||
name: "swift_classes",
|
||||
arguments: {},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
const text = result.content.find((item) => item.type === "text");
|
||||
expect(text?.type === "text" ? text.text : "").toBe(
|
||||
JSON.stringify(result.structuredContent),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/server";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js";
|
||||
import {
|
||||
jsonValueSchema,
|
||||
type JsonValue,
|
||||
} from "../../../src/domain/jsonValue.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { observed as ok } from "../../fixtures/analysisExecution.js";
|
||||
|
||||
const PROCEDURES = {
|
||||
"0x1": "_TtC7Fixture5Class",
|
||||
"0x2": "_TtV7Fixture6Struct",
|
||||
"0x3": "_TtP7Fixture8Protocol",
|
||||
"0x4": "_TtO7Fixture4Enum",
|
||||
"0x5": "_TtE7Fixture9Extension",
|
||||
"0x6": "prefix_TtOther",
|
||||
};
|
||||
|
||||
const page = (values: Readonly<Record<string, string>>) => ({
|
||||
items: Object.entries(values).map(([address, value]) => ({ address, value })),
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
total: Object.keys(values).length,
|
||||
next_offset: null,
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const fixturePort = (): AnalysisOperationPort => ({
|
||||
execute: (name, arguments_) => {
|
||||
switch (name) {
|
||||
case "list_procedures":
|
||||
return Promise.resolve(ok(page(PROCEDURES)));
|
||||
case "list_names":
|
||||
return Promise.resolve(
|
||||
ok(
|
||||
page({
|
||||
"0x10": "_OBJC_CLASS_$_Fixture",
|
||||
"0x11": "_OBJC_CLASS_$_Fixture",
|
||||
"0x12": "_OBJC_PROTOCOL_$_FixtureDelegate",
|
||||
"0x13": "entry",
|
||||
}),
|
||||
),
|
||||
);
|
||||
case "procedure_pseudo_code": {
|
||||
const procedure = arguments_.procedure;
|
||||
return Promise.resolve(
|
||||
ok(typeof procedure === "string" ? `pseudo:${procedure}` : "invalid"),
|
||||
);
|
||||
}
|
||||
case "procedure_callees": {
|
||||
const procedure = arguments_.procedure;
|
||||
return Promise.resolve(
|
||||
ok(
|
||||
procedure === "0x1"
|
||||
? ["0x2", "0x3"]
|
||||
: procedure === "0x2"
|
||||
? ["0x1"]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
case "procedure_callers":
|
||||
return Promise.resolve(ok(["0x9"]));
|
||||
case "address_name":
|
||||
return Promise.resolve(ok(arguments_.address ?? null));
|
||||
case "xrefs":
|
||||
return Promise.resolve(ok(["0x20", "0x21"]));
|
||||
case "resolve_containing_procedure":
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
query_address:
|
||||
typeof arguments_.address === "string"
|
||||
? arguments_.address
|
||||
: "0x0",
|
||||
found: false,
|
||||
procedure: null,
|
||||
reason: "not_in_procedure",
|
||||
}),
|
||||
);
|
||||
case "list_segments":
|
||||
return Promise.resolve(
|
||||
ok([{ name: "__TEXT", start: "0x1000", end: "0x2000" }]),
|
||||
);
|
||||
case "list_documents":
|
||||
return Promise.resolve(ok(["fixture"]));
|
||||
case "list_strings":
|
||||
return Promise.resolve(ok(page({ "0x30": "hello" })));
|
||||
case "analyze_function":
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
procedure: {
|
||||
address: "0x1",
|
||||
name: "entry",
|
||||
signature: null,
|
||||
locals: [],
|
||||
},
|
||||
pseudocode: {
|
||||
text: "return 0;",
|
||||
total_chars: 9,
|
||||
returned_chars: 9,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
},
|
||||
assembly: {
|
||||
items: [],
|
||||
total: 0,
|
||||
returned: 0,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
},
|
||||
comments: emptyBounded(),
|
||||
callers: emptyBounded(),
|
||||
callees: emptyBounded(),
|
||||
incoming_references: emptyBounded(),
|
||||
outgoing_references: emptyBounded(),
|
||||
referenced_strings: emptyBounded(),
|
||||
referenced_names: emptyBounded(),
|
||||
basic_blocks: emptyBounded(),
|
||||
instruction_scan: { scanned: 0, truncated: false },
|
||||
}),
|
||||
);
|
||||
default:
|
||||
return Promise.resolve(ok(null));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const emptyBounded = () => ({
|
||||
items: [],
|
||||
total: 0,
|
||||
returned: 0,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
});
|
||||
|
||||
const resources: Array<{ close(): Promise<void> }> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
resources.splice(0).map(async (resource) => resource.close()),
|
||||
);
|
||||
});
|
||||
|
||||
const connect = async (analysis: AnalysisOperationPort = fixturePort()) => {
|
||||
const server = createServer(analysis);
|
||||
const client = new Client({ name: "enhanced-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push(client, server);
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
return client;
|
||||
};
|
||||
|
||||
const jsonResult = (result: CallToolResult): JsonValue => {
|
||||
if (result.structuredContent === undefined)
|
||||
throw new Error("Tool result omitted structured content");
|
||||
const structured = jsonValueSchema.safeParse(result.structuredContent);
|
||||
if (!structured.success)
|
||||
throw new Error("Tool structured result was not JSON");
|
||||
if (
|
||||
typeof structured.data === "object" &&
|
||||
structured.data !== null &&
|
||||
!Array.isArray(structured.data) &&
|
||||
"normalized_result" in structured.data
|
||||
) {
|
||||
return structured.data.normalized_result ?? null;
|
||||
}
|
||||
if (
|
||||
typeof structured.data === "object" &&
|
||||
structured.data !== null &&
|
||||
!Array.isArray(structured.data) &&
|
||||
"evidence_id" in structured.data &&
|
||||
"result" in structured.data
|
||||
)
|
||||
return structured.data.result ?? null;
|
||||
const text = result.content.find((item) => item.type === "text");
|
||||
if (text?.type !== "text")
|
||||
throw new Error("Tool result omitted text content");
|
||||
const decoded: unknown = JSON.parse(text.text);
|
||||
const parsed = jsonValueSchema.safeParse(decoded);
|
||||
if (!parsed.success) throw new Error("Tool result was not JSON");
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
describe("enhanced MCP tools", () => {
|
||||
it("stops trace_feature at its explicit operation budget", async () => {
|
||||
let operations = 0;
|
||||
const client = await connect({
|
||||
execute: () => {
|
||||
operations += 1;
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
items: [{ address: "0x1", value: "needle" }],
|
||||
offset: 0,
|
||||
limit: 500,
|
||||
total: 2,
|
||||
next_offset: 1,
|
||||
has_more: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const result = jsonResult(
|
||||
await client.callTool({
|
||||
name: "trace_feature",
|
||||
arguments: { query: "needle", max_operations: 1 },
|
||||
}),
|
||||
);
|
||||
expect(operations).toBe(1);
|
||||
expect(result).toMatchObject({
|
||||
operations_used: 1,
|
||||
operation_budget: 1,
|
||||
truncated: true,
|
||||
});
|
||||
expect(JSON.stringify(result)).toContain("operation budget");
|
||||
});
|
||||
|
||||
it("returns correction-only structured validation issues", async () => {
|
||||
const client = await connect();
|
||||
const valid = await client.callTool({
|
||||
name: "trace_feature",
|
||||
arguments: { query: "needle", max_operations: 1 },
|
||||
});
|
||||
expect(valid.isError).not.toBe(true);
|
||||
const misspelled = await client.callTool({
|
||||
name: "trace_feature",
|
||||
arguments: { query: "needle", max_operatons: 1 },
|
||||
});
|
||||
expect(misspelled).toMatchObject({
|
||||
isError: true,
|
||||
structuredContent: {
|
||||
error: {
|
||||
code: "invalid_request",
|
||||
retryable: true,
|
||||
details: {
|
||||
issues: [{ path: ["max_operatons"], reason: "unknown_argument" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const nestedMisspelled = await client.callTool({
|
||||
name: "analyze_function",
|
||||
arguments: {
|
||||
procedure: "0x1",
|
||||
collection_offset: { commentz: 1 },
|
||||
},
|
||||
});
|
||||
expect(nestedMisspelled).toMatchObject({
|
||||
isError: true,
|
||||
structuredContent: {
|
||||
error: {
|
||||
details: {
|
||||
issues: [
|
||||
{
|
||||
path: ["collection_offset", "commentz"],
|
||||
reason: "unknown_argument",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const bounded = await client.callTool({
|
||||
name: "trace_feature",
|
||||
arguments: { query: "needle", max_operations: 101 },
|
||||
});
|
||||
expect(bounded.structuredContent).toMatchObject({
|
||||
error: {
|
||||
details: {
|
||||
issues: [
|
||||
{
|
||||
path: ["max_operations"],
|
||||
reason: "out_of_range",
|
||||
maximum: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(bounded.content[0]).toEqual({
|
||||
type: "text",
|
||||
text: JSON.stringify(bounded.structuredContent),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a typed tool error for malformed Hopper boundary values", async () => {
|
||||
const client = await connect({
|
||||
execute: () => Promise.resolve(ok(["not", "a", "procedure", "map"])),
|
||||
});
|
||||
const result = await client.callTool({
|
||||
name: "swift_classes",
|
||||
arguments: {},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
const text = result.content.find((item) => item.type === "text");
|
||||
expect(text?.type === "text" ? text.text : "").toBe(
|
||||
JSON.stringify(result.structuredContent),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { observed as ok } from "../../fixtures/analysisExecution.js";
|
||||
|
||||
const PROCEDURES = {
|
||||
"0x1": "_TtC7Fixture5Class",
|
||||
"0x2": "_TtV7Fixture6Struct",
|
||||
"0x3": "_TtP7Fixture8Protocol",
|
||||
"0x4": "_TtO7Fixture4Enum",
|
||||
"0x5": "_TtE7Fixture9Extension",
|
||||
"0x6": "prefix_TtOther",
|
||||
};
|
||||
|
||||
const page = (values: Readonly<Record<string, string>>) => ({
|
||||
items: Object.entries(values).map(([address, value]) => ({ address, value })),
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
total: Object.keys(values).length,
|
||||
next_offset: null,
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const fixturePort = (): AnalysisOperationPort => ({
|
||||
execute: (name, arguments_) => {
|
||||
switch (name) {
|
||||
case "list_procedures":
|
||||
return Promise.resolve(ok(page(PROCEDURES)));
|
||||
case "list_names":
|
||||
return Promise.resolve(
|
||||
ok(
|
||||
page({
|
||||
"0x10": "_OBJC_CLASS_$_Fixture",
|
||||
"0x11": "_OBJC_CLASS_$_Fixture",
|
||||
"0x12": "_OBJC_PROTOCOL_$_FixtureDelegate",
|
||||
"0x13": "entry",
|
||||
}),
|
||||
),
|
||||
);
|
||||
case "procedure_pseudo_code": {
|
||||
const procedure = arguments_.procedure;
|
||||
return Promise.resolve(
|
||||
ok(typeof procedure === "string" ? `pseudo:${procedure}` : "invalid"),
|
||||
);
|
||||
}
|
||||
case "procedure_callees": {
|
||||
const procedure = arguments_.procedure;
|
||||
return Promise.resolve(
|
||||
ok(
|
||||
procedure === "0x1"
|
||||
? ["0x2", "0x3"]
|
||||
: procedure === "0x2"
|
||||
? ["0x1"]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
case "procedure_callers":
|
||||
return Promise.resolve(ok(["0x9"]));
|
||||
case "address_name":
|
||||
return Promise.resolve(ok(arguments_.address ?? null));
|
||||
case "xrefs":
|
||||
return Promise.resolve(ok(["0x20", "0x21"]));
|
||||
case "resolve_containing_procedure":
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
query_address:
|
||||
typeof arguments_.address === "string"
|
||||
? arguments_.address
|
||||
: "0x0",
|
||||
found: false,
|
||||
procedure: null,
|
||||
reason: "not_in_procedure",
|
||||
}),
|
||||
);
|
||||
case "list_segments":
|
||||
return Promise.resolve(
|
||||
ok([{ name: "__TEXT", start: "0x1000", end: "0x2000" }]),
|
||||
);
|
||||
case "list_documents":
|
||||
return Promise.resolve(ok(["fixture"]));
|
||||
case "list_strings":
|
||||
return Promise.resolve(ok(page({ "0x30": "hello" })));
|
||||
case "analyze_function":
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
procedure: {
|
||||
address: "0x1",
|
||||
name: "entry",
|
||||
signature: null,
|
||||
locals: [],
|
||||
},
|
||||
pseudocode: {
|
||||
text: "return 0;",
|
||||
total_chars: 9,
|
||||
returned_chars: 9,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
},
|
||||
assembly: {
|
||||
items: [],
|
||||
total: 0,
|
||||
returned: 0,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
},
|
||||
comments: emptyBounded(),
|
||||
callers: emptyBounded(),
|
||||
callees: emptyBounded(),
|
||||
incoming_references: emptyBounded(),
|
||||
outgoing_references: emptyBounded(),
|
||||
referenced_strings: emptyBounded(),
|
||||
referenced_names: emptyBounded(),
|
||||
basic_blocks: emptyBounded(),
|
||||
instruction_scan: { scanned: 0, truncated: false },
|
||||
}),
|
||||
);
|
||||
default:
|
||||
return Promise.resolve(ok(null));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const emptyBounded = () => ({
|
||||
items: [],
|
||||
total: 0,
|
||||
returned: 0,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
});
|
||||
|
||||
const resources: Array<{ close(): Promise<void> }> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
resources.splice(0).map(async (resource) => resource.close()),
|
||||
);
|
||||
});
|
||||
|
||||
const connect = async (analysis: AnalysisOperationPort = fixturePort()) => {
|
||||
const server = createServer(analysis);
|
||||
const client = new Client({ name: "enhanced-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push(client, server);
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
return client;
|
||||
};
|
||||
|
||||
describe("enhanced MCP tools", () => {
|
||||
it("rejects incomplete function dossiers at the application boundary", async () => {
|
||||
const client = await connect({
|
||||
execute: () =>
|
||||
Promise.resolve(
|
||||
ok({
|
||||
procedure: { address: "0x1", name: "entry" },
|
||||
pseudocode: { text: "plausible but incomplete" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
const result = await client.callTool({
|
||||
name: "analyze_function",
|
||||
arguments: { procedure: "0x1" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
const text = result.content.find((item) => item.type === "text");
|
||||
expect(text?.type === "text" ? text.text : "").toBe(
|
||||
JSON.stringify(result.structuredContent),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects deceptive function dossier collection metadata", async () => {
|
||||
const malformedPort = fixturePort();
|
||||
const client = await connect({
|
||||
execute: async (name, arguments_, options) => {
|
||||
const result = await malformedPort.execute(name, arguments_, options);
|
||||
if (!result.ok || name !== "analyze_function") return result;
|
||||
const dossier = result.value.result;
|
||||
if (
|
||||
typeof dossier !== "object" ||
|
||||
dossier === null ||
|
||||
Array.isArray(dossier)
|
||||
)
|
||||
return result;
|
||||
return ok({
|
||||
...dossier,
|
||||
comments: {
|
||||
items: [],
|
||||
total: 0,
|
||||
returned: 1,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const result = await client.callTool({
|
||||
name: "analyze_function",
|
||||
arguments: { procedure: "0x1" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
const text = result.content.find((item) => item.type === "text");
|
||||
expect(text?.type === "text" ? text.text : "").toBe(
|
||||
JSON.stringify(result.structuredContent),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a final dossier page whose total exceeds returned", async () => {
|
||||
const malformedPort = fixturePort();
|
||||
const client = await connect({
|
||||
execute: async (name, arguments_, options) => {
|
||||
const result = await malformedPort.execute(name, arguments_, options);
|
||||
if (!result.ok || name !== "analyze_function") return result;
|
||||
const dossier = result.value.result;
|
||||
if (
|
||||
typeof dossier !== "object" ||
|
||||
dossier === null ||
|
||||
Array.isArray(dossier)
|
||||
)
|
||||
return result;
|
||||
return ok({
|
||||
...dossier,
|
||||
callers: {
|
||||
items: [],
|
||||
total: 1,
|
||||
returned: 0,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const result = await client.callTool({
|
||||
name: "analyze_function",
|
||||
arguments: { procedure: "0x1" },
|
||||
});
|
||||
expect(result.isError).not.toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js";
|
||||
import type { BinarySession } from "../../../src/application/BinarySession.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { observed } from "../../fixtures/analysisExecution.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
|
||||
import { createInvestigationWorkspace } from "../../../src/domain/investigationWorkspace.js";
|
||||
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
|
||||
interface ResourceFixture {
|
||||
readonly evidence: ReturnType<typeof createEvidence>;
|
||||
readonly capture: ReturnType<typeof createEvidence>;
|
||||
readonly workspace: ReturnType<typeof createInvestigationWorkspace>;
|
||||
readonly unknown: ReturnType<BinarySession["recordUnknown"]>;
|
||||
}
|
||||
|
||||
function seedResourceFixture(session: BinarySession): ResourceFixture {
|
||||
const evidence = createEvidence(undefined, provider, {
|
||||
operation: "resource_probe",
|
||||
parameters: {},
|
||||
result: { observed: true },
|
||||
});
|
||||
session.recordEvidence(evidence);
|
||||
const capture = createEvidence(undefined, provider, {
|
||||
operation: "capture_process_scenario",
|
||||
parameters: {},
|
||||
result: {
|
||||
frames: [{ sequence: 0, at_ms: 0, data: "ready" }],
|
||||
protocol_events: [],
|
||||
replay_transitions: [],
|
||||
},
|
||||
});
|
||||
session.recordEvidence(capture);
|
||||
const workspace = createInvestigationWorkspace(
|
||||
"resource test",
|
||||
createEvidenceBundle([]),
|
||||
[],
|
||||
);
|
||||
session.retainInvestigationWorkspace(workspace);
|
||||
const unknown = session.recordUnknown({
|
||||
approved: true,
|
||||
question: "Was every branch observed?",
|
||||
severity: "medium",
|
||||
domain: "control-flow",
|
||||
supporting_evidence_ids: [evidence.evidence_id],
|
||||
contradicting_evidence_ids: [],
|
||||
required_authority: "controlled-replay",
|
||||
required_confidence: "observed",
|
||||
required_environment: null,
|
||||
recommended_probes: [],
|
||||
relationships: [],
|
||||
});
|
||||
expect(unknown.ok).toBe(true);
|
||||
return { evidence, capture, workspace, unknown };
|
||||
}
|
||||
|
||||
async function verifyResourceProtocol(
|
||||
client: Client,
|
||||
fixture: ResourceFixture,
|
||||
): Promise<void> {
|
||||
const { evidence, capture, workspace, unknown } = fixture;
|
||||
const templates = await client.listResourceTemplates();
|
||||
expect(templates.resourceTemplates).toContainEqual(
|
||||
expect.objectContaining({ uriTemplate: "rea://evidence/{evidenceId}" }),
|
||||
);
|
||||
expect(
|
||||
templates.resourceTemplates.map(({ uriTemplate }) => uriTemplate),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
"rea://artifact/{manifestId}/{collection}",
|
||||
"rea://function/{targetSha256}/{address}",
|
||||
"rea://evidence-bundle/{bundleDigest}",
|
||||
"rea://workspace/{workspaceId}/revision/{revision}",
|
||||
]),
|
||||
);
|
||||
expect(templates.resourceTemplates).toContainEqual(
|
||||
expect.objectContaining({
|
||||
uriTemplate: "rea://evidence/{evidenceId}/section/{section}",
|
||||
}),
|
||||
);
|
||||
const listed = await client.listResources();
|
||||
expect(listed.resources).toContainEqual(
|
||||
expect.objectContaining({ uri: "rea://snapshot/current" }),
|
||||
);
|
||||
expect(listed.resources).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
uri: `rea://evidence/${evidence.evidence_id}`,
|
||||
}),
|
||||
);
|
||||
const read = await client.readResource({
|
||||
uri: `rea://evidence/${evidence.evidence_id}`,
|
||||
});
|
||||
expect(read.contents).toEqual([
|
||||
expect.objectContaining({
|
||||
uri: `rea://evidence/${evidence.evidence_id}`,
|
||||
mimeType: "application/json",
|
||||
text: expect.stringContaining(evidence.evidence_id),
|
||||
}),
|
||||
]);
|
||||
const firstSnapshot = await client.callTool({
|
||||
name: "snapshot_evidence_bundle",
|
||||
arguments: {},
|
||||
});
|
||||
const secondSnapshot = await client.callTool({
|
||||
name: "snapshot_evidence_bundle",
|
||||
arguments: {},
|
||||
});
|
||||
expect(secondSnapshot.structuredContent).toEqual(
|
||||
firstSnapshot.structuredContent,
|
||||
);
|
||||
expect(firstSnapshot.content).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "text",
|
||||
text: expect.stringContaining("Copy the opaque URI exactly"),
|
||||
}),
|
||||
);
|
||||
const bundleUri = z
|
||||
.object({ result: z.object({ bundle_uri: z.string() }) })
|
||||
.parse(firstSnapshot.structuredContent).result.bundle_uri;
|
||||
const retainedBundle = await client.readResource({ uri: bundleUri });
|
||||
expect(retainedBundle.contents[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
uri: bundleUri,
|
||||
text: expect.stringContaining(evidence.evidence_id),
|
||||
}),
|
||||
);
|
||||
const terminal = await client.readResource({
|
||||
uri: `rea://evidence/${capture.evidence_id}/section/terminal`,
|
||||
});
|
||||
const workspaceResource = await client.readResource({
|
||||
uri: `rea://workspace/${workspace.workspace_id}/revision/${String(workspace.revision)}`,
|
||||
});
|
||||
expect(workspaceResource.contents[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining(workspace.revision_digest),
|
||||
}),
|
||||
);
|
||||
expect(terminal.contents[0]).toEqual(
|
||||
expect.objectContaining({ text: expect.stringContaining("ready") }),
|
||||
);
|
||||
if (unknown.ok) {
|
||||
const unknownResource = await client.readResource({
|
||||
uri: `rea://unknown/${unknown.value.unknown_id}`,
|
||||
});
|
||||
expect(unknownResource.contents[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining(unknown.value.revision_digest),
|
||||
}),
|
||||
);
|
||||
}
|
||||
await expect(
|
||||
client.readResource({ uri: `rea://evidence/ev_${"0".repeat(64)}` }),
|
||||
).rejects.toThrow(/not found/iu);
|
||||
}
|
||||
|
||||
describe("evidence MCP resources", () => {
|
||||
it("publishes mutable snapshot changes for every Evidence mutation", () => {
|
||||
const session = composeBinarySessionFromFactory(() => ({
|
||||
health: () => Promise.resolve(),
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
let updates = 0;
|
||||
session.onAnalysisSnapshotChanged(() => {
|
||||
updates += 1;
|
||||
});
|
||||
const first = createEvidence(undefined, provider, {
|
||||
operation: "notification_probe",
|
||||
parameters: {},
|
||||
result: 1,
|
||||
});
|
||||
expect(session.recordEvidence(first)).toEqual({ ok: true, value: "added" });
|
||||
expect(session.recordEvidence(first)).toEqual({
|
||||
ok: true,
|
||||
value: "duplicate",
|
||||
});
|
||||
const imported = createEvidence(undefined, provider, {
|
||||
operation: "import_notification_probe",
|
||||
parameters: {},
|
||||
result: 2,
|
||||
});
|
||||
expect(
|
||||
session.importEvidenceBundle(createEvidenceBundle([imported])),
|
||||
).toEqual({ ok: true, value: 1 });
|
||||
expect(
|
||||
session.recordUnknown({
|
||||
approved: true,
|
||||
question: "Does mutation publish a snapshot update?",
|
||||
severity: "low",
|
||||
domain: "notifications",
|
||||
supporting_evidence_ids: [first.evidence_id],
|
||||
contradicting_evidence_ids: [],
|
||||
required_authority: "controlled-replay",
|
||||
required_confidence: "observed",
|
||||
required_environment: null,
|
||||
recommended_probes: [],
|
||||
relationships: [],
|
||||
}).ok,
|
||||
).toBe(true);
|
||||
expect(updates).toBe(3);
|
||||
});
|
||||
|
||||
it("retains bundles without eviction and rejects a seventeenth digest", () => {
|
||||
const session = composeBinarySessionFromFactory(() => ({
|
||||
health: () => Promise.resolve(),
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
const first = session.snapshotEvidenceBundle();
|
||||
expect(first.ok).toBe(true);
|
||||
for (let index = 0; index < 15; index += 1) {
|
||||
session.recordEvidence(
|
||||
createEvidence(undefined, provider, {
|
||||
operation: `retention_probe_${String(index)}`,
|
||||
parameters: {},
|
||||
result: index,
|
||||
}),
|
||||
);
|
||||
expect(session.snapshotEvidenceBundle().ok).toBe(true);
|
||||
}
|
||||
session.recordEvidence(
|
||||
createEvidence(undefined, provider, {
|
||||
operation: "retention_probe_overflow",
|
||||
parameters: {},
|
||||
result: true,
|
||||
}),
|
||||
);
|
||||
expect(session.snapshotEvidenceBundle()).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "EvidenceLimitError", limit: "records", maximum: 16 },
|
||||
});
|
||||
if (first.ok)
|
||||
expect(session.retainedEvidenceBundle(first.value.bundleDigest)).toEqual(
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it("lists and reads only evidence owned by the current session", async () => {
|
||||
const session = composeBinarySessionFromFactory(() => ({
|
||||
health: () => Promise.resolve(),
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
const fixture = seedResourceFixture(session);
|
||||
const server = createServer(session, session);
|
||||
const client = new Client({
|
||||
name: "evidence-resource-test",
|
||||
version: "1",
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
await verifyResourceProtocol(client, fixture);
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
client.close(),
|
||||
server.close(),
|
||||
session.close(),
|
||||
]);
|
||||
}
|
||||
}, 10_000);
|
||||
});
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { BinarySession } from "../src/application/BinarySession.js";
|
||||
import { FUNCTION_COMPARISON_EXAMPLE } from "../src/contracts/functionComparisonExample.js";
|
||||
import { createEvidence } from "../src/domain/evidence.js";
|
||||
import { createServer } from "../src/server/createServer.js";
|
||||
import { observed } from "./fixtures/analysisExecution.js";
|
||||
import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js";
|
||||
import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { observed } from "../../fixtures/analysisExecution.js";
|
||||
|
||||
describe("function comparison MCP integration", () => {
|
||||
it("records linked comparison Evidence and an approved residual unknown", async () => {
|
||||
const session = new BinarySession(() => ({
|
||||
const session = composeBinarySessionFromFactory(() => ({
|
||||
health: () => Promise.resolve(),
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
|
|
@ -75,7 +75,7 @@ describe("function comparison MCP integration", () => {
|
|||
});
|
||||
|
||||
it("rejects dangling Evidence links before recording output", async () => {
|
||||
const session = new BinarySession(() => ({
|
||||
const session = composeBinarySessionFromFactory(() => ({
|
||||
health: () => Promise.resolve(),
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
|
|
@ -106,7 +106,7 @@ describe("function comparison MCP integration", () => {
|
|||
});
|
||||
|
||||
it("rejects Evidence IDs with the wrong operation", async () => {
|
||||
const session = new BinarySession(() => ({
|
||||
const session = composeBinarySessionFromFactory(() => ({
|
||||
health: () => Promise.resolve(),
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { EnhancedTools } from "../../../src/application/EnhancedTools.js";
|
||||
import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js";
|
||||
|
||||
describe("Ghidra MCP evidence parity", () => {
|
||||
it("preserves provider evidence, composed parity, and capability routing", async () => {
|
||||
const harness = await connectGhidraMcp("ghidra-parity");
|
||||
const { mcp, session } = harness;
|
||||
try {
|
||||
const listed = sessionEvidence(
|
||||
session,
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "list_procedures",
|
||||
arguments: {},
|
||||
})
|
||||
).structuredContent,
|
||||
);
|
||||
expect(listed).toMatchObject({
|
||||
operation: "list_procedures",
|
||||
provider: { id: "ghidra", name: "Ghidra", version: "12.1.2" },
|
||||
analysis_profile: {
|
||||
provider: { id: "ghidra", version: "12.1.2" },
|
||||
parameters: {
|
||||
import_mode: "ephemeral-read-only",
|
||||
analyzer_preset: "ghidra-default",
|
||||
},
|
||||
},
|
||||
normalized_result: {
|
||||
items: [
|
||||
{
|
||||
address: "0x401000",
|
||||
value: "fixture_main",
|
||||
procedure: {
|
||||
external: false,
|
||||
thunk: false,
|
||||
thunk_target: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
raw_result: {
|
||||
items: [{ value_truncated: false }],
|
||||
},
|
||||
});
|
||||
|
||||
const mcpOverview = sessionEvidence(
|
||||
session,
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "binary_overview",
|
||||
arguments: { detail: "detailed", limit: 5 },
|
||||
})
|
||||
).structuredContent,
|
||||
);
|
||||
const directOverview = await new EnhancedTools(session).execute(
|
||||
"binary_overview",
|
||||
{ detail: "detailed", limit: 5 },
|
||||
);
|
||||
expect(directOverview.ok).toBe(true);
|
||||
if (!directOverview.ok) return;
|
||||
expect(mcpOverview.normalized_result).toEqual(directOverview.value);
|
||||
expect(mcpOverview).toMatchObject({
|
||||
provider: { id: "rea-workflow" },
|
||||
confidence: "derived",
|
||||
normalized_result: {
|
||||
document: "fixture",
|
||||
segment_count: 1,
|
||||
procedure_count: 1,
|
||||
string_count: 2,
|
||||
segments: [{ name: ".text", length: 256 }],
|
||||
},
|
||||
});
|
||||
|
||||
const pseudocode = sessionEvidence(
|
||||
session,
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "procedure_pseudo_code",
|
||||
arguments: { procedure: "fixture_main" },
|
||||
})
|
||||
).structuredContent,
|
||||
);
|
||||
expect(pseudocode).toMatchObject({
|
||||
operation: "procedure_pseudo_code",
|
||||
provider: { id: "ghidra", version: "12.1.2" },
|
||||
normalized_result: expect.stringContaining("return 42"),
|
||||
limitations: expect.arrayContaining([
|
||||
expect.stringContaining("not text-equivalent to Hopper"),
|
||||
expect.stringContaining("30-second native deadline"),
|
||||
]),
|
||||
});
|
||||
|
||||
const analyzed = sessionEvidence(
|
||||
session,
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "analyze_function",
|
||||
arguments: { procedure: "fixture_main", include_assembly: true },
|
||||
})
|
||||
).structuredContent,
|
||||
);
|
||||
expect(analyzed).toMatchObject({
|
||||
operation: "analyze_function",
|
||||
provider: { id: "rea-workflow", version: "1" },
|
||||
normalized_result: {
|
||||
procedure: {
|
||||
address: "0x401000",
|
||||
name: "fixture_main",
|
||||
classification: {
|
||||
external: false,
|
||||
thunk: false,
|
||||
provenance: "ghidra-function-manager",
|
||||
},
|
||||
},
|
||||
pseudocode: { truncated: false },
|
||||
outgoing_references: {
|
||||
items: [
|
||||
{
|
||||
kind: {
|
||||
available: true,
|
||||
provenance: "ghidra-reference-manager",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
limitations: expect.arrayContaining([
|
||||
expect.stringContaining("indirect flows without target addresses"),
|
||||
expect.stringContaining(
|
||||
"not original source or Hopper-equivalent text",
|
||||
),
|
||||
]),
|
||||
},
|
||||
limitations: [
|
||||
"Derived by an REA workflow from one or more provider observations.",
|
||||
],
|
||||
});
|
||||
const directAnalyzed = await new EnhancedTools(session).execute(
|
||||
"analyze_function",
|
||||
{ procedure: "fixture_main", include_assembly: true },
|
||||
);
|
||||
expect(directAnalyzed.ok).toBe(true);
|
||||
if (!directAnalyzed.ok) return;
|
||||
expect(analyzed.normalized_result).toEqual(directAnalyzed.value);
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { composeBinarySession } from "../../../src/application/BinarySessionComposition.js";
|
||||
import type { BinarySession } from "../../../src/application/BinarySession.js";
|
||||
import { AnalysisProviderRegistry } from "../../../src/application/AnalysisProviderRegistry.js";
|
||||
import { SessionProviderRouter } from "../../../src/application/SessionProviderRouter.js";
|
||||
import { parseConfig } from "../../../src/config.js";
|
||||
import type { JsonValue } from "../../../src/domain/jsonValue.js";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import type { GhidraInstallationHost } from "../../../src/ghidra/GhidraInstallation.js";
|
||||
import type { GhidraOperation } from "../../../src/ghidra/GhidraClient.js";
|
||||
import {
|
||||
GhidraProvider,
|
||||
type GhidraProviderClientFactory,
|
||||
} from "../../../src/ghidra/GhidraProvider.js";
|
||||
import { GHIDRA_SESSION_CAPABILITIES } from "../../../src/ghidra/GhidraSessionValues.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import {
|
||||
ghidraBounded,
|
||||
ghidraFunctionClassification,
|
||||
ghidraFunctionDossier,
|
||||
ghidraFunctionIdentity,
|
||||
ghidraReferenceEdge,
|
||||
} from "../../fixtures/ghidraFunction.js";
|
||||
|
||||
const INSTALL = "/opt/ghidra_12.1.2_PUBLIC";
|
||||
|
||||
export const connectGhidraMcp = async (name: string) => {
|
||||
const calls: GhidraOperation[] = [];
|
||||
const factory: GhidraProviderClientFactory = (options) => ({
|
||||
start: () =>
|
||||
Promise.resolve(
|
||||
ok(sessionInfo(options.profileDigest, options.targetSha256)),
|
||||
),
|
||||
callTool: (operation, input) => {
|
||||
calls.push(operation);
|
||||
return Promise.resolve(ok(resultFor(operation, input)));
|
||||
},
|
||||
close: () => Promise.resolve(),
|
||||
});
|
||||
const config = parseConfig({ GHIDRA_INSTALL_DIR: INSTALL });
|
||||
if (!config.ok) throw config.error;
|
||||
const provider = new GhidraProvider(
|
||||
config.value,
|
||||
silentLogger,
|
||||
installationHost(),
|
||||
factory,
|
||||
);
|
||||
const session = composeBinarySession(
|
||||
SessionProviderRouter.selectable(
|
||||
new AnalysisProviderRegistry([provider]),
|
||||
[],
|
||||
),
|
||||
);
|
||||
const server = createServer(session, session, { logger: silentLogger });
|
||||
const mcp = new Client({ name, version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
const close = async () => {
|
||||
await Promise.allSettled([mcp.close(), server.close()]);
|
||||
await session.close();
|
||||
};
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await mcp.connect(clientTransport);
|
||||
const opened = await mcp.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: process.execPath },
|
||||
});
|
||||
if (opened.isError === true)
|
||||
throw new Error("The Ghidra MCP harness could not open its target");
|
||||
} catch (cause: unknown) {
|
||||
await close();
|
||||
throw cause;
|
||||
}
|
||||
return {
|
||||
calls,
|
||||
mcp,
|
||||
session,
|
||||
close,
|
||||
};
|
||||
};
|
||||
|
||||
export const sessionEvidence = (session: BinarySession, value: unknown) => {
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
!("evidence_id" in value) ||
|
||||
typeof value.evidence_id !== "string"
|
||||
)
|
||||
throw new TypeError(
|
||||
`Missing compact Evidence ID: ${JSON.stringify(value)}`,
|
||||
);
|
||||
const evidence = session.evidenceById(value.evidence_id);
|
||||
if (evidence === undefined) throw new TypeError("Missing session Evidence");
|
||||
return evidence;
|
||||
};
|
||||
|
||||
const installationHost = (): GhidraInstallationHost => ({
|
||||
platform: "linux",
|
||||
architecture: "x64",
|
||||
readText: () => "application.version=12.1.2\n",
|
||||
executable: () => true,
|
||||
probeJava: () => ({
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/usr/lib/jvm/jdk-21",
|
||||
bits: 64,
|
||||
jdk: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const sessionInfo = (profileDigest: string, targetSha256: string) => ({
|
||||
name: "REA Ghidra bridge" as const,
|
||||
bridge_version: 6 as const,
|
||||
run_id: "11111111-1111-4111-8111-111111111111",
|
||||
profile_digest: profileDigest,
|
||||
provider: { id: "ghidra" as const, version: "12.1.2" },
|
||||
read_only: true as const,
|
||||
analysis_complete: true,
|
||||
analysis_timed_out: false,
|
||||
capabilities: [...GHIDRA_SESSION_CAPABILITIES],
|
||||
target: {
|
||||
name: "fixture",
|
||||
language_id: "x86:LE:64:default",
|
||||
compiler_spec_id: "gcc",
|
||||
image_base: "0x400000",
|
||||
default_address_space: "ram",
|
||||
sha256: targetSha256,
|
||||
},
|
||||
});
|
||||
|
||||
type GhidraResultBuilder = (
|
||||
input: Readonly<Record<string, JsonValue>>,
|
||||
limit: number,
|
||||
) => JsonValue;
|
||||
|
||||
const resultBuilders = new Map<GhidraOperation, GhidraResultBuilder>([
|
||||
["list_documents", () => ["fixture"]],
|
||||
[
|
||||
"list_procedures",
|
||||
(_input, limit) =>
|
||||
page(
|
||||
[
|
||||
{
|
||||
address: "0x401000",
|
||||
value: "fixture_main",
|
||||
value_truncated: false,
|
||||
procedure: {
|
||||
external: false,
|
||||
thunk: false,
|
||||
thunk_target: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
limit,
|
||||
),
|
||||
],
|
||||
[
|
||||
"list_strings",
|
||||
(_input, limit) =>
|
||||
page(
|
||||
[
|
||||
stringItem("0x402000", "inventory fixture"),
|
||||
stringItem("0x402020", "external fixture"),
|
||||
],
|
||||
limit,
|
||||
),
|
||||
],
|
||||
[
|
||||
"list_segments",
|
||||
() => [
|
||||
{
|
||||
name: ".text",
|
||||
start: "0x401000",
|
||||
end: "0x401100",
|
||||
readable: true,
|
||||
writable: false,
|
||||
executable: true,
|
||||
permissions: { available: true, source: "ghidra-memory-block" },
|
||||
provenance: "ghidra-memory-block",
|
||||
address_space: "ram",
|
||||
image_base: "0x400000",
|
||||
initialized: true,
|
||||
overlay: false,
|
||||
sections: [],
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
"list_names",
|
||||
(_input, limit) =>
|
||||
page(
|
||||
[
|
||||
{
|
||||
address: "0x401000",
|
||||
value: "fixture_main",
|
||||
value_truncated: false,
|
||||
symbol: {
|
||||
primary: true,
|
||||
dynamic: false,
|
||||
external: false,
|
||||
type: "function",
|
||||
source: "user_defined",
|
||||
},
|
||||
},
|
||||
],
|
||||
limit,
|
||||
),
|
||||
],
|
||||
["search_procedures", (_input, limit) => page([], limit)],
|
||||
["search_strings", (_input, limit) => page([], limit)],
|
||||
["address_name", () => "fixture_main"],
|
||||
["procedure_address", () => "0x401000"],
|
||||
[
|
||||
"resolve_containing_procedure",
|
||||
() => ({
|
||||
query_address: "0x401001",
|
||||
found: true,
|
||||
procedure: ghidraFunctionIdentity(),
|
||||
}),
|
||||
],
|
||||
["procedure_assembly", () => "0x401000: CALL 0x401020\n0x401005: RET"],
|
||||
["procedure_callees", () => []],
|
||||
["procedure_callers", () => []],
|
||||
[
|
||||
"procedure_info",
|
||||
() => ({
|
||||
name: "fixture_main",
|
||||
entrypoint: "0x401000",
|
||||
basicblock_count: 1,
|
||||
length: 6,
|
||||
signature: "int fixture_main(void)",
|
||||
locals: [],
|
||||
classification: ghidraFunctionClassification(),
|
||||
}),
|
||||
],
|
||||
["procedure_pseudo_code", () => "int fixture_main(void) { return 42; }"],
|
||||
[
|
||||
"read_function_instructions",
|
||||
() => ({
|
||||
procedure: ghidraFunctionIdentity(),
|
||||
instructions: ghidraBounded(["0x401000: CALL 0x401020", "0x401005: RET"]),
|
||||
instructions_scanned: 2,
|
||||
instruction_scan_truncated: false,
|
||||
limitations: ["Ghidra-specific instruction text."],
|
||||
}),
|
||||
],
|
||||
[
|
||||
"procedure_references",
|
||||
(input, _limit) => ({
|
||||
procedure: ghidraFunctionIdentity(),
|
||||
direction: input.direction ?? "outgoing",
|
||||
references: ghidraBounded([ghidraReferenceEdge()]),
|
||||
instructions_scanned: 2,
|
||||
instruction_scan_truncated: false,
|
||||
}),
|
||||
],
|
||||
["xrefs", () => ["0x401001"]],
|
||||
[
|
||||
"analyze_function",
|
||||
(input, _limit) =>
|
||||
ghidraFunctionDossier(
|
||||
input.include_assembly === true,
|
||||
input.procedure === "fixture_truncated",
|
||||
),
|
||||
],
|
||||
]);
|
||||
|
||||
const resultFor = (
|
||||
operation: GhidraOperation,
|
||||
input: Readonly<Record<string, JsonValue>>,
|
||||
): JsonValue => {
|
||||
const limit = typeof input.limit === "number" ? input.limit : 100;
|
||||
const builder = resultBuilders.get(operation);
|
||||
if (builder === undefined)
|
||||
throw new TypeError(`Unexpected Ghidra operation: ${operation}`);
|
||||
return builder(input, limit);
|
||||
};
|
||||
|
||||
const page = (items: readonly JsonValue[], limit: number): JsonValue => ({
|
||||
items: items.slice(0, limit),
|
||||
offset: 0,
|
||||
limit,
|
||||
total: items.length,
|
||||
next_offset: items.length > limit ? limit : null,
|
||||
has_more: items.length > limit,
|
||||
});
|
||||
|
||||
const stringItem = (address: string, value: string): JsonValue => ({
|
||||
address,
|
||||
value,
|
||||
value_truncated: false,
|
||||
string: {
|
||||
encoding: "UTF-8",
|
||||
termination: "present_or_not_required",
|
||||
byte_length: value.length + 1,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js";
|
||||
|
||||
describe("Ghidra MCP native API evidence", () => {
|
||||
it("records approved native API truncation once and links its evidence", async () => {
|
||||
const harness = await connectGhidraMcp("ghidra-native-api");
|
||||
const { mcp, session } = harness;
|
||||
try {
|
||||
const unapprovedInspection = sessionEvidence(
|
||||
session,
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "inspect_native_api",
|
||||
arguments: { procedure: "fixture_truncated" },
|
||||
})
|
||||
).structuredContent,
|
||||
);
|
||||
expect(unapprovedInspection.normalized_result).toMatchObject({
|
||||
boundary: {
|
||||
available: true,
|
||||
jump_tables: [{ mappings_truncated: true }],
|
||||
},
|
||||
residual_unknowns: [
|
||||
expect.stringContaining("additional data sources or targets"),
|
||||
],
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "list_unknowns",
|
||||
arguments: {},
|
||||
})
|
||||
).structuredContent,
|
||||
).toMatchObject({ result: { items: [] } });
|
||||
|
||||
const approvedInspection = sessionEvidence(
|
||||
session,
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "inspect_native_api",
|
||||
arguments: {
|
||||
procedure: "fixture_truncated",
|
||||
unknown_registry_approved: true,
|
||||
},
|
||||
})
|
||||
).structuredContent,
|
||||
);
|
||||
await mcp.callTool({
|
||||
name: "inspect_native_api",
|
||||
arguments: {
|
||||
procedure: "fixture_truncated",
|
||||
unknown_registry_approved: true,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "list_unknowns",
|
||||
arguments: {},
|
||||
})
|
||||
).structuredContent,
|
||||
).toMatchObject({
|
||||
result: {
|
||||
items: [
|
||||
{
|
||||
unknown: {
|
||||
status: "open",
|
||||
domain: "native-api",
|
||||
question: expect.stringContaining(
|
||||
"additional data sources or targets",
|
||||
),
|
||||
supporting_evidence_ids: [approvedInspection.evidence_id],
|
||||
recommended_probes: [
|
||||
{
|
||||
operation: "inspect_native_api",
|
||||
rationale: expect.stringContaining("ABI probe"),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { connectGhidraMcp } from "./ghidraMcpHarness.js";
|
||||
|
||||
describe("Ghidra MCP capability routing", () => {
|
||||
it("routes composed and capability tools", async () => {
|
||||
const harness = await connectGhidraMcp("ghidra-routing");
|
||||
const { calls, mcp } = harness;
|
||||
try {
|
||||
const batch = await mcp.callTool({
|
||||
name: "batch_decompile",
|
||||
arguments: { addresses: ["fixture_main"] },
|
||||
});
|
||||
expect(batch.isError).not.toBe(true);
|
||||
expect(batch.structuredContent).toMatchObject({
|
||||
result: {
|
||||
succeeded: 1,
|
||||
failed: 0,
|
||||
items: [{ status: "ok" }],
|
||||
},
|
||||
});
|
||||
|
||||
const availableNames = (await mcp.listTools()).tools.map(
|
||||
({ name }) => name,
|
||||
);
|
||||
expect(availableNames).not.toContain("set_comment");
|
||||
expect(calls).not.toContain("set_comment");
|
||||
|
||||
const status = await mcp.callTool({
|
||||
name: "binary_session",
|
||||
arguments: { detail: "full" },
|
||||
});
|
||||
expect(status.structuredContent).toMatchObject({
|
||||
result: {
|
||||
open: true,
|
||||
analysis_provider_binding: {
|
||||
provider: { id: "ghidra", version: "12.1.2" },
|
||||
},
|
||||
tool_availability: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "binary_overview",
|
||||
available: true,
|
||||
}),
|
||||
expect.objectContaining({ name: "swift_classes", available: true }),
|
||||
expect.objectContaining({
|
||||
name: "get_objc_classes",
|
||||
available: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "batch_decompile",
|
||||
available: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "trace_feature",
|
||||
available: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "get_call_graph",
|
||||
available: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "find_xrefs_to_name",
|
||||
available: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "set_comment",
|
||||
available: false,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
|
@ -0,0 +1,348 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js";
|
||||
import type { BinarySession } from "../../../src/application/BinarySession.js";
|
||||
import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js";
|
||||
import { INVESTIGATION_EXAMPLES } from "../../../src/contracts/investigationExamples.js";
|
||||
import {
|
||||
FUNCTION_COMPARISON_EVIDENCE,
|
||||
PROCESS_CAPTURE_RECONSTRUCTION,
|
||||
PROCESS_CAPTURE_REFERENCE,
|
||||
PROCESS_COMPARISON_EVIDENCE,
|
||||
} from "../../../src/contracts/investigationExamples.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import {
|
||||
jsonObjectSchema,
|
||||
jsonValueSchema,
|
||||
} from "../../../src/domain/jsonValue.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { observed } from "../../fixtures/analysisExecution.js";
|
||||
import type { EvidenceFilePolicy } from "../../../src/domain/evidenceBundle.js";
|
||||
import type { PermissionAuthority } from "../../../src/application/PermissionAuthority.js";
|
||||
|
||||
it("aggregates comparison Evidence and records an approved runtime gap", async () => {
|
||||
const { session, server, client } = await connected();
|
||||
const comparison =
|
||||
INVESTIGATION_EXAMPLES.find_changed_behavior.comparisons[0];
|
||||
if (comparison === undefined) throw new Error("missing comparison fixture");
|
||||
for (const evidence of [
|
||||
FUNCTION_COMPARISON_EXAMPLE.left,
|
||||
FUNCTION_COMPARISON_EXAMPLE.right,
|
||||
comparison,
|
||||
])
|
||||
expect(session.recordEvidence(evidence).ok).toBe(true);
|
||||
try {
|
||||
const response = await client.callTool({
|
||||
name: "find_changed_behavior",
|
||||
arguments: {
|
||||
comparisons: [comparison],
|
||||
unknown_registry_approved: true,
|
||||
},
|
||||
});
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
const evidence = sessionEvidence(session, response.structuredContent);
|
||||
expect(evidence).toMatchObject({
|
||||
provider: { id: "rea-changed-behavior" },
|
||||
normalized_result: { behavior_status: "unknown" },
|
||||
});
|
||||
expect(session.listUnknowns({ domain: "changed-behavior" })).toMatchObject([
|
||||
{
|
||||
question:
|
||||
"Did both versions behave the same under a complete controlled replay?",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await close(session, server, client);
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
it("builds a zero-hop exact-address path with dossier citations", async () => {
|
||||
const { session, server, client } = await connected();
|
||||
expect(session.recordEvidence(FUNCTION_COMPARISON_EXAMPLE.left).ok).toBe(
|
||||
true,
|
||||
);
|
||||
try {
|
||||
const response = await client.callTool({
|
||||
name: "build_call_path",
|
||||
arguments: INVESTIGATION_EXAMPLES.build_call_path,
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
const evidence = sessionEvidence(session, response.structuredContent);
|
||||
expect(evidence).toMatchObject({
|
||||
provider: { id: "rea-call-path" },
|
||||
normalized_result: {
|
||||
status: "found",
|
||||
shortest_hops: 0,
|
||||
paths: { total: 1 },
|
||||
},
|
||||
evidence_links: [FUNCTION_COMPARISON_EXAMPLE.left.evidence_id],
|
||||
});
|
||||
} finally {
|
||||
await close(session, server, client);
|
||||
}
|
||||
});
|
||||
|
||||
it("records a safe unresolved call-path question", async () => {
|
||||
const { session, server, client } = await connected();
|
||||
const base = FUNCTION_COMPARISON_EXAMPLE.left;
|
||||
if (base.subject === null) throw new Error("missing dossier subject");
|
||||
const dossier = createEvidence(
|
||||
{
|
||||
path: base.subject.local_path,
|
||||
sha256: base.subject.digest.sha256,
|
||||
format: base.subject.format,
|
||||
...(base.subject.architecture === null
|
||||
? {}
|
||||
: { architecture: base.subject.architecture }),
|
||||
},
|
||||
base.provider,
|
||||
{
|
||||
predicateType: base.predicate_type,
|
||||
operation: base.operation,
|
||||
parameters: base.parameters,
|
||||
result: jsonValueSchema.parse({
|
||||
...jsonObjectSchema.parse(base.normalized_result),
|
||||
callees: {
|
||||
items: [{ address: "0x2000", name: "next" }],
|
||||
total: 1,
|
||||
returned: 1,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
},
|
||||
}),
|
||||
rawResult: base.raw_result,
|
||||
confidence: base.confidence,
|
||||
authority: base.authority,
|
||||
limitations: base.limitations,
|
||||
locations: base.locations,
|
||||
evidenceLinks: base.evidence_links,
|
||||
},
|
||||
);
|
||||
expect(session.recordEvidence(dossier).ok).toBe(true);
|
||||
try {
|
||||
const response = await client.callTool({
|
||||
name: "build_call_path",
|
||||
arguments: {
|
||||
...INVESTIGATION_EXAMPLES.build_call_path,
|
||||
functions: [dossier],
|
||||
start: { address: "0x1000" },
|
||||
goal: { address: "0x3000" },
|
||||
unknown_registry_approved: true,
|
||||
},
|
||||
});
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
expect(session.listUnknowns({ domain: "call-path" })).toMatchObject([
|
||||
{
|
||||
question:
|
||||
"Can the requested call path be established from complete analysis?",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await close(session, server, client);
|
||||
}
|
||||
});
|
||||
|
||||
it("records an explicit non-causal static/runtime hypothesis", async () => {
|
||||
const { session, server, client } = await connected();
|
||||
for (const evidence of [
|
||||
FUNCTION_COMPARISON_EXAMPLE.left,
|
||||
FUNCTION_COMPARISON_EXAMPLE.right,
|
||||
FUNCTION_COMPARISON_EVIDENCE,
|
||||
PROCESS_CAPTURE_REFERENCE,
|
||||
PROCESS_CAPTURE_RECONSTRUCTION,
|
||||
PROCESS_COMPARISON_EVIDENCE,
|
||||
])
|
||||
expect(session.recordEvidence(evidence).ok).toBe(true);
|
||||
try {
|
||||
const response = await client.callTool({
|
||||
name: "correlate_static_and_runtime",
|
||||
arguments: INVESTIGATION_EXAMPLES.correlate_static_and_runtime,
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
const evidence = sessionEvidence(session, response.structuredContent);
|
||||
expect(evidence).toMatchObject({
|
||||
provider: { id: "rea-static-runtime-correlation" },
|
||||
confidence: "inferred",
|
||||
normalized_result: {
|
||||
status: "correlated",
|
||||
summary: { hypotheses: 1 },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await close(session, server, client);
|
||||
}
|
||||
});
|
||||
|
||||
it("records a safe unresolved static/runtime question", async () => {
|
||||
const { session, server, client } = await connected();
|
||||
for (const evidence of [
|
||||
FUNCTION_COMPARISON_EXAMPLE.left,
|
||||
FUNCTION_COMPARISON_EXAMPLE.right,
|
||||
FUNCTION_COMPARISON_EVIDENCE,
|
||||
PROCESS_CAPTURE_REFERENCE,
|
||||
PROCESS_CAPTURE_RECONSTRUCTION,
|
||||
PROCESS_COMPARISON_EVIDENCE,
|
||||
])
|
||||
expect(session.recordEvidence(evidence).ok).toBe(true);
|
||||
const mapping =
|
||||
INVESTIGATION_EXAMPLES.correlate_static_and_runtime.mappings[0];
|
||||
if (mapping === undefined) throw new Error("missing correlation fixture");
|
||||
try {
|
||||
const response = await client.callTool({
|
||||
name: "correlate_static_and_runtime",
|
||||
arguments: {
|
||||
...INVESTIGATION_EXAMPLES.correlate_static_and_runtime,
|
||||
mappings: [
|
||||
{
|
||||
...mapping,
|
||||
static: {
|
||||
...mapping.static,
|
||||
selector: { kind: "function", dimension: "assembly" },
|
||||
},
|
||||
},
|
||||
],
|
||||
unknown_registry_approved: true,
|
||||
},
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
expect(
|
||||
session.listUnknowns({ domain: "static-runtime-correlation" }),
|
||||
).toMatchObject([
|
||||
{
|
||||
question: "Does runtime behavior match the available static analysis?",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await close(session, server, client);
|
||||
}
|
||||
});
|
||||
|
||||
it("passes only the finite declared reconstruction specification", async () => {
|
||||
const { session, server, client } = await connected();
|
||||
for (const evidence of [
|
||||
PROCESS_CAPTURE_REFERENCE,
|
||||
PROCESS_CAPTURE_RECONSTRUCTION,
|
||||
PROCESS_COMPARISON_EVIDENCE,
|
||||
])
|
||||
expect(session.recordEvidence(evidence).ok).toBe(true);
|
||||
try {
|
||||
const response = await client.callTool({
|
||||
name: "verify_reconstruction",
|
||||
arguments: {
|
||||
...INVESTIGATION_EXAMPLES.verify_reconstruction,
|
||||
unknown_registry_approved: true,
|
||||
},
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
const evidence = sessionEvidence(session, response.structuredContent);
|
||||
expect(evidence).toMatchObject({
|
||||
provider: { id: "rea-reconstruction-verifier" },
|
||||
normalized_result: { status: "pass", summary: { passed: 1 } },
|
||||
});
|
||||
expect(evidence.limitations).toContain(
|
||||
"Pass means every declared claim passed; it does not establish global implementation equivalence.",
|
||||
);
|
||||
} finally {
|
||||
await close(session, server, client);
|
||||
}
|
||||
});
|
||||
|
||||
it("cannot omit a session-owned active unknown from reconstruction input", async () => {
|
||||
const { session, server, client } = await connected();
|
||||
for (const evidence of [
|
||||
PROCESS_CAPTURE_REFERENCE,
|
||||
PROCESS_CAPTURE_RECONSTRUCTION,
|
||||
PROCESS_COMPARISON_EVIDENCE,
|
||||
])
|
||||
expect(session.recordEvidence(evidence).ok).toBe(true);
|
||||
expect(
|
||||
session.recordUnknown({
|
||||
approved: true,
|
||||
question: "Was terminal equivalence reproduced independently?",
|
||||
severity: "high",
|
||||
domain: "reconstruction-verification",
|
||||
supporting_evidence_ids: [PROCESS_COMPARISON_EVIDENCE.evidence_id],
|
||||
contradicting_evidence_ids: [],
|
||||
required_authority: "controlled-replay",
|
||||
required_confidence: "observed",
|
||||
required_environment: null,
|
||||
recommended_probes: [
|
||||
{
|
||||
operation: "capture_process_scenario",
|
||||
rationale: "Repeat both sides under one controlled environment.",
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
}).ok,
|
||||
).toBe(true);
|
||||
try {
|
||||
const response = await client.callTool({
|
||||
name: "verify_reconstruction",
|
||||
arguments: {
|
||||
...INVESTIGATION_EXAMPLES.verify_reconstruction,
|
||||
unknown_registry_approved: true,
|
||||
},
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
const evidence = sessionEvidence(session, response.structuredContent);
|
||||
expect(evidence.normalized_result).toMatchObject({
|
||||
status: "unknown",
|
||||
summary: { unknown: 1 },
|
||||
});
|
||||
expect(
|
||||
session.listUnknowns({ domain: "reconstruction-verification" }),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
question: "Does the reconstruction satisfy every declared claim?",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await close(session, server, client);
|
||||
}
|
||||
});
|
||||
|
||||
const connected = async (
|
||||
evidenceFilePolicy?: EvidenceFilePolicy,
|
||||
investigationInputRoots: readonly string[] = evidenceFilePolicy?.roots ?? [],
|
||||
permissionAuthority?: PermissionAuthority,
|
||||
) => {
|
||||
const session = composeBinarySessionFromFactory(() => ({
|
||||
health: () => Promise.resolve(),
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
const server = createServer(session, session, {
|
||||
...(evidenceFilePolicy === undefined
|
||||
? {}
|
||||
: {
|
||||
evidenceFilePolicy,
|
||||
investigationInputRoots,
|
||||
}),
|
||||
...(permissionAuthority === undefined ? {} : { permissionAuthority }),
|
||||
});
|
||||
const client = new Client({ name: "investigation-test", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
return { session, server, client };
|
||||
};
|
||||
|
||||
const sessionEvidence = (session: BinarySession, value: unknown) => {
|
||||
const parsed = z
|
||||
.object({ evidence_id: z.string().regex(/^ev_[a-f0-9]{64}$/u) })
|
||||
.parse(value);
|
||||
const evidence = session.evidenceById(parsed.evidence_id);
|
||||
if (evidence === undefined) throw new TypeError("Missing session Evidence");
|
||||
return evidence;
|
||||
};
|
||||
|
||||
const close = async (
|
||||
session: BinarySession,
|
||||
server: Awaited<ReturnType<typeof createServer>>,
|
||||
client: Client,
|
||||
) => {
|
||||
await Promise.allSettled([client.close(), server.close(), session.close()]);
|
||||
};
|
||||
|
|
@ -0,0 +1,395 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js";
|
||||
import type { BinarySession } from "../../../src/application/BinarySession.js";
|
||||
import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js";
|
||||
import { INVESTIGATION_EXAMPLES } from "../../../src/contracts/investigationExamples.js";
|
||||
import {
|
||||
FUNCTION_COMPARISON_EVIDENCE,
|
||||
PROCESS_CAPTURE_RECONSTRUCTION,
|
||||
PROCESS_CAPTURE_REFERENCE,
|
||||
PROCESS_COMPARISON_EVIDENCE,
|
||||
} from "../../../src/contracts/investigationExamples.js";
|
||||
import { changedBehaviorResultSchema } from "../../../src/domain/changedBehavior.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { observed } from "../../fixtures/analysisExecution.js";
|
||||
import { readInvestigationWorkspace } from "../../../src/application/InvestigationWorkspaceStore.js";
|
||||
import type { EvidenceFilePolicy } from "../../../src/domain/evidenceBundle.js";
|
||||
import type { PermissionAuthority } from "../../../src/application/PermissionAuthority.js";
|
||||
import { permissionAuthorityForRoot } from "../../fixtures/permissionAuthority.js";
|
||||
|
||||
it("never retains derived evidence after request cancellation", async () => {
|
||||
const { session, server, client } = await connected();
|
||||
const inputs = [
|
||||
{
|
||||
name: "find_changed_behavior",
|
||||
arguments: INVESTIGATION_EXAMPLES.find_changed_behavior,
|
||||
},
|
||||
{
|
||||
name: "build_call_path",
|
||||
arguments: INVESTIGATION_EXAMPLES.build_call_path,
|
||||
},
|
||||
{
|
||||
name: "correlate_static_and_runtime",
|
||||
arguments: INVESTIGATION_EXAMPLES.correlate_static_and_runtime,
|
||||
},
|
||||
] as const;
|
||||
for (const evidence of [
|
||||
FUNCTION_COMPARISON_EXAMPLE.left,
|
||||
FUNCTION_COMPARISON_EXAMPLE.right,
|
||||
FUNCTION_COMPARISON_EVIDENCE,
|
||||
PROCESS_CAPTURE_REFERENCE,
|
||||
PROCESS_CAPTURE_RECONSTRUCTION,
|
||||
PROCESS_COMPARISON_EVIDENCE,
|
||||
])
|
||||
expect(session.recordEvidence(evidence).ok).toBe(true);
|
||||
const initialCount = session.exportEvidenceBundle().records.length;
|
||||
try {
|
||||
for (const input of inputs) {
|
||||
const controller = new AbortController();
|
||||
const request = client.callTool(input, {
|
||||
signal: controller.signal,
|
||||
onprogress: () => controller.abort(),
|
||||
});
|
||||
await expect(request).rejects.toThrow(/abort/iu);
|
||||
}
|
||||
await expect
|
||||
.poll(() => session.exportEvidenceBundle().records.length)
|
||||
.toBe(initialCount);
|
||||
} finally {
|
||||
await close(session, server, client);
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
it("runs and reuses a persistent cross-version workspace", async () => {
|
||||
const directory = await createTestTempDirectory("rea-investigation-mcp-");
|
||||
const left = join(directory, "left");
|
||||
const right = join(directory, "right");
|
||||
const workspace = join(directory, "workspace.json");
|
||||
await Promise.all([mkdir(left), mkdir(right)]);
|
||||
await Promise.all([
|
||||
writeFile(join(left, "feature.txt"), "old\n"),
|
||||
writeFile(join(right, "feature.txt"), "new\n"),
|
||||
]);
|
||||
const filePolicy = evidencePolicy(directory);
|
||||
const { session, server, client } = await connected(filePolicy);
|
||||
try {
|
||||
const arguments_ = {
|
||||
investigation_run: {
|
||||
approved: true,
|
||||
workspace_path: workspace,
|
||||
workspace_name: "mcp-release-diff",
|
||||
left_path: left,
|
||||
right_path: right,
|
||||
options: { page_size: 500, change_limit: 100 },
|
||||
},
|
||||
};
|
||||
const first = await client.callTool({
|
||||
name: "find_changed_behavior",
|
||||
arguments: arguments_,
|
||||
});
|
||||
expect(first.isError).not.toBe(true);
|
||||
const firstEvidence = sessionEvidence(session, first.structuredContent);
|
||||
expect(firstEvidence.normalized_result).toMatchObject({
|
||||
behavior_status: "unknown",
|
||||
summary: { static_candidates: 2 },
|
||||
investigation_run: { inventory_evidence_count: 2 },
|
||||
});
|
||||
expect(session.hasEvidence(firstEvidence.evidence_id)).toBe(true);
|
||||
const loaded = await readInvestigationWorkspace(workspace, filePolicy);
|
||||
expect(loaded).toMatchObject({
|
||||
ok: true,
|
||||
value: { revision: 3, runs: [{ status: "complete" }] },
|
||||
});
|
||||
expect(first.content).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "resource_link",
|
||||
uri: expect.stringMatching(
|
||||
/^rea:\/\/workspace\/ws_[a-f0-9]{64}\/revision\/3$/u,
|
||||
),
|
||||
}),
|
||||
);
|
||||
const retainedWorkspace = session.investigationWorkspaces()[0];
|
||||
expect(retainedWorkspace).toBeDefined();
|
||||
if (retainedWorkspace !== undefined) {
|
||||
const resource = await client.readResource({
|
||||
uri: `rea://workspace/${retainedWorkspace.workspace_id}/revision/${String(retainedWorkspace.revision)}`,
|
||||
});
|
||||
expect(resource.contents[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining(retainedWorkspace.revision_digest),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const second = await client.callTool({
|
||||
name: "find_changed_behavior",
|
||||
arguments: arguments_,
|
||||
});
|
||||
expect(second.isError, JSON.stringify(second)).not.toBe(true);
|
||||
const secondEvidence = sessionEvidence(session, second.structuredContent);
|
||||
expect(secondEvidence.evidence_id).toBe(firstEvidence.evidence_id);
|
||||
expect(
|
||||
await readInvestigationWorkspace(workspace, filePolicy),
|
||||
).toMatchObject({ ok: true, value: { revision: 3 } });
|
||||
} finally {
|
||||
await close(session, server, client);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("replays a complete workspace without write authority", async () => {
|
||||
const directory = await createTestTempDirectory("rea-investigation-read-");
|
||||
const left = join(directory, "left");
|
||||
const right = join(directory, "right");
|
||||
const workspace = join(directory, "workspace.json");
|
||||
await Promise.all([mkdir(left), mkdir(right)]);
|
||||
await Promise.all([
|
||||
writeFile(join(left, "feature.txt"), "old\n"),
|
||||
writeFile(join(right, "feature.txt"), "new\n"),
|
||||
]);
|
||||
const filePolicy = evidencePolicy(directory);
|
||||
const arguments_ = {
|
||||
investigation_run: {
|
||||
approved: true,
|
||||
workspace_path: workspace,
|
||||
workspace_name: "read-only-replay",
|
||||
left_path: left,
|
||||
right_path: right,
|
||||
options: { page_size: 500, change_limit: 100 },
|
||||
},
|
||||
};
|
||||
const writer = await investigationAuthority(directory, true);
|
||||
const initial = await connected(filePolicy, [directory], writer);
|
||||
try {
|
||||
const created = await initial.client.callTool({
|
||||
name: "find_changed_behavior",
|
||||
arguments: arguments_,
|
||||
});
|
||||
expect(created.isError, JSON.stringify(created)).not.toBe(true);
|
||||
} finally {
|
||||
await close(initial.session, initial.server, initial.client);
|
||||
}
|
||||
|
||||
const reader = await investigationAuthority(directory, false);
|
||||
const replay = await connected(filePolicy, [directory], reader);
|
||||
try {
|
||||
const cached = await replay.client.callTool({
|
||||
name: "find_changed_behavior",
|
||||
arguments: arguments_,
|
||||
});
|
||||
expect(cached.isError, JSON.stringify(cached)).not.toBe(true);
|
||||
expect(
|
||||
await readInvestigationWorkspace(workspace, filePolicy),
|
||||
).toMatchObject({ ok: true, value: { revision: 3 } });
|
||||
|
||||
await writeFile(join(right, "feature.txt"), "changed again\n");
|
||||
const denied = await replay.client.callTool({
|
||||
name: "find_changed_behavior",
|
||||
arguments: arguments_,
|
||||
});
|
||||
expect(denied).toMatchObject({
|
||||
isError: true,
|
||||
structuredContent: {
|
||||
error: {
|
||||
code: "permission_required",
|
||||
details: { capability: "investigation_workspace_write" },
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await close(replay.session, replay.server, replay.client);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("defers input permission for an explicit replay with deleted inputs", async () => {
|
||||
const directory = await createTestTempDirectory("rea-investigation-replay-");
|
||||
const left = join(directory, "left");
|
||||
const right = join(directory, "right");
|
||||
const workspace = join(directory, "workspace.json");
|
||||
await Promise.all([mkdir(left), mkdir(right)]);
|
||||
await Promise.all([
|
||||
writeFile(join(left, "feature.txt"), "old\n"),
|
||||
writeFile(join(right, "feature.txt"), "new\n"),
|
||||
]);
|
||||
const filePolicy = evidencePolicy(directory);
|
||||
const investigationRun = {
|
||||
approved: true,
|
||||
workspace_path: workspace,
|
||||
workspace_name: "selected-replay",
|
||||
left_path: left,
|
||||
right_path: right,
|
||||
options: { page_size: 500, change_limit: 100 },
|
||||
};
|
||||
const writer = await investigationAuthority(directory, true);
|
||||
const initial = await connected(filePolicy, [directory], writer);
|
||||
let runId: string | undefined;
|
||||
try {
|
||||
const created = await initial.client.callTool({
|
||||
name: "find_changed_behavior",
|
||||
arguments: { investigation_run: investigationRun },
|
||||
});
|
||||
expect(created.isError, JSON.stringify(created)).not.toBe(true);
|
||||
const evidence = sessionEvidence(
|
||||
initial.session,
|
||||
created.structuredContent,
|
||||
);
|
||||
const result = changedBehaviorResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
runId = result.investigation_run?.run_id;
|
||||
} finally {
|
||||
await close(initial.session, initial.server, initial.client);
|
||||
}
|
||||
if (runId === undefined) throw new Error("missing completed run ID");
|
||||
|
||||
await Promise.all([
|
||||
rm(left, { recursive: true, force: true }),
|
||||
rm(right, { recursive: true, force: true }),
|
||||
]);
|
||||
const workspaceOnly = await permissionAuthorityForRoot(
|
||||
directory,
|
||||
[
|
||||
"investigation_workspace_read",
|
||||
"investigation_workspace_write",
|
||||
"investigation_input",
|
||||
],
|
||||
["investigation_workspace_read"],
|
||||
);
|
||||
const replay = await connected(filePolicy, [], workspaceOnly);
|
||||
try {
|
||||
const cached = await replay.client.callTool({
|
||||
name: "find_changed_behavior",
|
||||
arguments: {
|
||||
investigation_run: {
|
||||
...investigationRun,
|
||||
replay_run_id: runId,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(cached.isError, JSON.stringify(cached)).not.toBe(true);
|
||||
expect(
|
||||
await readInvestigationWorkspace(workspace, filePolicy),
|
||||
).toMatchObject({ ok: true, value: { revision: 3 } });
|
||||
} finally {
|
||||
await close(replay.session, replay.server, replay.client);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses automatic artifact reads outside operator-approved roots", async () => {
|
||||
const directory = await createTestTempDirectory("rea-investigation-mcp-");
|
||||
const approvedInputs = join(directory, "approved-inputs");
|
||||
const outsideInputs = join(directory, "outside-inputs");
|
||||
const evidenceRoot = join(directory, "evidence");
|
||||
await Promise.all([
|
||||
mkdir(approvedInputs),
|
||||
mkdir(outsideInputs),
|
||||
mkdir(evidenceRoot),
|
||||
]);
|
||||
const left = join(outsideInputs, "left");
|
||||
const right = join(outsideInputs, "right");
|
||||
await Promise.all([mkdir(left), mkdir(right)]);
|
||||
const { session, server, client } = await connected(
|
||||
evidencePolicy(evidenceRoot),
|
||||
[approvedInputs],
|
||||
);
|
||||
try {
|
||||
const response = await client.callTool({
|
||||
name: "find_changed_behavior",
|
||||
arguments: {
|
||||
investigation_run: {
|
||||
approved: true,
|
||||
workspace_path: join(evidenceRoot, "workspace.json"),
|
||||
left_path: left,
|
||||
right_path: right,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(response.isError).toBe(true);
|
||||
expect(response.content[0]).toEqual({
|
||||
type: "text",
|
||||
text: JSON.stringify(response.structuredContent),
|
||||
});
|
||||
} finally {
|
||||
await close(session, server, client);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const connected = async (
|
||||
evidenceFilePolicy?: EvidenceFilePolicy,
|
||||
investigationInputRoots: readonly string[] = evidenceFilePolicy?.roots ?? [],
|
||||
permissionAuthority?: PermissionAuthority,
|
||||
) => {
|
||||
const session = composeBinarySessionFromFactory(() => ({
|
||||
health: () => Promise.resolve(),
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
const server = createServer(session, session, {
|
||||
...(evidenceFilePolicy === undefined
|
||||
? {}
|
||||
: {
|
||||
evidenceFilePolicy,
|
||||
investigationInputRoots,
|
||||
}),
|
||||
...(permissionAuthority === undefined ? {} : { permissionAuthority }),
|
||||
});
|
||||
const client = new Client({ name: "investigation-test", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
return { session, server, client };
|
||||
};
|
||||
|
||||
const evidencePolicy = (root: string): EvidenceFilePolicy => ({
|
||||
roots: [root],
|
||||
maxBytes: 64 * 1024 * 1024,
|
||||
maxDepth: 64,
|
||||
maxStringLength: 1024 * 1024,
|
||||
maxNodes: 1_000_000,
|
||||
});
|
||||
|
||||
const sessionEvidence = (session: BinarySession, value: unknown) => {
|
||||
const parsed = z
|
||||
.object({ evidence_id: z.string().regex(/^ev_[a-f0-9]{64}$/u) })
|
||||
.parse(value);
|
||||
const evidence = session.evidenceById(parsed.evidence_id);
|
||||
if (evidence === undefined) throw new TypeError("Missing session Evidence");
|
||||
return evidence;
|
||||
};
|
||||
|
||||
const investigationAuthority = async (
|
||||
root: string,
|
||||
includeWrite: boolean,
|
||||
): Promise<PermissionAuthority> =>
|
||||
permissionAuthorityForRoot(
|
||||
root,
|
||||
[
|
||||
"investigation_workspace_read",
|
||||
"investigation_workspace_write",
|
||||
"investigation_input",
|
||||
],
|
||||
[
|
||||
"investigation_workspace_read",
|
||||
"investigation_input",
|
||||
...(includeWrite ? (["investigation_workspace_write"] as const) : []),
|
||||
],
|
||||
);
|
||||
|
||||
const close = async (
|
||||
session: BinarySession,
|
||||
server: Awaited<ReturnType<typeof createServer>>,
|
||||
client: Client,
|
||||
) => {
|
||||
await Promise.allSettled([client.close(), server.close(), session.close()]);
|
||||
};
|
||||
|
|
@ -5,15 +5,15 @@ import { pathToFileURL } from "node:url";
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { BinarySession } from "../src/application/BinarySession.js";
|
||||
import { loadConfiguredPermissionAuthority } from "../src/application/PermissionConfiguration.js";
|
||||
import { observeJavaScriptRuntime } from "../src/application/JavaScriptRuntimeObservationService.js";
|
||||
import { V8InspectorProvider } from "../src/browser/V8InspectorProvider.js";
|
||||
import { parseConfig } from "../src/config.js";
|
||||
import { createServer } from "../src/server/createServer.js";
|
||||
import { observed } from "./fixtures/analysisExecution.js";
|
||||
import { startFakeV8Inspector } from "./fixtures/fakeV8Inspector.js";
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js";
|
||||
import { loadConfiguredPermissionAuthority } from "../../../src/application/PermissionConfiguration.js";
|
||||
import { observeJavaScriptRuntime } from "../../../src/application/JavaScriptRuntimeObservationService.js";
|
||||
import { V8InspectorProvider } from "../../../src/browser/V8InspectorProvider.js";
|
||||
import { parseConfig } from "../../../src/config.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { observed } from "../../fixtures/analysisExecution.js";
|
||||
import { startFakeV8Inspector } from "../../fixtures/fakeV8Inspector.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
describe("JavaScript runtime observation MCP tools", () => {
|
||||
const resources: Array<{ close(): Promise<unknown> }> = [];
|
||||
|
|
@ -45,7 +45,7 @@ describe("JavaScript runtime observation MCP tools", () => {
|
|||
if (!config.ok) throw config.error;
|
||||
const authority = await loadConfiguredPermissionAuthority(config.value);
|
||||
if (!authority.ok) throw authority.error;
|
||||
const session = new BinarySession(() => ({
|
||||
const session = composeBinarySessionFromFactory(() => ({
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { ServeStdioOptions } from "@modelcontextprotocol/server/stdio";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { run, runEntrypoint } from "../src/main.js";
|
||||
import { run, runEntrypoint } from "../../../src/main.js";
|
||||
|
||||
type RuntimeDependencies = NonNullable<Parameters<typeof run>[0]>;
|
||||
|
||||
|
|
@ -2,26 +2,25 @@ import { createHash } from "node:crypto";
|
|||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import type { PermissionAuthority } from "../src/application/PermissionAuthority.js";
|
||||
import type { ProjectPermissionStore } from "../src/application/ProjectPermissionStore.js";
|
||||
import type { PermissionGrant } from "../src/domain/permissionPolicy.js";
|
||||
import { ok } from "../src/domain/result.js";
|
||||
import { run } from "../src/main.js";
|
||||
import type { PermissionAuthority } from "../../../src/application/PermissionAuthority.js";
|
||||
import type { ProjectPermissionStore } from "../../../src/application/ProjectPermissionStore.js";
|
||||
import type { PermissionGrant } from "../../../src/domain/permissionPolicy.js";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import { run } from "../../../src/main.js";
|
||||
import {
|
||||
createServer,
|
||||
type CreateServerOptions,
|
||||
} from "../src/server/createServer.js";
|
||||
} from "../../../src/server/createServer.js";
|
||||
|
||||
type RuntimeDependencies = NonNullable<Parameters<typeof run>[0]>;
|
||||
|
||||
const directories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await Promise.all(
|
||||
directories
|
||||
.splice(0)
|
||||
|
|
@ -198,12 +197,12 @@ const startRuntime = async (
|
|||
createServer: (analysis, session, received) => {
|
||||
options = received;
|
||||
const server = createServer(analysis, session, received);
|
||||
vi.spyOn(server, "sendToolListChanged").mockImplementation(() => {
|
||||
server.sendToolListChanged = () => {
|
||||
notificationCount += 1;
|
||||
notification.resolve(undefined);
|
||||
notification = deferred<void>();
|
||||
waiters.get(notificationCount)?.resolve(undefined);
|
||||
});
|
||||
};
|
||||
return server;
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,400 @@
|
|||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/server";
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { AnalysisProviderRegistry } from "../../../src/application/AnalysisProviderRegistry.js";
|
||||
import { composeBinarySession } from "../../../src/application/BinarySessionComposition.js";
|
||||
import type { BinarySession } from "../../../src/application/BinarySession.js";
|
||||
import { createPermissionAuthority } from "../../../src/application/PermissionAuthority.js";
|
||||
import { SessionProviderRouter } from "../../../src/application/SessionProviderRouter.js";
|
||||
import { MANAGED_NATIVE_VERIFICATION_EXAMPLE } from "../../../src/contracts/managedWorkflowExamples.js";
|
||||
import type { PermissionCeiling } from "../../../src/domain/permissionPolicy.js";
|
||||
import { ManagedStaticProvider } from "../../../src/dotnet/ManagedStaticProvider.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { buildManagedPeFixture } from "../../fixtures/managedPe.js";
|
||||
|
||||
it("opens a managed PE and executes the managed static provider through MCP", async () => {
|
||||
const directory = await createTestTempDirectory("rea-managed-mcp-");
|
||||
const path = join(directory, "fixture.exe");
|
||||
const rightPath = join(directory, "fixture-renamed.exe");
|
||||
const runtimePath = join(directory, "dotnet");
|
||||
await writeFile(path, buildManagedPeFixture());
|
||||
await writeFile(runtimePath, "#!/bin/sh\n");
|
||||
await writeFile(rightPath, buildManagedPeFixture({ methodName: "Renamed" }));
|
||||
const session = composeBinarySession(
|
||||
SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), [
|
||||
new ManagedStaticProvider(),
|
||||
]),
|
||||
);
|
||||
const runtimeCeiling: PermissionCeiling = {
|
||||
capability: "managed_runtime",
|
||||
roots: [directory],
|
||||
executables: [runtimePath],
|
||||
environment_names: [],
|
||||
network: "none",
|
||||
mount: false,
|
||||
};
|
||||
const authority = await createPermissionAuthority(
|
||||
[runtimeCeiling],
|
||||
[
|
||||
{
|
||||
...runtimeCeiling,
|
||||
grant_id: "administrator:managed_runtime",
|
||||
lifetime: "administrator",
|
||||
operation_identity: null,
|
||||
expires_at: null,
|
||||
},
|
||||
],
|
||||
);
|
||||
if (!authority.ok) throw authority.error;
|
||||
const server = createServer(session, session, {
|
||||
permissionAuthority: authority.value,
|
||||
managedRuntimePolicy: {
|
||||
enabled: true,
|
||||
roots: [directory],
|
||||
executablePath: runtimePath,
|
||||
},
|
||||
availabilityPolicy: () => ({
|
||||
processCaptureEnabled: false,
|
||||
evidenceFileRoots: 0,
|
||||
investigationInputRoots: 0,
|
||||
browserObservationEnabled: false,
|
||||
electronObservationEnabled: false,
|
||||
javascriptReplayEnabled: false,
|
||||
managedRuntimeEnabled: true,
|
||||
}),
|
||||
});
|
||||
const client = new Client({ name: "managed-mcp-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
await verifyManagedCatalogAndNativeWorkflow(client, session);
|
||||
const members = await inspectManagedStaticWorkflow(client, session, path);
|
||||
await verifyManagedComparisonAndReconstruction(
|
||||
client,
|
||||
session,
|
||||
members,
|
||||
rightPath,
|
||||
);
|
||||
} finally {
|
||||
await Promise.all([client.close(), server.close()]);
|
||||
await session.close();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
const verifyManagedCatalogAndNativeWorkflow = async (
|
||||
client: Client,
|
||||
session: BinarySession,
|
||||
): Promise<void> => {
|
||||
const names = (await client.listTools()).tools.map(({ name }) => name);
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
"inspect_managed_artifact",
|
||||
"inspect_managed_members",
|
||||
"inspect_managed_native_boundaries",
|
||||
"compare_managed_members",
|
||||
"import_managed_reconstruction",
|
||||
"verify_managed_native_boundaries",
|
||||
"plan_managed_runtime_correlation",
|
||||
]),
|
||||
);
|
||||
for (const evidence of [
|
||||
MANAGED_NATIVE_VERIFICATION_EXAMPLE.managed_boundaries,
|
||||
...MANAGED_NATIVE_VERIFICATION_EXAMPLE.native_observations,
|
||||
])
|
||||
expect(session.recordEvidence(evidence)).toMatchObject({ ok: true });
|
||||
const verified = structured(
|
||||
await client.callTool({
|
||||
name: "verify_managed_native_boundaries",
|
||||
arguments: {
|
||||
managed_boundaries_evidence_id:
|
||||
MANAGED_NATIVE_VERIFICATION_EXAMPLE.managed_boundaries.evidence_id,
|
||||
native_observation_evidence_ids:
|
||||
MANAGED_NATIVE_VERIFICATION_EXAMPLE.native_observations.map(
|
||||
({ evidence_id: evidenceId }) => evidenceId,
|
||||
),
|
||||
limits: MANAGED_NATIVE_VERIFICATION_EXAMPLE.limits,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(verified).toMatchObject({
|
||||
evidence_id: expect.stringMatching(/^ev_[a-f0-9]{64}$/u),
|
||||
evidence_uri: expect.stringMatching(/^rea:\/\/evidence\/ev_/u),
|
||||
result: {
|
||||
summary: { verified: 1 },
|
||||
algorithm: { token_to_address_mapping: "not-inferred" },
|
||||
},
|
||||
});
|
||||
const native = MANAGED_NATIVE_VERIFICATION_EXAMPLE.native_observations[0];
|
||||
if (native === undefined) throw new Error("missing native Evidence");
|
||||
const wrong = await client.callTool({
|
||||
name: "verify_managed_native_boundaries",
|
||||
arguments: {
|
||||
managed_boundaries_evidence_id: native.evidence_id,
|
||||
native_observation_evidence_ids: [
|
||||
MANAGED_NATIVE_VERIFICATION_EXAMPLE.managed_boundaries.evidence_id,
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(wrong).toMatchObject({
|
||||
isError: true,
|
||||
structuredContent: {
|
||||
error: {
|
||||
code: "evidence_integrity_mismatch",
|
||||
details: { reason: "wrong_operation", actual: "inspect_macho" },
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const inspectManagedStaticWorkflow = async (
|
||||
client: Client,
|
||||
session: BinarySession,
|
||||
path: string,
|
||||
): Promise<Record<string, unknown>> => {
|
||||
const inspected = structured(
|
||||
await client.callTool({
|
||||
name: "inspect_managed_artifact",
|
||||
arguments: { path, reference_limit: 1 },
|
||||
}),
|
||||
);
|
||||
expect(inspected).toMatchObject({
|
||||
result: {
|
||||
classification: { status: "managed", runtime_family: "modern-dotnet" },
|
||||
references: { limit: 1 },
|
||||
},
|
||||
});
|
||||
const members = sessionEvidence(
|
||||
session,
|
||||
structured(
|
||||
await client.callTool({
|
||||
name: "inspect_managed_members",
|
||||
arguments: { method_limit: 1 },
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(members).toMatchObject({
|
||||
operation: "inspect_managed_members",
|
||||
provider: { id: "rea-dotnet-static" },
|
||||
subject: { local_path: path, format: "pe" },
|
||||
normalized_result: {
|
||||
identity_scope: { token_identity: "build-local" },
|
||||
methods: { total: 1, returned: 1 },
|
||||
call_edges: { total: 1 },
|
||||
field_accesses: { total: 1 },
|
||||
},
|
||||
});
|
||||
const boundaries = structured(
|
||||
await client.callTool({
|
||||
name: "inspect_managed_native_boundaries",
|
||||
arguments: { import_limit: 1 },
|
||||
}),
|
||||
);
|
||||
expect(boundaries).toMatchObject({
|
||||
result: {
|
||||
identity_scope: { token_identity: "build-local" },
|
||||
pinvoke_imports: { total: 0, limit: 1 },
|
||||
native_implementations: { total: 0 },
|
||||
},
|
||||
});
|
||||
return members;
|
||||
};
|
||||
|
||||
const methodFrom = (members: Record<string, unknown>) =>
|
||||
z
|
||||
.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
token: z.string(),
|
||||
signature: z.object({ raw_sha256: z.string() }),
|
||||
body: z.object({ normalized_il_sha256: z.string().nullable() }),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.parse(
|
||||
z.record(z.string(), z.unknown()).parse(members.normalized_result)
|
||||
.methods,
|
||||
).items[0];
|
||||
|
||||
const verifyManagedComparisonAndReconstruction = async (
|
||||
client: Client,
|
||||
session: BinarySession,
|
||||
members: Record<string, unknown>,
|
||||
rightPath: string,
|
||||
): Promise<void> => {
|
||||
await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: rightPath },
|
||||
});
|
||||
const right = sessionEvidence(
|
||||
session,
|
||||
structured(
|
||||
await client.callTool({
|
||||
name: "inspect_managed_members",
|
||||
arguments: { method_limit: 1 },
|
||||
}),
|
||||
),
|
||||
);
|
||||
const compared = sessionEvidence(
|
||||
session,
|
||||
structured(
|
||||
await client.callTool({
|
||||
name: "compare_managed_members",
|
||||
arguments: {
|
||||
left_evidence_id: members.evidence_id,
|
||||
right_evidence_id: right.evidence_id,
|
||||
limits: {
|
||||
max_method_matches: 100,
|
||||
max_field_matches: 100,
|
||||
max_candidates: 10,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(compared).toMatchObject({
|
||||
operation: "compare_managed_members",
|
||||
provider: { id: "rea-dotnet-workflows" },
|
||||
confidence: "inferred",
|
||||
normalized_result: {
|
||||
algorithm: { name_matching: "not-used" },
|
||||
matching: { exact_il_signature: 1 },
|
||||
},
|
||||
});
|
||||
const method = methodFrom(members);
|
||||
expect(method).toBeDefined();
|
||||
if (method === undefined) return;
|
||||
await verifyImport(client, session, members, method);
|
||||
await verifyRuntimePlan(client, session, members, method);
|
||||
};
|
||||
|
||||
type ManagedMethod = NonNullable<ReturnType<typeof methodFrom>>;
|
||||
|
||||
const verifyImport = async (
|
||||
client: Client,
|
||||
session: BinarySession,
|
||||
members: Record<string, unknown>,
|
||||
method: ManagedMethod,
|
||||
): Promise<void> => {
|
||||
const imported = sessionEvidence(
|
||||
session,
|
||||
structured(
|
||||
await client.callTool({
|
||||
name: "import_managed_reconstruction",
|
||||
arguments: {
|
||||
static_members_evidence_id: members.evidence_id,
|
||||
decompiler: {
|
||||
name: "ilspycmd",
|
||||
version: "9.1.0.7988",
|
||||
family: "ilspy",
|
||||
executable_sha256: null,
|
||||
options: ["--type", "Example.Program"],
|
||||
},
|
||||
methods: [
|
||||
{
|
||||
token: method.token,
|
||||
signature_sha256: method.signature.raw_sha256,
|
||||
normalized_il_sha256: method.body.normalized_il_sha256,
|
||||
reconstruction: {
|
||||
kind: "decompiled-csharp",
|
||||
language: "csharp",
|
||||
text: "internal static void Main() { }",
|
||||
},
|
||||
},
|
||||
],
|
||||
notes: ["synthetic MCP import"],
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(imported).toMatchObject({
|
||||
operation: "import_managed_reconstruction",
|
||||
provider: { id: "rea-dotnet-workflows" },
|
||||
confidence: "inferred",
|
||||
normalized_result: {
|
||||
executed: false,
|
||||
summary: { imported_methods: 1, decompiled_csharp_methods: 1 },
|
||||
methods: [
|
||||
{
|
||||
token: method.token,
|
||||
validation: { canonical_observation: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const verifyRuntimePlan = async (
|
||||
client: Client,
|
||||
session: BinarySession,
|
||||
members: Record<string, unknown>,
|
||||
method: ManagedMethod,
|
||||
): Promise<void> => {
|
||||
const planned = sessionEvidence(
|
||||
session,
|
||||
structured(
|
||||
await client.callTool({
|
||||
name: "plan_managed_runtime_correlation",
|
||||
arguments: {
|
||||
static_members_evidence_id: members.evidence_id,
|
||||
method: {
|
||||
token: method.token,
|
||||
signature_sha256: method.signature.raw_sha256,
|
||||
normalized_il_sha256: method.body.normalized_il_sha256,
|
||||
},
|
||||
requested_effect: "attach",
|
||||
host: {
|
||||
os: "linux",
|
||||
clr_family: "dotnet",
|
||||
architecture: "x86_64",
|
||||
},
|
||||
bounds: {
|
||||
timeout_ms: 5000,
|
||||
max_threads: 32,
|
||||
max_output_bytes: 65536,
|
||||
allow_network: false,
|
||||
allow_ui: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(planned).toMatchObject({
|
||||
operation: "plan_managed_runtime_correlation",
|
||||
provider: { id: "rea-dotnet-workflows" },
|
||||
confidence: "derived",
|
||||
normalized_result: {
|
||||
executed: false,
|
||||
authority_model: { capability: "managed_runtime" },
|
||||
requested_runtime: { effect: "attach", network: "none" },
|
||||
effect_taxonomy: { attaches_process: true },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const structured = (result: CallToolResult): Record<string, unknown> => {
|
||||
if (
|
||||
typeof result.structuredContent !== "object" ||
|
||||
result.structuredContent === null
|
||||
)
|
||||
throw new Error("missing structured result");
|
||||
return z.record(z.string(), z.unknown()).parse(result.structuredContent);
|
||||
};
|
||||
|
||||
const sessionEvidence = (
|
||||
session: BinarySession,
|
||||
result: Readonly<Record<string, unknown>>,
|
||||
): Record<string, unknown> => {
|
||||
const evidenceId = z.string().parse(result.evidence_id);
|
||||
const evidence = session.evidenceById(evidenceId);
|
||||
if (evidence === undefined) throw new Error("missing session Evidence");
|
||||
return z.record(z.string(), z.unknown()).parse(evidence);
|
||||
};
|
||||
|
|
@ -3,10 +3,13 @@ import { join, resolve } from "node:path";
|
|||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { compareInventory, runProductionMcpDoctor } from "../src/mcpDoctor.js";
|
||||
import { CATALOG_IDENTITY } from "../src/catalogIdentity.js";
|
||||
import {
|
||||
compareInventory,
|
||||
runProductionMcpDoctor,
|
||||
} from "../../../src/mcpDoctor.js";
|
||||
import { CATALOG_IDENTITY } from "../../../src/catalogIdentity.js";
|
||||
|
||||
const temporary: string[] = [];
|
||||
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MCP_STARTUP_POLICY } from "../src/mcpStartupPolicy.js";
|
||||
import { MCP_STARTUP_POLICY } from "../../../src/mcpStartupPolicy.js";
|
||||
import {
|
||||
createEsmModuleProfileCollector,
|
||||
parseLinuxVmRssBytes,
|
||||
} from "../scripts/lib/mcp-startup-probe.mjs";
|
||||
} from "../../../scripts/lib/mcp-startup-probe.mjs";
|
||||
|
||||
describe("MCP startup policy", () => {
|
||||
it("preserves headroom beneath the configured Codex timeout", () => {
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { AnalysisOperationPort } from "../src/application/AnalysisProvider.js";
|
||||
import { OFFICIAL_TOOL_CONTRACTS } from "../src/contracts/toolContracts.js";
|
||||
import { HopperRemoteError } from "../src/domain/errors.js";
|
||||
import { err } from "../src/domain/result.js";
|
||||
import { observed as ok } from "./fixtures/analysisExecution.js";
|
||||
import type { JsonValue } from "../src/domain/jsonValue.js";
|
||||
import { createServer } from "../src/server/createServer.js";
|
||||
import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js";
|
||||
import { OFFICIAL_TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js";
|
||||
import { HopperRemoteError } from "../../../src/domain/errors.js";
|
||||
import { err } from "../../../src/domain/result.js";
|
||||
import { observed as ok } from "../../fixtures/analysisExecution.js";
|
||||
import type { JsonValue } from "../../../src/domain/jsonValue.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
|
||||
const VALID_INPUTS: Readonly<
|
||||
Record<string, Readonly<Record<string, JsonValue>>>
|
||||
|
|
@ -3,10 +3,10 @@ import { realpath } from "node:fs/promises";
|
|||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AnalysisOperationPort } from "../src/application/AnalysisProvider.js";
|
||||
import { PermissionAuthority } from "../src/application/PermissionAuthority.js";
|
||||
import { createPermissionPolicy } from "../src/domain/permissionPolicy.js";
|
||||
import { createServer } from "../src/server/createServer.js";
|
||||
import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js";
|
||||
import { PermissionAuthority } from "../../../src/application/PermissionAuthority.js";
|
||||
import { createPermissionPolicy } from "../../../src/domain/permissionPolicy.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
|
||||
describe("MCP permission preflight", () => {
|
||||
it("denies extraction before dispatch and returns exact typed remediation", async () => {
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js";
|
||||
import { PermissionAuthority } from "../../../src/application/PermissionAuthority.js";
|
||||
import { createPermissionPolicy } from "../../../src/domain/permissionPolicy.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { observed } from "../../fixtures/analysisExecution.js";
|
||||
import { PROCESS_CAPTURE_ELICITATION_POLICY } from "../../../src/server/ProcessCaptureElicitation.js";
|
||||
describe("process-capture MCP elicitation handshake", () => {
|
||||
it("completes signed consent through the modern MCP client and server", async () => {
|
||||
const root = await createTestTempDirectory("rea-elicit-mcp-");
|
||||
const authority = new PermissionAuthority(
|
||||
createPermissionPolicy([
|
||||
{
|
||||
capability: "process_capture",
|
||||
roots: [root],
|
||||
executables: [process.execPath],
|
||||
environment_names: [],
|
||||
network: "external",
|
||||
mount: false,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const session = composeBinarySessionFromFactory(() => ({
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
const createTestServer = () =>
|
||||
createServer(session, session, {
|
||||
logger: silentLogger,
|
||||
permissionAuthority: authority,
|
||||
processPolicy: {
|
||||
enabled: true,
|
||||
executableRoots: [dirname(process.execPath)],
|
||||
workingRoots: [root],
|
||||
allowedEnvironment: [],
|
||||
allowExternalNetwork: true,
|
||||
},
|
||||
});
|
||||
const client = new Client(
|
||||
{ name: "process-elicit", version: "1" },
|
||||
{
|
||||
capabilities: { elicitation: { form: {} } },
|
||||
versionNegotiation: {
|
||||
mode: {
|
||||
pin: PROCESS_CAPTURE_ELICITATION_POLICY.protocolVersions[0],
|
||||
},
|
||||
},
|
||||
inputRequired: { autoFulfill: true, maxRounds: 3 },
|
||||
},
|
||||
);
|
||||
let prompts = 0;
|
||||
client.setRequestHandler("elicitation/create", () => {
|
||||
prompts += 1;
|
||||
return Promise.resolve({
|
||||
action: "accept" as const,
|
||||
content: { lifetime: "session" },
|
||||
});
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
const server = serveStdio(createTestServer, {
|
||||
transport: serverTransport,
|
||||
legacy: "reject",
|
||||
});
|
||||
try {
|
||||
await client.connect(clientTransport);
|
||||
const captured = await client.callTool({
|
||||
name: "capture_process_scenario",
|
||||
arguments: {
|
||||
approved: true,
|
||||
executable: process.execPath,
|
||||
arguments: ["-e", "process.exit(0)"],
|
||||
working_directory: root,
|
||||
},
|
||||
});
|
||||
expect(captured.isError).not.toBe(true);
|
||||
expect(prompts).toBe(1);
|
||||
} finally {
|
||||
await Promise.allSettled([client.close(), server.close()]);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 20000);
|
||||
});
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
import { isInputRequiredResult } from "@modelcontextprotocol/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PermissionAuthority } from "../../../src/application/PermissionAuthority.js";
|
||||
import { createPermissionPolicy } from "../../../src/domain/permissionPolicy.js";
|
||||
import {
|
||||
authorizeProcessCaptureWithElicitation,
|
||||
type ProcessCaptureElicitationState,
|
||||
} from "../../../src/server/ProcessCaptureElicitation.js";
|
||||
const request = {
|
||||
capability: "process_capture" as const,
|
||||
roots: ["/tmp"],
|
||||
executables: [process.execPath],
|
||||
environment_names: ["PATH"],
|
||||
network: "external" as const,
|
||||
mount: false,
|
||||
operation_identity: "capture:test",
|
||||
};
|
||||
const now = Date.parse("2026-07-18T00:00:00.000Z");
|
||||
const ceilingOnlyAuthority = (
|
||||
origins?: readonly string[],
|
||||
): PermissionAuthority =>
|
||||
new PermissionAuthority(
|
||||
createPermissionPolicy([
|
||||
{
|
||||
capability: "process_capture",
|
||||
roots: request.roots,
|
||||
executables: request.executables,
|
||||
environment_names: request.environment_names,
|
||||
...(origins === undefined ? {} : { origins }),
|
||||
network: request.network,
|
||||
mount: request.mount,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const requestContext = (
|
||||
state?: ProcessCaptureElicitationState,
|
||||
response?: Record<string, unknown>,
|
||||
) => ({
|
||||
mcpReq: {
|
||||
requestState: () => state,
|
||||
...(response === undefined
|
||||
? {}
|
||||
: { inputResponses: { process_capture_grant: response } }),
|
||||
},
|
||||
});
|
||||
const verifiedState = (
|
||||
state: ProcessCaptureElicitationState | undefined,
|
||||
): Promise<ProcessCaptureElicitationState> => {
|
||||
if (state === undefined) throw new Error("state was not minted");
|
||||
return Promise.resolve(state);
|
||||
};
|
||||
describe("process-capture MCP elicitation grants", () => {
|
||||
it("grants and reuses a signed session scope on the modern protocol", async () => {
|
||||
const authority = ceilingOnlyAuthority();
|
||||
let state: ProcessCaptureElicitationState | undefined;
|
||||
const elicitation = {
|
||||
stateCodec: {
|
||||
mint: (value: ProcessCaptureElicitationState) => {
|
||||
state = value;
|
||||
return Promise.resolve("signed-state");
|
||||
},
|
||||
verify: () => verifiedState(state),
|
||||
},
|
||||
supported: () => true,
|
||||
now: () => now,
|
||||
consumedNonces: new Map<string, number>(),
|
||||
};
|
||||
const first = await authorizeProcessCaptureWithElicitation(
|
||||
authority,
|
||||
request,
|
||||
requestContext(),
|
||||
elicitation,
|
||||
);
|
||||
expect(isInputRequiredResult(first)).toBe(true);
|
||||
expect(first).toMatchObject({ requestState: "signed-state" });
|
||||
expect(first).toMatchObject({
|
||||
inputRequests: {
|
||||
process_capture_grant: {
|
||||
params: {
|
||||
message: expect.stringContaining("Mount access: no"),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(state?.request).toEqual(request);
|
||||
const accepted = await authorizeProcessCaptureWithElicitation(
|
||||
authority,
|
||||
request,
|
||||
requestContext(state, {
|
||||
action: "accept",
|
||||
content: { lifetime: "session" },
|
||||
}),
|
||||
elicitation,
|
||||
);
|
||||
expect(isInputRequiredResult(accepted)).toBe(false);
|
||||
if (isInputRequiredResult(accepted)) return;
|
||||
expect(accepted.ok).toBe(true);
|
||||
const reused = await authorizeProcessCaptureWithElicitation(
|
||||
authority,
|
||||
request,
|
||||
requestContext(),
|
||||
elicitation,
|
||||
);
|
||||
expect(isInputRequiredResult(reused)).toBe(false);
|
||||
if (!isInputRequiredResult(reused)) expect(reused.ok).toBe(true);
|
||||
});
|
||||
it("fails closed for legacy clients and altered continuations", async () => {
|
||||
const authority = ceilingOnlyAuthority();
|
||||
let state: ProcessCaptureElicitationState | undefined;
|
||||
const elicitation = {
|
||||
stateCodec: {
|
||||
mint: (value: ProcessCaptureElicitationState) => {
|
||||
state = value;
|
||||
return Promise.resolve("signed-state");
|
||||
},
|
||||
verify: () => verifiedState(state),
|
||||
},
|
||||
supported: () => false,
|
||||
now: () => now,
|
||||
consumedNonces: new Map<string, number>(),
|
||||
};
|
||||
const legacy = await authorizeProcessCaptureWithElicitation(
|
||||
authority,
|
||||
request,
|
||||
requestContext(),
|
||||
elicitation,
|
||||
);
|
||||
expect(isInputRequiredResult(legacy)).toBe(false);
|
||||
if (!isInputRequiredResult(legacy)) expect(legacy.ok).toBe(false);
|
||||
const modern = { ...elicitation, supported: () => true };
|
||||
await authorizeProcessCaptureWithElicitation(
|
||||
authority,
|
||||
request,
|
||||
requestContext(),
|
||||
modern,
|
||||
);
|
||||
const altered = await authorizeProcessCaptureWithElicitation(
|
||||
authority,
|
||||
{ ...request, operation_identity: "capture:altered" },
|
||||
requestContext(state, {
|
||||
action: "accept",
|
||||
content: { lifetime: "session" },
|
||||
}),
|
||||
modern,
|
||||
);
|
||||
expect(isInputRequiredResult(altered)).toBe(false);
|
||||
if (!isInputRequiredResult(altered)) expect(altered.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
import { isInputRequiredResult } from "@modelcontextprotocol/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PermissionAuthority } from "../../../src/application/PermissionAuthority.js";
|
||||
import { createPermissionPolicy } from "../../../src/domain/permissionPolicy.js";
|
||||
import {
|
||||
authorizeProcessCaptureWithElicitation,
|
||||
type ProcessCaptureElicitationState,
|
||||
} from "../../../src/server/ProcessCaptureElicitation.js";
|
||||
const request = {
|
||||
capability: "process_capture" as const,
|
||||
roots: ["/tmp"],
|
||||
executables: [process.execPath],
|
||||
environment_names: ["PATH"],
|
||||
network: "external" as const,
|
||||
mount: false,
|
||||
operation_identity: "capture:test",
|
||||
};
|
||||
const now = Date.parse("2026-07-18T00:00:00.000Z");
|
||||
const ceilingOnlyAuthority = (
|
||||
origins?: readonly string[],
|
||||
): PermissionAuthority =>
|
||||
new PermissionAuthority(
|
||||
createPermissionPolicy([
|
||||
{
|
||||
capability: "process_capture",
|
||||
roots: request.roots,
|
||||
executables: request.executables,
|
||||
environment_names: request.environment_names,
|
||||
...(origins === undefined ? {} : { origins }),
|
||||
network: request.network,
|
||||
mount: request.mount,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const requestContext = (
|
||||
state?: ProcessCaptureElicitationState,
|
||||
response?: Record<string, unknown>,
|
||||
) => ({
|
||||
mcpReq: {
|
||||
requestState: () => state,
|
||||
...(response === undefined
|
||||
? {}
|
||||
: { inputResponses: { process_capture_grant: response } }),
|
||||
},
|
||||
});
|
||||
const verifiedState = (
|
||||
state: ProcessCaptureElicitationState | undefined,
|
||||
): Promise<ProcessCaptureElicitationState> => {
|
||||
if (state === undefined) throw new Error("state was not minted");
|
||||
return Promise.resolve(state);
|
||||
};
|
||||
describe("process-capture MCP elicitation grants", () => {
|
||||
it("binds signed continuation state to exact origins", async () => {
|
||||
const authority = ceilingOnlyAuthority([
|
||||
"https://example.test",
|
||||
"https://other.test",
|
||||
]);
|
||||
let state: ProcessCaptureElicitationState | undefined;
|
||||
const elicitation = {
|
||||
stateCodec: {
|
||||
mint: (value: ProcessCaptureElicitationState) => {
|
||||
state = value;
|
||||
return Promise.resolve("signed-state");
|
||||
},
|
||||
verify: () => verifiedState(state),
|
||||
},
|
||||
supported: () => true,
|
||||
now: () => now,
|
||||
consumedNonces: new Map<string, number>(),
|
||||
};
|
||||
const scoped = { ...request, origins: ["https://example.test"] };
|
||||
const prompted = await authorizeProcessCaptureWithElicitation(
|
||||
authority,
|
||||
scoped,
|
||||
requestContext(),
|
||||
elicitation,
|
||||
);
|
||||
expect(prompted).toMatchObject({
|
||||
inputRequests: {
|
||||
process_capture_grant: {
|
||||
params: {
|
||||
message: expect.stringContaining("Origins: https://example.test"),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const altered = await authorizeProcessCaptureWithElicitation(
|
||||
authority,
|
||||
{ ...scoped, origins: ["https://other.test"] },
|
||||
requestContext(state, {
|
||||
action: "accept",
|
||||
content: { lifetime: "session" },
|
||||
}),
|
||||
elicitation,
|
||||
);
|
||||
expect(isInputRequiredResult(altered)).toBe(false);
|
||||
if (!isInputRequiredResult(altered)) expect(altered.ok).toBe(false);
|
||||
});
|
||||
it("consumes once grants and rejects continuation replay", async () => {
|
||||
const authority = ceilingOnlyAuthority();
|
||||
let state: ProcessCaptureElicitationState | undefined;
|
||||
const elicitation = {
|
||||
stateCodec: {
|
||||
mint: (value: ProcessCaptureElicitationState) => {
|
||||
state = value;
|
||||
return Promise.resolve("signed-state");
|
||||
},
|
||||
verify: () => verifiedState(state),
|
||||
},
|
||||
supported: () => true,
|
||||
now: () => now,
|
||||
consumedNonces: new Map<string, number>(),
|
||||
};
|
||||
await authorizeProcessCaptureWithElicitation(
|
||||
authority,
|
||||
request,
|
||||
requestContext(),
|
||||
elicitation,
|
||||
);
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { lifetime: "once" },
|
||||
};
|
||||
const accepted = await authorizeProcessCaptureWithElicitation(
|
||||
authority,
|
||||
request,
|
||||
requestContext(state, response),
|
||||
elicitation,
|
||||
);
|
||||
if (isInputRequiredResult(accepted)) throw new Error("grant was not used");
|
||||
expect(accepted.ok).toBe(true);
|
||||
const replayed = await authorizeProcessCaptureWithElicitation(
|
||||
authority,
|
||||
request,
|
||||
requestContext(state, response),
|
||||
elicitation,
|
||||
);
|
||||
expect(isInputRequiredResult(replayed)).toBe(false);
|
||||
if (!isInputRequiredResult(replayed)) expect(replayed.ok).toBe(false);
|
||||
const next = await authorizeProcessCaptureWithElicitation(
|
||||
authority,
|
||||
request,
|
||||
requestContext(),
|
||||
elicitation,
|
||||
);
|
||||
expect(isInputRequiredResult(next)).toBe(true);
|
||||
const declined = await authorizeProcessCaptureWithElicitation(
|
||||
authority,
|
||||
request,
|
||||
requestContext(state, { action: "decline" }),
|
||||
elicitation,
|
||||
);
|
||||
expect(isInputRequiredResult(declined)).toBe(false);
|
||||
const acceptedAfterDecline = await authorizeProcessCaptureWithElicitation(
|
||||
authority,
|
||||
request,
|
||||
requestContext(state, {
|
||||
action: "accept",
|
||||
content: { lifetime: "session" },
|
||||
}),
|
||||
elicitation,
|
||||
);
|
||||
expect(isInputRequiredResult(acceptedAfterDecline)).toBe(false);
|
||||
if (!isInputRequiredResult(acceptedAfterDecline))
|
||||
expect(acceptedAfterDecline.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
import { isInputRequiredResult } from "@modelcontextprotocol/server";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { PermissionAuthority } from "../../../src/application/PermissionAuthority.js";
|
||||
import { createPermissionPolicy } from "../../../src/domain/permissionPolicy.js";
|
||||
import {
|
||||
authorizeProcessCaptureWithElicitation,
|
||||
type ProcessCaptureElicitationState,
|
||||
} from "../../../src/server/ProcessCaptureElicitation.js";
|
||||
const request = {
|
||||
capability: "process_capture" as const,
|
||||
roots: ["/tmp"],
|
||||
executables: [process.execPath],
|
||||
environment_names: ["PATH"],
|
||||
network: "external" as const,
|
||||
mount: false,
|
||||
operation_identity: "capture:test",
|
||||
};
|
||||
const now = Date.parse("2026-07-18T00:00:00.000Z");
|
||||
const ceilingOnlyAuthority = (
|
||||
origins?: readonly string[],
|
||||
): PermissionAuthority =>
|
||||
new PermissionAuthority(
|
||||
createPermissionPolicy([
|
||||
{
|
||||
capability: "process_capture",
|
||||
roots: request.roots,
|
||||
executables: request.executables,
|
||||
environment_names: request.environment_names,
|
||||
...(origins === undefined ? {} : { origins }),
|
||||
network: request.network,
|
||||
mount: request.mount,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const requestContext = (
|
||||
state?: ProcessCaptureElicitationState,
|
||||
response?: Record<string, unknown>,
|
||||
) => ({
|
||||
mcpReq: {
|
||||
requestState: () => state,
|
||||
...(response === undefined
|
||||
? {}
|
||||
: { inputResponses: { process_capture_grant: response } }),
|
||||
},
|
||||
});
|
||||
const verifiedState = (
|
||||
state: ProcessCaptureElicitationState | undefined,
|
||||
): Promise<ProcessCaptureElicitationState> => {
|
||||
if (state === undefined) throw new Error("state was not minted");
|
||||
return Promise.resolve(state);
|
||||
};
|
||||
describe("process-capture MCP elicitation grants", () => {
|
||||
it("bounds replay state and prunes entries at the signed-state TTL", async () => {
|
||||
const authority = ceilingOnlyAuthority();
|
||||
let state: ProcessCaptureElicitationState | undefined;
|
||||
const consumedNonces = new Map<string, number>();
|
||||
const elicitation = {
|
||||
stateCodec: {
|
||||
mint: (value: ProcessCaptureElicitationState) => {
|
||||
state = value;
|
||||
return Promise.resolve("signed-state");
|
||||
},
|
||||
verify: () => verifiedState(state),
|
||||
},
|
||||
supported: () => true,
|
||||
now: () => now,
|
||||
consumedNonces,
|
||||
};
|
||||
await authorizeProcessCaptureWithElicitation(
|
||||
authority,
|
||||
request,
|
||||
requestContext(),
|
||||
elicitation,
|
||||
);
|
||||
const future = now + 600000;
|
||||
for (let index = 0; index < 4096; index += 1)
|
||||
consumedNonces.set(`occupied-${String(index)}`, future);
|
||||
const rejectedAtCapacity = await authorizeProcessCaptureWithElicitation(
|
||||
authority,
|
||||
request,
|
||||
requestContext(state, {
|
||||
action: "accept",
|
||||
content: { lifetime: "session" },
|
||||
}),
|
||||
elicitation,
|
||||
);
|
||||
expect(isInputRequiredResult(rejectedAtCapacity)).toBe(false);
|
||||
if (!isInputRequiredResult(rejectedAtCapacity))
|
||||
expect(rejectedAtCapacity.ok).toBe(false);
|
||||
consumedNonces.set("occupied-0", now - 1);
|
||||
const acceptedAfterPrune = await authorizeProcessCaptureWithElicitation(
|
||||
authority,
|
||||
request,
|
||||
requestContext(state, {
|
||||
action: "accept",
|
||||
content: { lifetime: "session" },
|
||||
}),
|
||||
elicitation,
|
||||
);
|
||||
expect(isInputRequiredResult(acceptedAfterPrune)).toBe(false);
|
||||
if (!isInputRequiredResult(acceptedAfterPrune))
|
||||
expect(acceptedAfterPrune.ok).toBe(true);
|
||||
expect(consumedNonces.has("occupied-0")).toBe(false);
|
||||
expect(consumedNonces.size).toBe(4096);
|
||||
});
|
||||
it.each(["result", "throw"] as const)(
|
||||
"revokes an elicited grant when final authorization ends with %s",
|
||||
async (failure) => {
|
||||
const root = await createTestTempDirectory("rea-elicit-rollback-");
|
||||
const scoped = { ...request, roots: [root] };
|
||||
const policy = createPermissionPolicy([
|
||||
{
|
||||
capability: "process_capture",
|
||||
roots: scoped.roots,
|
||||
executables: scoped.executables,
|
||||
environment_names: scoped.environment_names,
|
||||
network: scoped.network,
|
||||
mount: scoped.mount,
|
||||
},
|
||||
]);
|
||||
class FailingFinalAuthority extends PermissionAuthority {
|
||||
private authorizationCount = 0;
|
||||
override async authorize(
|
||||
...arguments_: Parameters<PermissionAuthority["authorize"]>
|
||||
): ReturnType<PermissionAuthority["authorize"]> {
|
||||
this.authorizationCount += 1;
|
||||
const result = await super.authorize(...arguments_);
|
||||
if (failure === "result" && this.authorizationCount === 2)
|
||||
await rm(root, { recursive: true, force: true });
|
||||
if (failure === "throw" && this.authorizationCount === 3)
|
||||
throw new Error("synthetic final authorization rejection");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
const authority = new FailingFinalAuthority(policy);
|
||||
let state: ProcessCaptureElicitationState | undefined;
|
||||
const elicitation = {
|
||||
stateCodec: {
|
||||
mint: (value: ProcessCaptureElicitationState) => {
|
||||
state = value;
|
||||
return Promise.resolve("signed-state");
|
||||
},
|
||||
verify: () => verifiedState(state),
|
||||
},
|
||||
supported: () => true,
|
||||
now: () => now,
|
||||
consumedNonces: new Map<string, number>(),
|
||||
};
|
||||
await authorizeProcessCaptureWithElicitation(
|
||||
authority,
|
||||
scoped,
|
||||
requestContext(),
|
||||
elicitation,
|
||||
);
|
||||
const finalAuthorization = authorizeProcessCaptureWithElicitation(
|
||||
authority,
|
||||
scoped,
|
||||
requestContext(state, {
|
||||
action: "accept",
|
||||
content: { lifetime: "session" },
|
||||
}),
|
||||
elicitation,
|
||||
);
|
||||
if (failure === "throw")
|
||||
await expect(finalAuthorization).rejects.toThrow(
|
||||
"synthetic final authorization rejection",
|
||||
);
|
||||
else {
|
||||
const failed = await finalAuthorization;
|
||||
expect(isInputRequiredResult(failed)).toBe(false);
|
||||
if (!isInputRequiredResult(failed)) expect(failed.ok).toBe(false);
|
||||
await mkdir(root);
|
||||
}
|
||||
const retry = await authority.authorize(scoped, "read", {
|
||||
elicitationSupported: true,
|
||||
});
|
||||
expect(retry.ok).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue