mirror of
https://github.com/morluto/rea.git
synced 2026-11-03 13:26:10 +00:00
chore: refresh HTTP follow-up with latest main
This commit is contained in:
commit
d82c73932a
11 files changed
+496
-65
No files matched your search
@@ -23,7 +23,9 @@ rea inspect-dart-aot /targets/Example.apk --abi armeabi-v7a
|
||||
The equivalent MCP methods are `identify_flutter_build` (with `path`) and
|
||||
`inspect_dart_aot` (with `path` and an optional `abi`; the default prefers
|
||||
arm64-v8a, then x86_64, then x86, then armeabi-v7a, then the remaining
|
||||
ABIs alphabetically).
|
||||
ABIs alphabetically). An explicit `abi` is the directory label exactly as
|
||||
`identify_flutter_build` reports it, so a payload under a nonstandard label
|
||||
stays selectable and keeps that label in the result.
|
||||
|
||||
## Optional retained AOT delivery
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import { executeProviderOperation } from "./ProviderOperationExecution.js";
|
||||
|
||||
it("projects request-schema issues without exposing the request envelope", async () => {
|
||||
const execute = vi.fn();
|
||||
const result = await executeProviderOperation(
|
||||
"list_jeb_units",
|
||||
{ parent_unit_path: "" },
|
||||
undefined,
|
||||
z.strictObject({
|
||||
operation: z.literal("list_jeb_units"),
|
||||
input: z.strictObject({ parent_unit_path: z.string().min(1) }),
|
||||
}),
|
||||
{ execute },
|
||||
"observed",
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok || !(result.error instanceof AnalysisInputError))
|
||||
throw new Error("Expected input error");
|
||||
expect(result.error.issues).toEqual([
|
||||
{
|
||||
path: ["parent_unit_path"],
|
||||
reason: "out_of_range",
|
||||
minimum: 1,
|
||||
},
|
||||
]);
|
||||
expect(result.error.cause).toBeInstanceOf(z.ZodError);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes only the owned envelope prefix while preserving nested caller paths", async () => {
|
||||
const execute = vi.fn();
|
||||
const result = await executeProviderOperation(
|
||||
"inspect",
|
||||
{ input: "", rows: [{}], mode: "foreign", extra: "caller-only-marker" },
|
||||
undefined,
|
||||
z.strictObject({
|
||||
operation: z.literal("inspect"),
|
||||
input: z.strictObject({
|
||||
input: z.string().min(1),
|
||||
rows: z.array(z.strictObject({ name: z.string() })),
|
||||
mode: z.enum(["first", "second"]),
|
||||
}),
|
||||
}),
|
||||
{ execute },
|
||||
"observed",
|
||||
);
|
||||
if (result.ok || !(result.error instanceof AnalysisInputError))
|
||||
throw new Error("Expected input error");
|
||||
expect(result.error.issues).toEqual([
|
||||
{ path: ["input"], reason: "out_of_range", minimum: 1 },
|
||||
{
|
||||
path: ["rows", 0, "name"],
|
||||
reason: "missing_argument",
|
||||
expected: "string",
|
||||
},
|
||||
{ path: ["mode"], reason: "invalid_value", expected: ["first", "second"] },
|
||||
{ path: ["extra"], reason: "unknown_argument" },
|
||||
]);
|
||||
expect(JSON.stringify(result.error.issues)).not.toContain(
|
||||
"caller-only-marker",
|
||||
);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves operation correction outside the input envelope", async () => {
|
||||
const execute = vi.fn();
|
||||
const result = await executeProviderOperation(
|
||||
"unknown",
|
||||
{},
|
||||
undefined,
|
||||
z.strictObject({
|
||||
operation: z.literal("known"),
|
||||
input: z.strictObject({}),
|
||||
}),
|
||||
{ execute },
|
||||
"observed",
|
||||
);
|
||||
if (result.ok || !(result.error instanceof AnalysisInputError))
|
||||
throw new Error("Expected input error");
|
||||
expect(result.error.issues).toEqual([
|
||||
{ path: ["operation"], reason: "invalid_value", expected: ["known"] },
|
||||
]);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
AnalysisInputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import { projectInputIssues } from "../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
createAnalysisExecutionEvidence,
|
||||
@@ -52,7 +53,18 @@ export const executeProviderOperation = async <
|
||||
);
|
||||
const request = schema.safeParse({ operation, input });
|
||||
if (!request.success)
|
||||
return err(new AnalysisInputError(operation, { cause: request.error }));
|
||||
return err(
|
||||
new AnalysisInputError(
|
||||
operation,
|
||||
{ cause: request.error },
|
||||
projectInputIssues(request.error.issues, { operation, input }).map(
|
||||
(issue) =>
|
||||
issue.path[0] === "input"
|
||||
? { ...issue, path: issue.path.slice(1) }
|
||||
: issue,
|
||||
),
|
||||
),
|
||||
);
|
||||
const executed = await provider.execute(request.data, options);
|
||||
if (!executed.ok) return executed;
|
||||
const evidence = createAnalysisExecutionEvidence(
|
||||
|
||||
@@ -52,10 +52,17 @@ describe("JebAnalysisService admission", () => {
|
||||
const result = await service.execute("list_jeb_units", {
|
||||
count: 101,
|
||||
index: -1,
|
||||
parent_unit_path: "",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error).toBeInstanceOf(AnalysisInputError);
|
||||
if (!(result.error instanceof AnalysisInputError)) throw result.error;
|
||||
expect(result.error.issues).toContainEqual({
|
||||
path: ["parent_unit_path"],
|
||||
reason: "out_of_range",
|
||||
minimum: 1,
|
||||
});
|
||||
expect(requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import type { FlutterBuildAnalysisPort } from "../application/flutter/FlutterBui
|
||||
import { createFlutterBuildAnalysisProvider } from "../composition/flutter.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import type { FlutterOperation } from "../domain/flutter/flutterBuildAnalysis.js";
|
||||
import {
|
||||
flutterInputSchemas,
|
||||
type FlutterOperation,
|
||||
} from "../domain/flutter/flutterBuildAnalysis.js";
|
||||
import type { Logger } from "pino";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
@@ -35,7 +38,7 @@ export const registerFlutterCommands = (
|
||||
description:
|
||||
"Identify a Flutter build's Dart snapshot hash and engine facts by pure APK parsing",
|
||||
argumentSchema: z.object({
|
||||
path: z.string().min(1).describe("Local APK file to inspect"),
|
||||
path: flutterInputSchemas.identify_flutter_build.shape.path,
|
||||
}),
|
||||
operation: "identify_flutter_build",
|
||||
toInput: (arguments_) => arguments_,
|
||||
@@ -45,18 +48,10 @@ export const registerFlutterCommands = (
|
||||
description:
|
||||
"Inspect one ABI's Dart AOT snapshot: sections, headers, and the string pool",
|
||||
argumentSchema: z.object({
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Local APK carrying a Flutter payload"),
|
||||
path: flutterInputSchemas.inspect_dart_aot.shape.path,
|
||||
}),
|
||||
optionSchema: z.object({
|
||||
abi: z
|
||||
.string()
|
||||
.min(2)
|
||||
.max(16)
|
||||
.optional()
|
||||
.describe("ABI under lib/ to inspect; defaults to the preferred one"),
|
||||
abi: flutterInputSchemas.inspect_dart_aot.shape.abi,
|
||||
}),
|
||||
operation: "inspect_dart_aot",
|
||||
toInput: (arguments_, options) => ({
|
||||
|
||||
+9
-38
@@ -9,6 +9,7 @@ import type { Logger } from "pino";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
|
||||
import { jebInputSchemas } from "../domain/jeb/jebAnalysis.js";
|
||||
|
||||
/** Expose the same explicit JEB requests through one-shot CLI commands. */
|
||||
export const registerJebCommands = (
|
||||
@@ -40,9 +41,7 @@ export const registerJebCommands = (
|
||||
description:
|
||||
"Open or create a JEB project from an artifact or .jdb2 database",
|
||||
args: z.object({
|
||||
path: z
|
||||
.string()
|
||||
.describe("Artifact or .jdb2 path resolved by the JEB engine host"),
|
||||
path: jebInputSchemas.open_jeb_project.shape.path,
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
execute(CLI_COMMANDS.openJebProject, "open_jeb_project", args),
|
||||
@@ -51,27 +50,10 @@ export const registerJebCommands = (
|
||||
description: "List JEB project units by path and type with optional filter",
|
||||
args: z.object({}),
|
||||
options: z.object({
|
||||
filter: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Wildcard filter matched against unit paths"),
|
||||
"parent-unit-path": z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Restrict results to descendants of this unit path"),
|
||||
index: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.default(0)
|
||||
.describe("Zero-based index of the first result to return"),
|
||||
count: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.default(100)
|
||||
.describe("Maximum results; the engine caps unit pages at 100"),
|
||||
filter: jebInputSchemas.list_jeb_units.shape.filter,
|
||||
"parent-unit-path": jebInputSchemas.list_jeb_units.shape.parent_unit_path,
|
||||
index: jebInputSchemas.list_jeb_units.shape.index,
|
||||
count: jebInputSchemas.list_jeb_units.shape.count,
|
||||
}),
|
||||
run: ({ options }) =>
|
||||
execute(CLI_COMMANDS.listJebUnits, "list_jeb_units", {
|
||||
@@ -86,22 +68,11 @@ export const registerJebCommands = (
|
||||
cli.command(CLI_COMMANDS.decompileJebItem, {
|
||||
description: "Decompile one JEB type or method to engine pseudo-code",
|
||||
args: z.object({
|
||||
"item-address": z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Type or method address within the unit"),
|
||||
"item-kind": z
|
||||
.enum(["type", "method"])
|
||||
.describe("Kind of item to decompile"),
|
||||
"item-address": jebInputSchemas.decompile_jeb_item.shape.item_address,
|
||||
"item-kind": jebInputSchemas.decompile_jeb_item.shape.item_kind,
|
||||
}),
|
||||
options: z.object({
|
||||
"unit-path": z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"Target code unit; the engine's first code unit when omitted",
|
||||
),
|
||||
"unit-path": jebInputSchemas.decompile_jeb_item.shape.unit_path,
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
execute(CLI_COMMANDS.decompileJebItem, "decompile_jeb_item", {
|
||||
|
||||
@@ -2,6 +2,33 @@ import { z } from "zod";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
import { digestSchema } from "../digests.js";
|
||||
|
||||
/** Full logical ZIP path budget, enforced by Flutter library inventory. */
|
||||
export const MAX_FLUTTER_LIBRARY_PATH_BYTES = 1024;
|
||||
|
||||
/**
|
||||
* A libapp label can occupy every remaining byte of an admitted path. Its
|
||||
* Unicode character count cannot exceed that byte count. The query permits
|
||||
* unmatched labels; inventory owns the exact UTF-8 byte check on real paths.
|
||||
*/
|
||||
const ABI_SELECTOR_MAX_CHARACTERS =
|
||||
MAX_FLUTTER_LIBRARY_PATH_BYTES - "lib/".length - "/libapp.so".length;
|
||||
|
||||
/**
|
||||
* ZIP labels are literal names, including backslashes and controls. They are
|
||||
* never used as extraction paths. A Unicode regex gives runtime validation
|
||||
* and JSON Schema the same character unit; Zod max() counts UTF-16 units.
|
||||
* The final lookahead requires the actual end, including a trailing newline.
|
||||
*/
|
||||
const abiSelectorSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
new RegExp(
|
||||
`^[^/]{1,${String(ABI_SELECTOR_MAX_CHARACTERS)}}(?![\\s\\S])`,
|
||||
"u",
|
||||
),
|
||||
`ABI must be the exact lib/ directory label: 1–${String(ABI_SELECTOR_MAX_CHARACTERS)} Unicode characters without '/'.`,
|
||||
);
|
||||
|
||||
/**
|
||||
* Caller intent for Flutter build identification. This family is pure
|
||||
* parsing: it reads an APK's `lib/<abi>/libapp.so` and `libflutter.so`
|
||||
@@ -15,17 +42,10 @@ export const flutterInputSchemas = {
|
||||
path: localPathStringSchema.describe(
|
||||
"Local APK file carrying a Flutter AOT payload",
|
||||
),
|
||||
abi: z
|
||||
.string()
|
||||
.min(2)
|
||||
.max(16)
|
||||
.regex(
|
||||
/^[a-z0-9_\x2d]+$/u,
|
||||
"ABI name as it appears under lib/, e.g. arm64-v8a",
|
||||
)
|
||||
abi: abiSelectorSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"ABI to inspect; defaults to arm64-v8a, x86_64, x86, armeabi-v7a, then remaining ABIs alphabetically",
|
||||
"Exact lib/ directory label reported by identify_flutter_build; defaults to arm64-v8a, x86_64, x86, armeabi-v7a, then remaining ABIs alphabetically",
|
||||
),
|
||||
}),
|
||||
} as const;
|
||||
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
createAnalysisExecutionEvidence,
|
||||
type AnalysisExecution,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import type { FlutterRequest } from "../domain/flutter/flutterBuildAnalysis.js";
|
||||
import {
|
||||
MAX_FLUTTER_LIBRARY_PATH_BYTES,
|
||||
type FlutterRequest,
|
||||
} from "../domain/flutter/flutterBuildAnalysis.js";
|
||||
import {
|
||||
AnalysisAccessDeniedError,
|
||||
AnalysisArtifactChangedError,
|
||||
@@ -71,7 +74,6 @@ const APK_MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
// engine labels this keeps expanded result metadata below 512 KiB for ordinary
|
||||
// ABI names, independently of the archive's compressed-byte budget.
|
||||
const MAX_FLUTTER_ABIS = 32;
|
||||
const MAX_LIBRARY_PATH_BYTES = 1024;
|
||||
const MAX_POOL_URIS = 500;
|
||||
const MAX_POOL_TOKENS = 2_000;
|
||||
/** A complete AOT image carries exactly one VM and one isolate section. */
|
||||
@@ -284,7 +286,7 @@ const collectFlutterLibraries = async (
|
||||
abis.add(match[1]!);
|
||||
if (
|
||||
abis.size > MAX_FLUTTER_ABIS ||
|
||||
Buffer.byteLength(entry.path) > MAX_LIBRARY_PATH_BYTES
|
||||
Buffer.byteLength(entry.path) > MAX_FLUTTER_LIBRARY_PATH_BYTES
|
||||
)
|
||||
throw new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
@@ -292,7 +294,7 @@ const collectFlutterLibraries = async (
|
||||
"Flutter library inventory exceeds the bounded ABI projection budget",
|
||||
{
|
||||
max_flutter_abis: MAX_FLUTTER_ABIS,
|
||||
max_library_path_bytes: MAX_LIBRARY_PATH_BYTES,
|
||||
max_library_path_bytes: MAX_FLUTTER_LIBRARY_PATH_BYTES,
|
||||
},
|
||||
);
|
||||
libraries.push({
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { rm } from "node:fs/promises";
|
||||
import { Cli } from "incur";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { Ajv } from "ajv";
|
||||
import type { AnySchema } from "ajv";
|
||||
|
||||
import { registerFlutterCommands } from "../../../src/cli/flutterCommands.js";
|
||||
import { aotFixtureImage } from "../../../src/flutter/FlutterBuild.fixture.js";
|
||||
import { FlutterBuildProvider } from "../../../src/flutter/FlutterBuildProvider.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { flutterInputSchemas } from "../../../src/domain/flutter/flutterBuildAnalysis.js";
|
||||
import { createFlutterApk } from "../../fixtures/flutterApk.js";
|
||||
import { serveCli } from "./cliServeHarness.js";
|
||||
|
||||
@@ -27,6 +30,94 @@ it("reports an unreadable target with an input refusal", async () => {
|
||||
expect(result.stdout).toContain("invalid_request");
|
||||
});
|
||||
|
||||
it.each(["identify-flutter-build", "inspect-dart-aot"])(
|
||||
"%s uses canonical local-path validation before provider admission",
|
||||
async (command) => {
|
||||
const result = await serveCli([command, "fixture\u0000.apk", "--json"]);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
code: "VALIDATION_ERROR",
|
||||
fieldErrors: [
|
||||
expect.objectContaining({
|
||||
path: "path",
|
||||
message: expect.stringContaining("NUL"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("advertises and validates one ABI selector grammar with field correction guidance", async () => {
|
||||
const advertised = await serveCli(["inspect-dart-aot", "--schema", "--json"]);
|
||||
expect(advertised.exitCode).toBe(0);
|
||||
const schema = JSON.parse(advertised.stdout) as { options: AnySchema };
|
||||
const ajv = new Ajv({ strict: false });
|
||||
expect(ajv.validateSchema(schema.options)).toBe(true);
|
||||
const accepts = ajv.compile(schema.options);
|
||||
const abiSchema = flutterInputSchemas.inspect_dart_aot.shape.abi;
|
||||
for (const abi of [
|
||||
"ARM64-V8A",
|
||||
"ARM64",
|
||||
"arm64.v8a",
|
||||
"A",
|
||||
"a",
|
||||
"abi\\label",
|
||||
"abi\nlabel",
|
||||
"l".repeat(1010),
|
||||
"😀".repeat(1010),
|
||||
]) {
|
||||
expect(accepts({ abi })).toBe(true);
|
||||
expect(abiSchema.safeParse(abi).success).toBe(true);
|
||||
}
|
||||
for (const abi of [
|
||||
"",
|
||||
"lib/x86_64",
|
||||
"l".repeat(1011),
|
||||
"😀".repeat(1011),
|
||||
"l".repeat(1010) + "\n",
|
||||
]) {
|
||||
expect(accepts({ abi })).toBe(false);
|
||||
expect(abiSchema.safeParse(abi).success).toBe(false);
|
||||
const rejected = await serveCli([
|
||||
"inspect-dart-aot",
|
||||
"/nonexistent/app.apk",
|
||||
"--abi",
|
||||
abi,
|
||||
"--json",
|
||||
]);
|
||||
expect(JSON.parse(rejected.stdout)).toMatchObject({
|
||||
code: "VALIDATION_ERROR",
|
||||
fieldErrors: [
|
||||
expect.objectContaining({
|
||||
path: "abi",
|
||||
message: expect.stringContaining("ABI"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("inspects the caller-selected nonstandard ABI through the public CLI", async () => {
|
||||
const path = await createFlutterApk("selector.apk", [
|
||||
{ path: "lib/ARM64-V8A/libapp.so", bytes: aotFixtureImage("a".repeat(32)) },
|
||||
{ path: "lib/x86_64/libapp.so", bytes: aotFixtureImage("b".repeat(32)) },
|
||||
]);
|
||||
const selected = await serveCli([
|
||||
"inspect-dart-aot",
|
||||
path,
|
||||
"--abi",
|
||||
"ARM64-V8A",
|
||||
"--json",
|
||||
]);
|
||||
expect(selected.exitCode).toBe(0);
|
||||
expect(JSON.parse(selected.stdout)).toMatchObject({
|
||||
parameters: { path, abi: "ARM64-V8A" },
|
||||
normalized_result: {
|
||||
abi: "ARM64-V8A",
|
||||
libapp: { snapshot_hash: "a".repeat(32), coverage: "complete" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/** Serve one command whose first snapshot-root removal fails and is retried on close. */
|
||||
const serveWithCleanupRetry = async (
|
||||
command: string,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Cli } from "incur";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import type { JebAnalysisPort } from "../../../src/application/jeb/JebAnalysisPort.js";
|
||||
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
|
||||
import { registerJebCommands } from "../../../src/cli/jebCommands.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import type { JebRequest } from "../../../src/domain/jeb/jebAnalysis.js";
|
||||
import { jebResultSchemas } from "../../../src/domain/jeb/jebAnalysis.js";
|
||||
|
||||
const invoke = async (argv: readonly string[]) => {
|
||||
const requests: JebRequest[] = [];
|
||||
const provider: JebAnalysisPort = {
|
||||
async inspectAvailability() {
|
||||
throw new Error("unexpected provider probe");
|
||||
},
|
||||
async close() {},
|
||||
async execute(request) {
|
||||
requests.push(request);
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
jebResultSchemas.list_jeb_units.parse({
|
||||
engine: {
|
||||
name: "JEB test provider",
|
||||
version: null,
|
||||
endpoint: "http://127.0.0.1:1/mcp",
|
||||
gui_client: null,
|
||||
},
|
||||
filter: null,
|
||||
parent_unit_path:
|
||||
request.operation === "list_jeb_units"
|
||||
? (request.input.parent_unit_path ?? null)
|
||||
: null,
|
||||
index: 0,
|
||||
count: 100,
|
||||
units: [],
|
||||
coverage: "complete",
|
||||
}),
|
||||
{ id: "jeb-test", name: "JEB test provider", version: null },
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
const cli = Cli.create("rea-jeb-input-test", { sync: false });
|
||||
registerJebCommands(cli, silentLogger, {}, provider);
|
||||
let stdout = "";
|
||||
let exitCode = 0;
|
||||
await cli.serve([...argv], {
|
||||
env: {},
|
||||
exit: (code) => {
|
||||
exitCode = code;
|
||||
},
|
||||
stdout: (text) => {
|
||||
stdout += text;
|
||||
},
|
||||
});
|
||||
return {
|
||||
exitCode,
|
||||
output: JSON.parse(stdout) as Record<string, unknown>,
|
||||
requests,
|
||||
};
|
||||
};
|
||||
|
||||
it("keeps JEB CLI correction details aligned with canonical list-unit validation", async () => {
|
||||
const invalid = await invoke([
|
||||
"list-jeb-units",
|
||||
"--parent-unit-path",
|
||||
"",
|
||||
"--json",
|
||||
]);
|
||||
expect(invalid.output).toMatchObject({
|
||||
code: "VALIDATION_ERROR",
|
||||
fieldErrors: [{ path: "parent-unit-path", code: "too_small" }],
|
||||
});
|
||||
expect(invalid.requests).toEqual([]);
|
||||
|
||||
const valid = await invoke([
|
||||
"list-jeb-units",
|
||||
"--parent-unit-path",
|
||||
"sample.dex",
|
||||
"--json",
|
||||
]);
|
||||
expect(valid.output).not.toHaveProperty("code");
|
||||
expect(valid.requests).toMatchObject([
|
||||
{ input: { parent_unit_path: "sample.dex" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("advertises the canonical minimum for a JEB parent unit path", async () => {
|
||||
const { output } = await invoke(["list-jeb-units", "--schema", "--json"]);
|
||||
const schema = output.options as Parameters<Ajv2020["compile"]>[0];
|
||||
const validate = new Ajv2020({ strict: false }).compile(schema);
|
||||
expect(validate({ index: 0, count: 100 })).toBe(true);
|
||||
expect(validate({ index: 0, count: 100, "parent-unit-path": "" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
validate({ index: 0, count: 100, "parent-unit-path": "sample.dex" }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("advertises the canonical nonempty, NUL-free JEB project path", async () => {
|
||||
const { output } = await invoke(["open-jeb-project", "--schema", "--json"]);
|
||||
const schema = output.args as Parameters<Ajv2020["compile"]>[0];
|
||||
const validate = new Ajv2020({ strict: false }).compile(schema);
|
||||
expect(validate({ path: "sample.apk" })).toBe(true);
|
||||
expect(validate({ path: "" })).toBe(false);
|
||||
expect(validate({ path: "sample\u0000.apk" })).toBe(false);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
|
||||
import type { FlutterBuildAnalysisPort } from "../../../src/application/flutter/FlutterBuildAnalysisPort.js";
|
||||
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
|
||||
@@ -8,6 +9,91 @@ import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import { createFlutterBuildAnalysisProvider } from "../../../src/composition/flutter.js";
|
||||
import { flutterResultSchemas } from "../../../src/domain/flutter/flutterBuildAnalysis.js";
|
||||
import { aotFixtureImage } from "../../../src/flutter/FlutterBuild.fixture.js";
|
||||
import { createFlutterApk } from "../../fixtures/flutterApk.js";
|
||||
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
|
||||
|
||||
it.each([
|
||||
{ label: "ARM64-V8A", paired: false },
|
||||
...[
|
||||
"ARM64-V8A",
|
||||
"arm64.v8a",
|
||||
"A",
|
||||
".",
|
||||
"..",
|
||||
"abi\\label",
|
||||
"abi\u0000label",
|
||||
"abi\nlabel",
|
||||
"abi\u0085label",
|
||||
"l".repeat(336),
|
||||
"l".repeat(1010),
|
||||
"界".repeat(336),
|
||||
"😀".repeat(252),
|
||||
].map((label) => ({ label, paired: true })),
|
||||
])(
|
||||
"selects the exact reported archive ABI $label (paired=$paired)",
|
||||
async ({ label, paired }) => {
|
||||
const path = await createFlutterApk("reported-label.apk", [
|
||||
{
|
||||
path: `lib/${label}/libapp.so`,
|
||||
bytes: aotFixtureImage("a".repeat(32)),
|
||||
},
|
||||
...(paired
|
||||
? [
|
||||
{
|
||||
path: "lib/x86_64/libapp.so",
|
||||
bytes: aotFixtureImage("b".repeat(32)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
const provider = createFlutterBuildAnalysisProvider();
|
||||
onTestFinished(() => provider.close());
|
||||
const harness = await createApplicationMcpHarness({
|
||||
serverOptions: { flutterAnalysis: provider },
|
||||
});
|
||||
onTestFinished(harness.close);
|
||||
const call = (name: string, abi?: string) =>
|
||||
harness.client.callTool({
|
||||
name,
|
||||
arguments: { path, ...(abi === undefined ? {} : { abi }) },
|
||||
});
|
||||
const inventoryReply = await call("identify_flutter_build");
|
||||
expect(inventoryReply.isError).not.toBe(true);
|
||||
const inventory = flutterResultSchemas.identify_flutter_build.parse(
|
||||
parseEvidence(inventoryReply.structuredContent).normalized_result,
|
||||
);
|
||||
expect(inventory.abis.map((row) => row.abi)).toContain(label);
|
||||
const defaultReply = await call("inspect_dart_aot");
|
||||
expect(defaultReply.isError).not.toBe(true);
|
||||
const defaultFacts = flutterResultSchemas.inspect_dart_aot.parse(
|
||||
parseEvidence(defaultReply.structuredContent).normalized_result,
|
||||
);
|
||||
expect(defaultFacts.abi).toBe(paired ? "x86_64" : label);
|
||||
expect(defaultFacts.libapp.snapshot_hash).toBe(
|
||||
(paired ? "b" : "a").repeat(32),
|
||||
);
|
||||
const selectedReply = await call("inspect_dart_aot", label);
|
||||
expect(selectedReply.isError).not.toBe(true);
|
||||
const evidence = parseEvidence(selectedReply.structuredContent);
|
||||
const selected = flutterResultSchemas.inspect_dart_aot.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
expect(selected.abi).toBe(label);
|
||||
expect(selected.libapp.snapshot_hash).toBe("a".repeat(32));
|
||||
expect(selected.libapp.coverage).toBe("complete");
|
||||
expect(evidence.parameters).toEqual({ path, abi: label });
|
||||
const tool = (await harness.client.listTools()).tools.find(
|
||||
(tool) => tool.name === "inspect_dart_aot",
|
||||
);
|
||||
if (tool === undefined) throw new Error("Missing AOT tool");
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
expect(ajv.validateSchema(tool.inputSchema)).toBe(true);
|
||||
expect(ajv.validate(tool.inputSchema, { path, abi: label })).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
const identity = {
|
||||
id: "flutter",
|
||||
@@ -122,6 +208,51 @@ const fakeProvider = (): FlutterBuildAnalysisPort => ({
|
||||
},
|
||||
});
|
||||
|
||||
it("advertises and enforces the same Unicode selector grammar through MCP", async () => {
|
||||
const harness = await createApplicationMcpHarness({
|
||||
serverOptions: { flutterAnalysis: fakeProvider() },
|
||||
});
|
||||
onTestFinished(harness.close);
|
||||
const tool = (await harness.client.listTools()).tools.find(
|
||||
(tool) => tool.name === "inspect_dart_aot",
|
||||
);
|
||||
if (tool === undefined) throw new Error("Missing AOT tool");
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
expect(ajv.validateSchema(tool.inputSchema)).toBe(true);
|
||||
for (const abi of ["😀".repeat(1010), "l".repeat(1010)]) {
|
||||
const arguments_ = { path: "/fixture.apk", abi };
|
||||
expect(ajv.validate(tool.inputSchema, arguments_)).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await harness.client.callTool({
|
||||
name: "inspect_dart_aot",
|
||||
arguments: arguments_,
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
}
|
||||
for (const abi of [
|
||||
"",
|
||||
"lib/x86_64",
|
||||
"😀".repeat(1011),
|
||||
"l".repeat(1010) + "\n",
|
||||
]) {
|
||||
const arguments_ = { path: "/fixture.apk", abi };
|
||||
expect(ajv.validate(tool.inputSchema, arguments_)).toBe(false);
|
||||
const refused = await harness.client.callTool({
|
||||
name: "inspect_dart_aot",
|
||||
arguments: arguments_,
|
||||
});
|
||||
expect(refused.isError).toBe(true);
|
||||
expect(refused.content).toContainEqual({
|
||||
type: "text",
|
||||
text: expect.stringContaining(
|
||||
"abi: ABI must be the exact lib/ directory label",
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("advertises an exact valid Flutter schema and records inline evidence through MCP", async () => {
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("deep provider must not start");
|
||||
|
||||
Reference in new issue
Block a user