fix(cli): preserve canonical input bounds and required choices

This commit is contained in:
morluto
2026-10-24 02:24:40 +08:00
parent 049bc2d055
commit 3f49545320
10 changed files with 129 additions and 58 deletions
+14
View File
@@ -242,6 +242,20 @@ which some clients require even alongside `prefixItems`. Tuples with a rest
schema also need explicit minimum lengths. Check parser/schema parity for empty,
short, overlong, and malformed values in both input and output projections.
CLI translations must reuse the canonical semantic fields, enum membership and
list bounds. Document transport differences such as relative path spelling and
JSON text decoding; reject empty selections before path normalization. Preserve
required alternatives across positional arguments and options. When the CLI
schema format separates those groups and cannot express their relationship,
state the required choice in command discovery/help and retain aggregate admission
before target access. Test emitted schemas and parser behavior together, including
omission, empty strings, alternatives, conflicts and boundary list counts.
Expected input failures must retain caller field/index paths and safe constraints.
Internal operation/input envelopes are not caller paths. Reuse the input issue
projector at the admission owner; a schema cause alone does not supply correction
metadata to either adapter.
When discoverability changes, evaluate representative broad, direct, and negative
tasks on the intended host. Record host/model/catalog, selected tools, arguments,
errors, retries, and task outcomes. Schema validity and keyword heuristics do not
+18 -12
View File
@@ -155,7 +155,8 @@ const registerDecompileCommand = (
runDirectAnalysis: DirectAnalysis["runDirectAnalysis"],
): void => {
cli.command(CLI_COMMANDS.decompile, {
description: "Read one part of an app as code",
description:
"Read one part of an app as code; requires a positional selector, --procedure, or --file-offset",
args: z.object({
path: z.string().describe("App or program path"),
address: z
@@ -170,7 +171,7 @@ const registerDecompileCommand = (
.min(1)
.optional()
.describe(
"Procedure name or address; use --procedure=<value> for leading dashes",
"Required selector: supply this option or the positional selector (decompile also accepts --file-offset); use --procedure=<value> for leading dashes",
),
"file-offset": z
.string()
@@ -249,7 +250,8 @@ const registerXrefsCommand = (
runDirectAnalysis: DirectAnalysis["runDirectAnalysis"],
): void => {
cli.command(CLI_COMMANDS.xrefs, {
description: "List bounded references to an analyzed address",
description:
"List bounded references to an analyzed address; requires a positional selector or --address",
args: z.object({
path: z.string().describe("App or program path"),
address: z.string().min(1).optional().describe("Address or symbol name"),
@@ -260,7 +262,7 @@ const registerXrefsCommand = (
.min(1)
.optional()
.describe(
"Address or symbol name; use --address=<value> for leading dashes",
"Required selector: supply this option or the positional selector; use --address=<value> for leading dashes",
),
snapshot: snapshotSelectionOption,
...nativeOpenOptionFields,
@@ -290,7 +292,8 @@ const registerTraceCommand = (
runDirectAnalysis: DirectAnalysis["runDirectAnalysis"],
): void => {
cli.command(CLI_COMMANDS.trace, {
description: "Trace a literal feature through analyzed references",
description:
"Trace a literal feature through analyzed references; requires a positional selector or --query",
args: z.object({
path: z.string().describe("App or program path"),
query: z.string().min(1).optional().describe("Literal feature query"),
@@ -301,7 +304,7 @@ const registerTraceCommand = (
.min(1)
.optional()
.describe(
"Complete literal feature query; use --query=<value> for leading dashes",
"Required selector: supply this option or the positional selector; use --query=<value> for leading dashes",
),
caseSensitive: z
.boolean()
@@ -336,7 +339,8 @@ const registerFunctionCommand = (
runDirectAnalysis: DirectAnalysis["runDirectAnalysis"],
): void => {
cli.command(CLI_COMMANDS.function, {
description: "Analyze one complete function with evidence",
description:
"Analyze one complete function with evidence; requires a positional selector or --procedure",
args: z.object({
path: z.string().describe("App or program path"),
address: z
@@ -351,7 +355,7 @@ const registerFunctionCommand = (
.min(1)
.optional()
.describe(
"Procedure name or address; use --procedure=<value> for leading dashes",
"Required selector: supply this option or the positional selector (decompile also accepts --file-offset); use --procedure=<value> for leading dashes",
),
snapshot: snapshotSelectionOption,
...nativeOpenOptionFields,
@@ -381,7 +385,8 @@ const registerInstructionsCommand = (
runDirectAnalysis: DirectAnalysis["runDirectAnalysis"],
): void => {
cli.command(CLI_COMMANDS.instructions, {
description: "Read every raw instruction without decompiling",
description:
"Read every raw instruction without decompiling; requires a positional selector or --procedure",
args: z.object({
path: z.string().describe("App or program path"),
address: z
@@ -396,7 +401,7 @@ const registerInstructionsCommand = (
.min(1)
.optional()
.describe(
"Procedure name or address; use --procedure=<value> for leading dashes",
"Required selector: supply this option or the positional selector (decompile also accepts --file-offset); use --procedure=<value> for leading dashes",
),
snapshot: snapshotSelectionOption,
...nativeOpenOptionFields,
@@ -426,7 +431,8 @@ const registerSearchCommand = (
runDirectAnalysis: DirectAnalysis["runDirectAnalysis"],
): void => {
cli.command(CLI_COMMANDS.search, {
description: "Search every analyzed string or procedure name",
description:
"Search every analyzed string or procedure name; requires a positional selector or --pattern",
args: z.object({
path: z.string().describe("App or program path"),
pattern: z
@@ -441,7 +447,7 @@ const registerSearchCommand = (
.min(1)
.optional()
.describe(
"Complete literal text or regex pattern; use --pattern=<value> for leading dashes",
"Required selector: supply this option or the positional selector; use --pattern=<value> for leading dashes",
),
kind: z
.enum(["strings", "procedures"])
+2 -1
View File
@@ -1,3 +1,4 @@
import { localPathStringSchema } from "../domain/localPath.js";
import {
analysisEvidenceDeliveryOptions,
deliverCliAnalysisEvidence,
@@ -20,7 +21,7 @@ export const registerDesktopApplicationCommands = (
description:
"Identify desktop runtime candidates from bounded directory and header observations",
args: z.object({
path: z.string().describe("Local installation directory"),
path: localPathStringSchema.describe("Local installation directory"),
}),
options: z.object({
max_entries: z
+8 -2
View File
@@ -136,7 +136,8 @@ export const registerFridaCommands = (
),
});
cli.command(CLI_COMMANDS.instrumentWithFrida, {
description: "Run one Frida script against a selected process and detach",
description:
"Run one Frida script against a selected process and detach; requires exactly one of --script or --script-file",
args: z.object({
mode: z
.enum(["attach", "spawn"])
@@ -153,7 +154,12 @@ export const registerFridaCommands = (
.min(1)
.optional()
.describe("Frida device identifier"),
script: z.string().optional().describe("Inline JavaScript to load"),
script: z
.string()
.optional()
.describe(
"Inline JavaScript to load; supply exactly one of --script or --script-file",
),
scriptFile: z
.string()
.min(1)
+10 -22
View File
@@ -1,3 +1,4 @@
import { javascriptRecoveryInputSchema } from "../domain/javascript/javascriptRecovery.js";
import { z } from "incur";
import { JavaScriptRecoveryService } from "../application/javascript/JavaScriptRecoveryService.js";
import { createJavaScriptRecoveryProvider } from "../composition/javascriptRecovery.js";
@@ -21,30 +22,17 @@ export const registerJavaScriptRecoveryCommands = (
description:
"Recover readable JavaScript modules with source provenance and verified files",
args: z.object({
path: z
.string()
.describe("Absolute UTF-8 JavaScript input; never executed"),
outputDirectory: z.string().describe("Absolute absent output directory"),
path: javascriptRecoveryInputSchema.shape.path,
outputDirectory: javascriptRecoveryInputSchema.shape.output_directory,
}),
options: z.object({
additionalPath: z
.array(z.string())
.optional()
.describe(
"Related absolute UTF-8 JavaScript file; repeat for multiple chunks",
),
extractionMode: z
.enum(["structural", "heuristic", "inspection"])
.optional()
.describe(
"Structural boundaries, heuristic fallback, or inspection-only regions",
),
rewriteLevel: z
.enum(["minimal", "standard", "aggressive"])
.optional()
.describe(
"Upstream syntax recovery level; runtime equivalence remains unknown",
),
additionalPath: javascriptRecoveryInputSchema.shape.additional_paths,
extractionMode: javascriptRecoveryInputSchema.shape.extraction_mode
.removeDefault()
.optional(),
rewriteLevel: javascriptRecoveryInputSchema.shape.rewrite_level
.removeDefault()
.optional(),
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
+1 -6
View File
@@ -24,12 +24,7 @@ import type { CliInstance } from "./types.js";
import { SUPPORTED_CLIENT_DEFINITIONS } from "../application/SupportedClients.js";
const supportedClientIds = SUPPORTED_CLIENT_DEFINITIONS.map(({ name }) => name);
const supportedClientSchema = z
.string()
.refine(
(candidate) => supportedClientIds.some((name) => name === candidate),
"Unsupported agent integration",
);
const supportedClientSchema = z.enum(supportedClientIds);
const skillClientSchema = z.union([z.literal("shared"), supportedClientSchema]);
/** Register setup, doctor, uninstall, and update CLI commands. */
+5 -7
View File
@@ -121,16 +121,14 @@ const registerNativeUiCommands = (
...nativeUiScopeOptions,
steps: z
.string()
.optional()
.describe("JSON array of declarative AX actions or waits"),
.describe(
"Required JSON array of one or more declarative AX actions or waits",
),
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.captureNativeUiScenario, async () => {
const parameters = nativeUiScope(options);
const decoded =
options.steps === undefined
? { ok: true as const, value: undefined }
: safeParseJson(options.steps);
const decoded = safeParseJson(options.steps);
if (!decoded.ok)
return invalidNativeUiScenarioInput(operation, [
{ path: ["steps"], reason: "invalid_format", expected: "JSON" },
@@ -144,7 +142,7 @@ const registerNativeUiCommands = (
operation,
projectInputIssues(parsed.error.issues, {
...parameters,
...(options.steps === undefined ? {} : { steps: decoded.value }),
steps: decoded.value,
}),
);
const { native_ui_deadline_ms: deadline, ...scenario } = parsed.data;
+8 -6
View File
@@ -1,3 +1,4 @@
import { localPathStringSchema } from "../domain/localPath.js";
import { compareWasmInterfaces } from "../application/wasm/WasmInterfaceComparisonService.js";
import { parseCliJsonInput } from "../cliJsonInput.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
@@ -10,7 +11,10 @@ import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
import { inspectWasmArtifactInputSchema } from "../domain/wasm/wasmArtifact.js";
import {
inspectWasmArtifactInputSchema,
wasmArtifactPathsSchema,
} from "../domain/wasm/wasmArtifact.js";
/** Resolve all caller-selected artifact paths through the shared workflow. */
export const registerWasmCommands = (
@@ -45,16 +49,14 @@ export const registerWasmCommands = (
cli.command(wasmArtifactModule.cliCommand, {
description: "Inspect an explicit local WASM artifact with WABT",
args: z.object({
path: z.string().describe("Local WASM artifact"),
path: localPathStringSchema.describe("Local WASM artifact"),
}),
options: z.object({
commandTimeoutMs: inspectWasmArtifactInputSchema.shape.command_timeout_ms,
glue_paths: z
.array(z.string())
glue_paths: wasmArtifactPathsSchema(localPathStringSchema)
.optional()
.describe("Selected local JavaScript glue paths"),
candidate_paths: z
.array(z.string())
candidate_paths: wasmArtifactPathsSchema(localPathStringSchema)
.optional()
.describe("Explicit local WASM candidates"),
}),
+6 -2
View File
@@ -14,13 +14,17 @@ const artifact = z.strictObject({
/** Default execution budget; upper bound is the runtime's signed 32-bit timer range. */
export const DEFAULT_WASM_COMMAND_TIMEOUT_MS = 30_000;
export const MAX_WASM_COMMAND_TIMEOUT_MS = 2_147_483_647;
/** Selected artifact lists share the same resource bound across path transports. */
export const wasmArtifactPathsSchema = (
pathSchema: z.ZodString = absoluteLocalPathStringSchema,
) => z.array(pathSchema).max(16);
/** All artifacts are explicitly selected local files; references never trigger acquisition. */
export const inspectWasmArtifactInputSchema = z.strictObject({
path: absoluteLocalPathStringSchema.describe(
"Absolute selected local WASM artifact path",
),
glue_paths: z.array(absoluteLocalPathStringSchema).max(16).default([]),
candidate_paths: z.array(absoluteLocalPathStringSchema).max(16).default([]),
glue_paths: wasmArtifactPathsSchema().default([]),
candidate_paths: wasmArtifactPathsSchema().default([]),
command_timeout_ms: z
.number()
.int()
@@ -112,6 +112,63 @@ describe("CLI advertised input schemas", () => {
expect(mismatches).toEqual([]);
});
it("preserves client choices, selected path bounds and required decoded steps", async () => {
const cli = createCli({});
const ajv = new Ajv2020({ strict: false });
for (const command of ["setup", "doctor"]) {
const schema = await advertisedSchema(cli, command);
const validate = ajv.compile(schema.options ?? {});
for (const client of ["codex", "unsupported-client"]) {
const input = { client: [client] };
expect(validate(input)).toBe(client === "codex");
expect(
parserSchema(cli, command, "options")?.safeParse(input).success,
).toBe(client === "codex");
}
}
const setup = await advertisedSchema(cli, "setup");
const validateSkill = ajv.compile(setup.options ?? {});
for (const skillClient of ["shared", "codex", "unsupported-client"]) {
const input = { skillClient: [skillClient] };
expect(validateSkill(input)).toBe(skillClient !== "unsupported-client");
expect(
parserSchema(cli, "setup", "options")?.safeParse(input).success,
).toBe(skillClient !== "unsupported-client");
}
for (const [command, field] of [
["inspect-wasm-artifact", "glue_paths"],
["inspect-wasm-artifact", "candidate_paths"],
["recover-javascript-sources", "additionalPath"],
]) {
const schema = await advertisedSchema(cli, command);
const validate = ajv.compile(schema.options ?? {});
for (const count of [16, 17]) {
const input = {
[field]: Array.from({ length: count }, () => "./selected"),
};
expect(validate(input)).toBe(count === 16);
expect(
parserSchema(cli, command, "options")?.safeParse(input).success,
).toBe(count === 16);
}
}
const scenario = await advertisedSchema(cli, "capture-native-ui-scenario");
expect(scenario.options?.required).toContain("steps");
expect(scenario.options?.properties.steps).toMatchObject({
description: expect.stringContaining("one or more"),
});
const desktop = await advertisedSchema(cli, "inspect-desktop-application");
const validate = ajv.compile(desktop.args ?? {});
for (const path of ["", ".", "./selected"]) {
expect(validate({ path })).toBe(path !== "");
expect(
parserSchema(cli, "inspect-desktop-application", "args")?.safeParse({
path,
}).success,
).toBe(path !== "");
}
});
it("allows an omitted WASM timeout while validating explicitly supplied values", async () => {
const cli = createCli({});
const advertised = await advertisedSchema(cli, "inspect-wasm-artifact");