diff --git a/src/cliJsonInput.ts b/src/cliJsonInput.ts index 44199b79e..8bb2d105a 100644 --- a/src/cliJsonInput.ts +++ b/src/cliJsonInput.ts @@ -37,9 +37,9 @@ export const parseCliJsonInput = async ( /** * Resolve named string fields of CLI-supplied JSON against the operator * working directory. Each entry is a key path from the document root; only - * fields holding strings are resolved, everything else passes through - * untouched. Shared MCP contracts reject relative paths, so CLI workflows - * resolve operator-relative values before validation. + * fields holding nonblank strings are resolved, everything else passes through + * untouched so shared schemas can reject blank paths. Shared MCP contracts reject + * relative paths, so CLI workflows resolve operator-relative values before validation. */ export const resolveCliJsonPaths = ( value: unknown, @@ -61,7 +61,12 @@ const resolveJsonPath = ( ([key, entry]: readonly [string, unknown]): readonly [string, unknown] => { if (key !== head) return [key, entry]; if (tail.length === 0) - return [key, typeof entry === "string" ? resolve(entry) : entry]; + return [ + key, + typeof entry === "string" && entry.trim().length > 0 + ? resolve(entry) + : entry, + ]; return [key, resolveJsonPath(entry, tail)]; }, ); diff --git a/src/cliJsonPaths.test.ts b/src/cliJsonPaths.test.ts new file mode 100644 index 000000000..936bfacea --- /dev/null +++ b/src/cliJsonPaths.test.ts @@ -0,0 +1,59 @@ +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { resolveCliJsonPaths } from "./cliJsonInput.js"; +import { electronActiveObservationInputSchema } from "./domain/javascript/electronActiveObservation.js"; + +const paths = [ + ["executable_path"], + ["application_path"], + ["application_root"], +] as const; + +describe("CLI JSON path normalization", () => { + it.each([ + ["executable_path", ""], + ["executable_path", " \t "], + ["application_path", ""], + ["application_path", " \t "], + ["application_root", ""], + ["application_root", " \t "], + ])( + "keeps blank %s (%j) invalid at the shared input boundary", + (field, value) => { + const input = { + executable_path: "/synthetic/electron", + application_path: "/synthetic/app", + application_root: "/synthetic", + [field]: value, + }; + const parsed = electronActiveObservationInputSchema.safeParse( + resolveCliJsonPaths(input, paths), + ); + expect(parsed.success).toBe(false); + if (parsed.success) throw new Error("Expected blank path rejection"); + expect(parsed.error.issues).toEqual( + expect.arrayContaining([expect.objectContaining({ path: [field] })]), + ); + expect(input).toHaveProperty(field, value); + }, + ); + + it("resolves selected relative paths without trimming names or changing unrelated fields", () => { + const input = { + browser: { executable_path: " browser ", label: "relative-label" }, + application_path: "app", + }; + expect( + resolveCliJsonPaths(input, [["browser", "executable_path"]]), + ).toEqual({ + browser: { + executable_path: resolve(" browser "), + label: "relative-label", + }, + application_path: "app", + }); + expect(input.browser.executable_path).toBe(" browser "); + }); +}); diff --git a/tests/acceptance/applications/electronBlankPathCli.test.ts b/tests/acceptance/applications/electronBlankPathCli.test.ts new file mode 100644 index 000000000..f060aed34 --- /dev/null +++ b/tests/acceptance/applications/electronBlankPathCli.test.ts @@ -0,0 +1,32 @@ +import { expect } from "vitest"; + +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for(["", " \t "])( + "rejects blank Electron application_root (%j) before filesystem or provider work", + async (applicationRoot, { cli }) => { + const response = await cli.run({ + arguments: [ + "capture-electron-scenario", + JSON.stringify({ + executable_path: "/synthetic/missing-electron", + application_path: "/synthetic/missing-app", + application_root: applicationRoot, + }), + "--json", + ], + environment: { REA_LOG_LEVEL: "silent" }, + timeoutMs: 15000, + }); + expect(response.exitCode).toBe(1); + expect(response.json).toMatchObject({ + code: "invalid_request", + details: { + operation: "capture_electron_scenario", + issues: expect.arrayContaining([ + { path: ["application_root"], reason: "out_of_range", minimum: 1 }, + ]), + }, + }); + }, +);