fix(cli): preserve Cutter choices and reject invalid runtime arguments

This commit is contained in:
morluto committed 2026-10-15 03:06:26 +08:00
1 parent 7f2f0b78c4
commit b647e4a4e8
8 files changed
+237 -44

No files matched your search

+24 -5
View File
@@ -1,5 +1,6 @@
import { z } from "incur";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { AdbDeviceAnalysisService } from "../application/adb/AdbDeviceAnalysisService.js";
import type { AdbDeviceAnalysisPort } from "../application/adb/AdbDeviceAnalysisPort.js";
import { createAdbDeviceAnalysisProvider } from "../composition/adb.js";
@@ -351,12 +352,30 @@ export const registerAdbCommands = (
),
}),
run: ({ args, options }) => {
const extras = options.extra.map((entry) => {
const extras: {
readonly type: "string" | "boolean" | "int" | "long" | "float";
readonly key: string;
readonly value: string;
}[] = [];
for (const [index, entry] of options.extra.entries()) {
const first = entry.indexOf(":");
const second = entry.indexOf(":", first + 1);
if (first === -1 || second === -1)
throw new Error(`--extra expects type:key:value, received: ${entry}`);
return {
return logCliCommand(
logger,
CLI_COMMANDS.startAdbActivity,
async () => {
throw new AnalysisInputError("start_adb_activity", undefined, [
{
path: ["extras", index],
reason: "invalid_format",
message: "Expected --extra in type:key:value form.",
expected: "type:key:value",
},
]);
},
);
extras.push({
type: entry.slice(0, first) as
| "string"
| "boolean"
@@ -365,8 +384,8 @@ export const registerAdbCommands = (
| "float",
key: entry.slice(first + 1, second),
value: entry.slice(second + 1),
};
});
});
}
return execute(CLI_COMMANDS.startAdbActivity, "start_adb_activity", {
serial: args.serial,
action: args.action,
+11 -3
View File
@@ -45,10 +45,18 @@ export const registerCutterCommands = (
.refine((value) => !/[\r\n]/u.test(value)),
}),
options: z.object({
jsonOutput: z
.boolean()
.optional()
.describe(
"Return command-specific JSON output when available (independent of CLI output formatting).",
),
json: z
.boolean()
.default(false)
.describe("Return command-specific JSON output when available."),
.optional()
.describe(
"Deprecated compatibility input: use --json-output; pass --json=true or --json=false (bare --json formats CLI output).",
),
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
@@ -58,7 +66,7 @@ export const registerCutterCommands = (
session_id: args.sessionId,
expected_generation: args.expectedGeneration,
command: args.command,
json: options.json,
json: options.jsonOutput ?? options.json ?? false,
},
{ signal },
);
+15
View File
@@ -215,6 +215,21 @@ export const registerFridaCommands = (
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.instrumentWithFrida, async () => {
if (args.mode === "attach" && options.argument !== undefined)
return projectAnalysisError(
new AnalysisInputError(
CLI_COMMANDS.instrumentWithFrida,
undefined,
[
{
path: ["argument"],
reason: "invalid_value",
message:
"--argument is only valid in spawn mode; remove it when attaching to a process.",
},
],
),
);
if (
(options.script === undefined) ===
(options.scriptFile === undefined)
+23 -5
View File
@@ -278,11 +278,11 @@ export class CutterBridgeClient {
),
}
: undefined);
this.#directory =
configured ??
(this.#platform === "win32" && localAppData
? join(localAppData, "REA", "CutterBridge")
: join(homedir(), ".cache", "rea", "cutter-bridge"));
this.#directory = resolveBridgeDirectory(
configured,
this.#platform,
localAppData,
);
}
async listSessions(): Promise<CutterBridgeDiscovery> {
@@ -889,3 +889,21 @@ export class CutterBridgeClient {
});
}
}
const resolveBridgeDirectory = (
configured: string | undefined,
platform: NodeJS.Platform,
localAppData: string | undefined,
): string => {
if (configured !== undefined && configured !== "") {
if (configured === "~") return homedir();
if (configured.startsWith("~/"))
return join(homedir(), configured.slice(2));
if (platform === "win32" && configured.startsWith("~\\"))
return join(homedir(), configured.slice(2));
return configured;
}
return platform === "win32" && localAppData
? join(localAppData, "REA", "CutterBridge")
: join(homedir(), ".cache", "rea", "cutter-bridge");
};
+51
View File
@@ -44,6 +44,57 @@ it("validates device serials before contacting any device", async () => {
});
});
it.each(["string:key", ":", "string"])(
"classifies malformed activity extra %j before contacting adb",
async (extra) => {
const result = await serve(
[
"start-adb-activity",
"emulator-5554",
"android.intent.action.VIEW",
"--extra",
extra,
"--format",
"json",
],
{ REA_ADB_PATH: "/path/that/does/not/exist" },
);
const error = JSON.parse(result.stdout);
expect(error).toMatchObject({
code: "invalid_request",
category: "invalid_input",
retryable: true,
remediation: { action: expect.any(String) },
});
expect(error.remediation.action).not.toBe("");
expect(error.message).not.toBe("");
},
);
it.each([
["bogus:key:value", "invalid_request"],
["string:key:value", "capability_unavailable"],
] as const)(
"preserves downstream validation for well-delimited activity extra %j",
async (extra, code) => {
const result = await serve(
[
"start-adb-activity",
"emulator-5554",
"android.intent.action.VIEW",
"--extra",
extra,
"--format",
"json",
],
{ REA_ADB_PATH: "/path/that/does/not/exist" },
);
expect(JSON.parse(result.stdout)).toMatchObject({ code });
},
);
it.skipIf(process.platform === "win32")(
"preserves requested reinstall mode without claiming prior package state",
async () => {
+46 -25
View File
@@ -4,32 +4,11 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Cli } from "incur";
import { afterEach, describe, expect, it } from "vitest";
import { createCli } from "../../../src/cli.js";
import { serveCli } from "./cliServeHarness.js";
const SESSION_ID = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a";
const registeredCommands = Cli.toCommands as WeakMap<
object,
Map<string, unknown>
>;
const cutterCommand = async (
environment: Record<string, string | undefined>,
args: {
readonly sessionId: string;
readonly expectedGeneration: number;
readonly command: string;
},
): Promise<unknown> => {
const cli = createCli(environment);
const entry = registeredCommands.get(cli)?.get("cutter-command") ?? {};
const run = Reflect.get(entry, "run") as (...args: unknown[]) => unknown;
return Reflect.apply(run, undefined, [{ args, options: { json: false } }]);
};
const listen = async (
server: ReturnType<typeof createServer>,
): Promise<number> => {
@@ -79,11 +58,26 @@ describe("Cutter CLI commands", () => {
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("invalid_request");
});
});
describe("Cutter CLI command output flags", () => {
let directory: string | undefined;
let server: ReturnType<typeof createServer> | undefined;
afterEach(async () => {
if (server !== undefined)
await new Promise<void>((resolve) => server?.close(() => resolve()));
if (directory !== undefined)
await rm(directory, { recursive: true, force: true });
directory = undefined;
server = undefined;
});
it("returns Evidence when the selected Cutter session completes a command", async () => {
directory = await mkdtemp(join(tmpdir(), "rea-cutter-cli-test-"));
const token = "synthetic-cutter-cli-token-for-test";
let nextConnectionOrdinal = 0;
const commandJsonModes: boolean[] = [];
server = createServer((connection) => {
const ordinal = ++nextConnectionOrdinal;
connection.setEncoding("utf8");
@@ -109,6 +103,8 @@ describe("Cutter CLI commands", () => {
}
const requestBytes = String(frame.request);
const request = JSON.parse(requestBytes) as Record<string, unknown>;
if (request.kind === "command")
commandJsonModes.push(request.json === true);
if (
frame.proof !==
createHmac("sha256", token)
@@ -172,12 +168,37 @@ describe("Cutter CLI commands", () => {
{ mode: 0o600 },
);
const result = await cutterCommand(
const result = await serveCli(
["cutter-command", SESSION_ID, "0", "pdi", "--json-output"],
{ REA_CUTTER_BRIDGE_DIR: directory },
{ sessionId: SESSION_ID, expectedGeneration: 0, command: "pdi" },
);
expect(JSON.stringify(result)).toContain("rea.cutter.command-observation");
expect(JSON.stringify(result)).toContain('"answer":42');
expect(result.exitCode).toBe(0);
expect(commandJsonModes).toEqual([true]);
expect(result.stdout).toContain("answer: 42");
const formatted = await serveCli(
["cutter-command", SESSION_ID, "0", "pdi", "--json"],
{ REA_CUTTER_BRIDGE_DIR: directory },
);
expect(formatted.exitCode).toBe(0);
expect(commandJsonModes).toEqual([true, false]);
expect(() => JSON.parse(formatted.stdout)).not.toThrow();
for (const [flags, expected] of [
[["--json=true"], true],
[["--json=false"], false],
[["--json=true", "--json-output=false"], false],
[["--json=false", "--json-output=true"], true],
[["--json-output=false", "--json=true"], false],
[["--json-output=true", "--json=false"], true],
] as const) {
const compatible = await serveCli(
["cutter-command", SESSION_ID, "0", "pdi", ...flags],
{ REA_CUTTER_BRIDGE_DIR: directory },
);
expect(compatible.exitCode).toBe(0);
expect(commandJsonModes.at(-1)).toBe(expected);
}
});
});
+5
View File
@@ -246,6 +246,11 @@ it.each([
"script",
"Provide exactly one of --script or --script-file.",
],
[
["attach", "71", "--script", "send(1)", "--argument", "ignored"],
"argument",
"--argument is only valid in spawn mode; remove it when attaching to a process.",
],
])(
"names the rejected instrument-with-frida argument for %j",
async (argv, path, message) => {
@@ -10,7 +10,7 @@ import {
writeFile,
} from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { join, relative } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -1124,16 +1124,12 @@ describe("CutterBridgeClient: scenario group 7", () => {
});
});
describe("CutterBridgeClient: scenario group 8", () => {
describe("CutterBridgeClient: conservative platform checks", () => {
let directory: string | undefined;
let server: ReturnType<typeof createServer> | undefined;
afterEach(async () => {
if (server !== undefined)
await new Promise<void>((resolve) => server?.close(() => resolve()));
if (directory !== undefined)
await rm(directory, { recursive: true, force: true });
server = undefined;
directory = undefined;
});
@@ -1196,6 +1192,66 @@ describe("CutterBridgeClient: scenario group 8", () => {
).resolves.toMatchObject({
bridge_directory: join(homedir(), ".cache", "rea", "cutter-bridge"),
});
await expect(
new CutterBridgeClient(
{ REA_CUTTER_BRIDGE_DIR: "" },
{ platform: "darwin" },
).listSessions(),
).resolves.toMatchObject({
bridge_directory: join(homedir(), ".cache", "rea", "cutter-bridge"),
});
});
});
describe("CutterBridgeClient: bridge paths and native descriptor reads", () => {
let directory: string | undefined;
let server: ReturnType<typeof createServer> | undefined;
afterEach(async () => {
if (server !== undefined)
await new Promise<void>((resolve) => server?.close(() => resolve()));
if (directory !== undefined)
await rm(directory, { recursive: true, force: true });
server = undefined;
directory = undefined;
});
it("expands a tilde bridge directory before discovering its live producer", async () => {
directory = await mkdtemp(join(homedir(), ".rea-cutter-tilde-"));
const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a";
const token = "synthetic-cutter-bridge-token-for-tilde-test";
server = authenticatedServer(token, sessionId, (request, respond) => {
respond({
ok: true,
session_id: request.session_id,
current_file: "/fixture/sample",
document_generation: 3,
cutter_version: "Cutter fixture version",
identity_status: "partial",
});
});
const port = await listen(server);
await writeDescriptor(directory, sessionId, port, token, {
currentFile: "/fixture/sample",
documentGeneration: 3,
});
const configured = `~/${relative(homedir(), directory)}`;
const result = await createClient({
REA_CUTTER_BRIDGE_DIR: configured,
}).listSessions();
expect(result).toMatchObject({
bridge_directory: directory,
discovery_status: "sessions_found",
sessions: [
{
session_id: sessionId,
document_generation: 3,
current_file: "/fixture/sample",
},
],
});
});
it("uses the packaged Windows authority for descriptor verification and reads", async () => {