fix(cli): preserve Cutter choices and reject invalid runtime arguments
This commit is contained in:
1 parent
7f2f0b78c4
commit
b647e4a4e8
8 files changed
+237
-44
No files matched your search
+24
-5
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user