fix(cli): keep file URLs out of operator-relative path resolution (#2882)

CLI path normalization resolved every nonblank selected string against
the working directory, so executable_path "file:///opt/electron" became
an invented local path and failed with ENOENT for that location instead
of the field's local-path requirement. Leave `file:` and `scheme://`
references unchanged so the shared schema rejects them as non-local
paths. One-letter prefixes stay paths, so Windows drive paths and
relative names containing a colon resolve as before.

Fixes #2577

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Shubham Ahuja 2026-10-13 22:35:14 +05:30 committed by GitHub
parent c8ef1f916c
commit f652709cac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 84 additions and 4 deletions

View File

@ -65,12 +65,19 @@ const accessDeniedSystemCode = (
: undefined;
};
/**
* A `file:` URL or `scheme://` URL is not an operator-relative filesystem path.
* One-letter prefixes stay paths so Windows drive paths resolve normally.
*/
const URL_REFERENCE = /^(?:file:|[A-Za-z][A-Za-z0-9+.-]+:\/\/)/iu;
/**
* 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 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.
* fields holding nonblank, non-URL strings are resolved, everything else passes
* through untouched so shared schemas can reject blank paths and URLs. Shared MCP
* contracts reject relative paths, so CLI workflows resolve operator-relative
* values before validation.
*/
export const resolveCliJsonPaths = (
value: unknown,
@ -94,7 +101,9 @@ const resolveJsonPath = (
if (tail.length === 0)
return [
key,
typeof entry === "string" && entry.trim().length > 0
typeof entry === "string" &&
entry.trim().length > 0 &&
!URL_REFERENCE.test(entry)
? resolve(entry)
: entry,
];

View File

@ -56,4 +56,38 @@ describe("CLI JSON path normalization", () => {
});
expect(input.browser.executable_path).toBe(" browser ");
});
it.each([
"file:///opt/electron",
"FILE:electron",
"https://example.test/app",
])("keeps URL %j distinguishable from a local path", (value) => {
const input = {
executable_path: value,
application_path: resolve("/synthetic/app"),
};
const normalized = resolveCliJsonPaths(input, paths);
expect(normalized).toEqual(input);
const parsed = electronActiveObservationInputSchema.safeParse(normalized);
expect(parsed.success).toBe(false);
if (parsed.success) throw new Error("Expected URL path rejection");
expect(parsed.error.issues).toEqual([
expect.objectContaining({
path: ["executable_path"],
message: expect.stringContaining(
"must be absolute local filesystem paths",
),
}),
]);
});
it.each([
"bin/electron",
"release:v1/electron",
"C:\\Electron\\electron.exe",
])("keeps resolving local path %j", (value) => {
expect(resolveCliJsonPaths({ executable_path: value }, paths)).toEqual({
executable_path: resolve(value),
});
});
});

View File

@ -67,3 +67,40 @@ cliTest(
expect(session.isError, JSON.stringify(session)).not.toBe(true);
},
);
cliTest(
"rejects a file URL path field without resolving an invented local path",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-electron-file-url-");
const application = join(root, "main.js");
await writeFile(application, "module.exports = {};\n");
const response = await cli.run({
arguments: [
"capture-electron-scenario",
JSON.stringify({
executable_path: "file:///opt/electron",
application_path: application,
}),
"--format",
"json",
],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(response.exitCode).toBe(1);
expect(response.json).toMatchObject({
code: "invalid_request",
details: {
issues: [
{
path: ["executable_path"],
message: expect.stringContaining(
"must be absolute local filesystem paths",
),
},
],
},
});
expect(response.stdout).not.toContain("ENOENT");
expect(response.stdout).not.toContain("file:/opt/electron");
},
);