mirror of
https://github.com/morluto/rea.git
synced 2026-11-06 23:06:18 +00:00
* feat(artifacts): select extraction entries and output destination Refs morluto/rea#3490 * fix(artifacts): omit absent selection from execution parameters * fix(artifacts): protect source directories and verify public selection * fix(artifacts): keep optional selection inputs JSON-safe * fix(artifacts): separate reader ownership from selected destinations Validate complete reader inventory independently from collisions among selected output entries. Use occurrence-parent ownership instead of filename prefixes so independently owned PyInstaller entries remain selectable while nested-reader members remain refused. Validate advertised MCP schemas through the shared acceptance helper and add real PyInstaller/ASAR and excluded-entry regressions. * fix(artifacts): advertise absolute extraction destinations Reuse the shared host-absolute local path schema instead of an opaque refinement so SDK-converted JSON Schema preserves the runtime constraint. Extend the advertised-path and MCP-handler regression matrix for extract_artifact.output_root. * fix(contracts): advertise unique extraction occurrence IDs --------- Co-authored-by: gensokyo-dev <gensokyo-dev@users.noreply.github.com>
205 lines
7.0 KiB
TypeScript
205 lines
7.0 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { lstat, readFile, readdir, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { promisify } from "node:util";
|
|
|
|
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
|
|
import { Client } from "@modelcontextprotocol/client";
|
|
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
|
import { expect, it } from "vitest";
|
|
|
|
import { artifactMcpCall } from "../../scripts/lib/artifact-e2e.mjs";
|
|
import {
|
|
artifactExtractionResultSchema,
|
|
artifactInventoryResultSchema,
|
|
} from "../../src/domain/artifactGraph.js";
|
|
import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js";
|
|
import { parseEvidence } from "../../src/domain/evidence.js";
|
|
import { advertisedMcpToolSchemas } from "../fixtures/advertisedMcpTool.js";
|
|
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
|
|
|
const execute = promisify(execFile);
|
|
const cli = fileURLToPath(new URL("../../scripts/rea.mjs", import.meta.url));
|
|
|
|
const setup = async () => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-public-extraction-selection-",
|
|
);
|
|
const path = join(root, "fixture.zip");
|
|
const writer = new ZipWriter(new Uint8ArrayWriter());
|
|
await writer.add("lib/selected.so", new TextReader("native fixture"));
|
|
await writer.add("lib/other.so", new TextReader("other architecture"));
|
|
await writer.add("res/2f.xml", new TextReader("lower"));
|
|
await writer.add("res/2F.xml", new TextReader("upper"));
|
|
const bytes = await writer.close();
|
|
await writeFile(path, bytes);
|
|
const environment = {
|
|
PATH: process.env.PATH ?? "",
|
|
HOME: root,
|
|
USERPROFILE: root,
|
|
REA_LOG_LEVEL: "silent",
|
|
REA_ANALYSIS_PROVIDER: "auto",
|
|
};
|
|
const run = async (command: string, flags: readonly string[] = []) => {
|
|
const { stdout } = await execute(
|
|
process.execPath,
|
|
[cli, command, path, ...flags, "--json"],
|
|
{
|
|
env: environment,
|
|
timeout: 30000,
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
},
|
|
);
|
|
return parseEvidence(JSON.parse(stdout));
|
|
};
|
|
const inspected = artifactInspectionResultSchema.parse(
|
|
(await run("inspect-artifact")).normalized_result,
|
|
);
|
|
const inventoryEvidence = inspected.substeps.find(
|
|
({ evidence }) => evidence.operation === "inventory_artifact",
|
|
)?.evidence;
|
|
if (inventoryEvidence === undefined)
|
|
throw new Error("Missing inventory Evidence");
|
|
const inventory = artifactInventoryResultSchema.parse(
|
|
inventoryEvidence.normalized_result,
|
|
);
|
|
const occurrence = (path: string) => {
|
|
const entry = inventory.occurrences.find(
|
|
({ logical_path }) => logical_path === path,
|
|
);
|
|
if (entry === undefined) throw new Error(`Missing occurrence: ${path}`);
|
|
return entry.occurrence_id;
|
|
};
|
|
return { root, path, bytes, environment, run, inventory, occurrence };
|
|
};
|
|
|
|
it("extracts repeatable CLI selections into the exact requested destination", async () => {
|
|
const data = await setup();
|
|
const ids = [
|
|
data.occurrence("lib/selected.so"),
|
|
data.occurrence("lib/other.so"),
|
|
];
|
|
const output = join(data.root, "chosen-output");
|
|
const evidence = await data.run("extract-artifact", [
|
|
"--output-root",
|
|
output,
|
|
...ids.flatMap((id) => ["--occurrence-id", id]),
|
|
]);
|
|
const result = artifactExtractionResultSchema.parse(
|
|
evidence.normalized_result,
|
|
);
|
|
expect(result.output_root).toBe(output);
|
|
expect(result.artifacts.map(({ relative_path }) => relative_path)).toEqual([
|
|
"lib/other.so",
|
|
"lib/selected.so",
|
|
]);
|
|
expect(result.extraction_manifest.selected_occurrence_ids).toEqual(
|
|
[...ids].sort(),
|
|
);
|
|
expect(result.manifest).toEqual(data.inventory.manifest);
|
|
expect(await readdir(output)).toEqual(["lib"]);
|
|
expect(await readFile(join(output, "lib/selected.so"), "utf8")).toBe(
|
|
"native fixture",
|
|
);
|
|
});
|
|
|
|
it("keeps CLI/MCP selection identity, invalid-input refusals and committed output after close", async () => {
|
|
const data = await setup();
|
|
const id = data.occurrence("lib/selected.so");
|
|
const cliResult = artifactExtractionResultSchema.parse(
|
|
(
|
|
await data.run("extract-artifact", [
|
|
"--output-root",
|
|
join(data.root, "cli-output"),
|
|
"--occurrence-id",
|
|
id,
|
|
])
|
|
).normalized_result,
|
|
);
|
|
const transport = new StdioClientTransport({
|
|
command: process.execPath,
|
|
args: [cli, "mcp"],
|
|
env: {
|
|
...data.environment,
|
|
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
|
|
REA_MCP_TOOL_FAMILIES: "artifact,session",
|
|
},
|
|
stderr: "pipe",
|
|
});
|
|
const client = new Client({
|
|
name: "extraction-selection-test",
|
|
version: "1",
|
|
});
|
|
const output = join(data.root, "mcp-output");
|
|
try {
|
|
await client.connect(transport);
|
|
const opened = await artifactMcpCall(client, {
|
|
name: "open_binary",
|
|
arguments: { path: data.path },
|
|
});
|
|
expect(opened.isError).not.toBe(true);
|
|
const schemas = await advertisedMcpToolSchemas(client, "extract_artifact");
|
|
for (const occurrence_ids of [[id], [id, data.occurrence("lib/other.so")]])
|
|
expect(
|
|
schemas.ajv.validate(schemas.inputSchema, {
|
|
output_root: output,
|
|
occurrence_ids,
|
|
}),
|
|
JSON.stringify(schemas.ajv.errors),
|
|
).toBe(true);
|
|
expect(
|
|
schemas.ajv.validate(schemas.inputSchema, {
|
|
output_root: output,
|
|
occurrence_ids: [id, id],
|
|
}),
|
|
).toBe(false);
|
|
expect(schemas.ajv.errors).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ keyword: "uniqueItems" }),
|
|
]),
|
|
);
|
|
const reply = await artifactMcpCall(client, {
|
|
name: "extract_artifact",
|
|
arguments: { output_root: output, occurrence_ids: [id] },
|
|
});
|
|
expect(reply.isError).not.toBe(true);
|
|
const evidence = parseEvidence(reply.structuredContent);
|
|
const result = artifactExtractionResultSchema.parse(
|
|
evidence.normalized_result,
|
|
);
|
|
expect(result.output_root).toBe(output);
|
|
expect(result.extraction_manifest).toEqual(cliResult.extraction_manifest);
|
|
expect(result.artifacts).toEqual(cliResult.artifacts);
|
|
for (const [index, occurrence_ids] of [
|
|
[`occ_${"0".repeat(64)}`],
|
|
[id, id],
|
|
].entries()) {
|
|
const invalidOutput = join(data.root, `invalid-output-${index}`);
|
|
const invalid = await artifactMcpCall(client, {
|
|
name: "extract_artifact",
|
|
arguments: { output_root: invalidOutput, occurrence_ids },
|
|
});
|
|
expect(invalid.isError).toBe(true);
|
|
await expect(lstat(invalidOutput)).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
}
|
|
const closed = await artifactMcpCall(client, {
|
|
name: "close_binary",
|
|
arguments: {},
|
|
});
|
|
expect(closed.isError).not.toBe(true);
|
|
} finally {
|
|
await client.close();
|
|
await transport.close();
|
|
}
|
|
expect(await readFile(join(output, "lib/selected.so"), "utf8")).toBe(
|
|
"native fixture",
|
|
);
|
|
const digest = (bytes: Uint8Array) =>
|
|
createHash("sha256").update(bytes).digest("hex");
|
|
expect(digest(await readFile(data.path))).toBe(digest(data.bytes));
|
|
});
|