fix(flutter): select reported ABI labels through shared contracts

Reuse canonical CLI fields and preserve literal archive labels through exact selection. Keep UTF-8 path budgets at inventory while advertising the same bounded Unicode selector that runtime admission enforces.

Co-authored-by: Yi-111-a <153097222+Yi-111-a@users.noreply.github.com>
This commit is contained in:
morlutoandYi-111-a committed 2026-10-19 18:38:57 +08:00
1 parent 69aa5465cd
commit 64abd4aefb
6 files changed
+267 -26

No files matched your search

+3 -1
View File
@@ -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
+7 -12
View File
@@ -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) => ({
+29 -9
View File
@@ -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;
+6 -4
View File
@@ -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({
+91
View File
@@ -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,
+131
View File
@@ -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");