diff --git a/src/cli/adbCommands.ts b/src/cli/adbCommands.ts index 1a8180ee92..34b777e8a0 100644 --- a/src/cli/adbCommands.ts +++ b/src/cli/adbCommands.ts @@ -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, diff --git a/src/cli/cutterCommands.ts b/src/cli/cutterCommands.ts index 2b8ce574b7..695cf846ff 100644 --- a/src/cli/cutterCommands.ts +++ b/src/cli/cutterCommands.ts @@ -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 }, ); diff --git a/src/cli/fridaCommands.ts b/src/cli/fridaCommands.ts index 8f3b90efbe..51f8b1b2c1 100644 --- a/src/cli/fridaCommands.ts +++ b/src/cli/fridaCommands.ts @@ -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) diff --git a/src/cutter/CutterBridgeClient.ts b/src/cutter/CutterBridgeClient.ts index 13973cabfa..fb6c643480 100644 --- a/src/cutter/CutterBridgeClient.ts +++ b/src/cutter/CutterBridgeClient.ts @@ -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 { @@ -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"); +}; diff --git a/tests/boundary/cli/adbCli.test.ts b/tests/boundary/cli/adbCli.test.ts index f0f1d3f918..cda871ae34 100644 --- a/tests/boundary/cli/adbCli.test.ts +++ b/tests/boundary/cli/adbCli.test.ts @@ -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 () => { diff --git a/tests/boundary/cli/cutterCommands.test.ts b/tests/boundary/cli/cutterCommands.test.ts index e7c34ee973..6a588cb4b8 100644 --- a/tests/boundary/cli/cutterCommands.test.ts +++ b/tests/boundary/cli/cutterCommands.test.ts @@ -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 ->; - -const cutterCommand = async ( - environment: Record, - args: { - readonly sessionId: string; - readonly expectedGeneration: number; - readonly command: string; - }, -): Promise => { - 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, ): Promise => { @@ -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 | undefined; + + afterEach(async () => { + if (server !== undefined) + await new Promise((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; + 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); + } }); }); diff --git a/tests/boundary/cli/fridaCli.test.ts b/tests/boundary/cli/fridaCli.test.ts index 317ca62152..0751e28d42 100644 --- a/tests/boundary/cli/fridaCli.test.ts +++ b/tests/boundary/cli/fridaCli.test.ts @@ -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) => { diff --git a/tests/boundary/providers/CutterBridgeClient.test.ts b/tests/boundary/providers/CutterBridgeClient.test.ts index a4b7fb24df..edb11275be 100644 --- a/tests/boundary/providers/CutterBridgeClient.test.ts +++ b/tests/boundary/providers/CutterBridgeClient.test.ts @@ -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 | undefined; afterEach(async () => { - if (server !== undefined) - await new Promise((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 | undefined; + + afterEach(async () => { + if (server !== undefined) + await new Promise((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 () => {