fix(cli): refuse scenario steps on passive native UI observation (#2712)

observe-native-ui and capture-native-ui-scenario were registered in one
loop with a shared option schema and description. The passive command
therefore advertised `--steps` ("Observe or run a scenario"), accepted
any value, and then dispatched observe_native_ui without it, so a
caller who selected the wrong command never learned that no scenario ran.

Register each command against its own contract. Observation declares
only the passive scope options, so `--steps` is rejected as an unknown
flag; the scenario command keeps its JSON step decoding and validation.
Each command now has its own description.

Fixes #2674

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822 2026-10-13 06:59:33 -07:00 committed by GitHub
parent beca34f522
commit 96d1f0f3fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 130 additions and 80 deletions

View File

@ -32,89 +32,116 @@ export const registerUtilityCommands = (
registerCapabilityCommands(cli, logger, analysis);
registerNativeCommands(cli, logger, runProviderAnalysis);
registerNativeCallCommands(cli, logger, runProviderAnalysis);
for (const [command, operation] of [
[CLI_COMMANDS.observeNativeUi, "observe_native_ui"],
[CLI_COMMANDS.captureNativeUiScenario, "capture_native_ui_scenario"],
] as const) {
cli.command(command, {
description:
"Observe or run a scenario in one exact native application window",
args: z.object({
path: z.string().describe("Local native executable or app path"),
}),
options: z.object({
pid: z.number().int().positive().describe("Existing application PID"),
windowId: z
.number()
.int()
.positive()
.describe("Exact selected application window ID"),
steps: z
.string()
.optional()
.describe("JSON array of declarative AX actions or waits"),
screenshot: z
.boolean()
.default(true)
.describe("Capture only the selected window"),
accessibility: z
.boolean()
.default(true)
.describe("Read the selected window accessibility tree"),
maxNodes: z
.number()
.int()
.positive()
.safe()
.default(500)
.describe("Maximum accessibility nodes per capture"),
}),
run: ({ args, options }) =>
logCliCommand(logger, command, async () => {
const parameters = {
pid: options.pid,
window_id: options.windowId,
screenshot: options.screenshot,
accessibility: options.accessibility,
max_nodes: options.maxNodes,
};
if (operation === "capture_native_ui_scenario") {
const decoded =
options.steps === undefined
? { ok: true as const, value: undefined }
: safeParseJson(options.steps);
if (!decoded.ok)
return invalidNativeUiScenarioInput(operation, [
{ path: ["steps"], reason: "invalid_format", expected: "JSON" },
]);
const parsed = nativeUiScenarioInputSchema.safeParse({
...parameters,
steps: decoded.value,
});
if (!parsed.success)
return invalidNativeUiScenarioInput(
operation,
projectInputIssues(parsed.error.issues, {
...parameters,
...(options.steps === undefined
? {}
: { steps: decoded.value }),
}),
);
return runProviderAnalysis(
args.path,
operation,
parsed.data,
logger,
);
}
return runProviderAnalysis(args.path, operation, parameters, logger);
}),
});
}
registerNativeUiCommands(cli, logger, runProviderAnalysis);
registerReferenceSourceCommand(cli, logger, environment);
};
const nativeUiScopeOptions = {
pid: z.number().int().positive().describe("Existing application PID"),
windowId: z
.number()
.int()
.positive()
.describe("Exact selected application window ID"),
screenshot: z
.boolean()
.default(true)
.describe("Capture only the selected window"),
accessibility: z
.boolean()
.default(true)
.describe("Read the selected window accessibility tree"),
maxNodes: z
.number()
.int()
.positive()
.safe()
.default(500)
.describe("Maximum accessibility nodes per capture"),
};
const nativeUiScope = (options: {
readonly pid: number;
readonly windowId: number;
readonly screenshot: boolean;
readonly accessibility: boolean;
readonly maxNodes: number;
}) => ({
pid: options.pid,
window_id: options.windowId,
screenshot: options.screenshot,
accessibility: options.accessibility,
max_nodes: options.maxNodes,
});
/**
* Passive observation and active scenarios have different input contracts.
* Only the scenario command accepts steps, so passive observation rejects
* them as an unknown flag instead of silently dropping them.
*/
const registerNativeUiCommands = (
cli: CliInstance,
logger: Logger,
runProviderAnalysis: DirectAnalysis["runProviderAnalysis"],
): void => {
const pathArgument = z.object({
path: z.string().describe("Local native executable or app path"),
});
cli.command(CLI_COMMANDS.observeNativeUi, {
description:
"Observe one already-running native application window by PID and window ID",
args: pathArgument,
options: z.object(nativeUiScopeOptions),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.observeNativeUi, () =>
runProviderAnalysis(
args.path,
"observe_native_ui",
nativeUiScope(options),
logger,
),
),
});
const operation = "capture_native_ui_scenario";
cli.command(CLI_COMMANDS.captureNativeUiScenario, {
description:
"Run declarative AX actions or waits in one exact native application window",
args: pathArgument,
options: z.object({
...nativeUiScopeOptions,
steps: z
.string()
.optional()
.describe("JSON array of 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);
if (!decoded.ok)
return invalidNativeUiScenarioInput(operation, [
{ path: ["steps"], reason: "invalid_format", expected: "JSON" },
]);
const parsed = nativeUiScenarioInputSchema.safeParse({
...parameters,
steps: decoded.value,
});
if (!parsed.success)
return invalidNativeUiScenarioInput(
operation,
projectInputIssues(parsed.error.issues, {
...parameters,
...(options.steps === undefined ? {} : { steps: decoded.value }),
}),
);
return runProviderAnalysis(args.path, operation, parsed.data, logger);
}),
});
};
const invalidNativeUiScenarioInput = (
operation: string,
issues: ConstructorParameters<typeof AnalysisInputError>[2],

View File

@ -71,4 +71,27 @@ describe("native UI scenario CLI input", () => {
});
},
);
cliTest(
"refuses scenario steps on passive observation instead of dropping them",
async ({ cli }) => {
const result = await cli.run({
arguments: [
"observe-native-ui",
"/provider-must-not-be-invoked",
"--pid",
"1",
"--window-id",
"1",
"--steps",
'[{"kind":"wait","milliseconds":0}]',
"--json",
],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(result.exitCode).not.toBe(0);
expect(result.stdout).toContain("Unknown flag: --steps");
},
);
});