fix(cli): preserve caller-selected JSON paths

Keep POSIX symlink-parent traversal at the shared JSON input boundary and forward the selected browser executable without a second lexical normalization. Verify Electron, browser, and Evidence-file consumers through registered commands and the built CLI.

Refs #5103
This commit is contained in:
morluto committed 2026-10-21 03:50:14 +08:00
1 parent 690fc51854
commit 1bfa60ac20
8 files changed
+422 -13

No files matched your search

+45 -1
View File
@@ -3,7 +3,7 @@ import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { afterEach, expect, it, vi } from "vitest";
import { mkdir, writeFile, rm, access } from "node:fs/promises";
import { mkdir, writeFile, rm, access, symlink } from "node:fs/promises";
import { join } from "node:path";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import type {
@@ -14,6 +14,7 @@ import type {
} from "playwright-core";
import { browserScenarioSchema } from "../domain/browserScenario.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import {
attachedScenarioLeaseKey,
failBrowserScenarioOperation,
@@ -91,6 +92,49 @@ it("passes the selected environment to the actual browser launch boundary", asyn
});
});
it.skipIf(process.platform === "win32")(
"passes the caller-selected symlink-parent executable to the launch boundary",
async () => {
const root = await createTestTempDirectory("rea-browser-caller-path-");
try {
await mkdir(join(root, "real/inner"), { recursive: true });
await symlink(join(root, "real/inner"), join(root, "link"));
const selected = absoluteCallerPath("link/../browser", { cwd: root });
let launchedExecutable: string | undefined;
const scenario = browserScenarioSchema.parse({
browser: { mode: "launch", executable_path: selected },
start_url: { url: "https://example.test" },
actions: [
{ step_id: "settle", action: "wait_for_timeout", duration_ms: 1 },
],
});
await expect(
openPlaywrightScenarioBrowser(
scenario,
{},
{
launcher: {
connectOverCDP: async () => {
throw new Error("Unexpected connection");
},
launchPersistentContext: async (_profile, options) => {
launchedExecutable = options?.executablePath;
throw new Error("fake launch boundary reached");
},
},
},
),
).rejects.toThrow("fake launch boundary reached");
expect(launchedExecutable).toBe(selected);
expect(selected).toBe(`${root}/link/../browser`);
} finally {
await rm(root, { recursive: true, force: true });
}
},
);
it("keeps Termux execution support when launching Chromium on Android", async () => {
Object.defineProperty(process, "platform", { value: "android" });
vi.stubEnv("PREFIX", "/data/data/com.termux/files/usr");
+2 -2
View File
@@ -7,7 +7,7 @@ import {
} from "../artifacts/AnalysisCleanupLease.js";
import { chmod, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { join } from "node:path";
import type {
Browser,
@@ -717,7 +717,7 @@ export const openPlaywrightScenarioBrowser = async (
try {
await chmod(profilePath, 0o700);
context = await launcher.launchPersistentContext(profilePath, {
executablePath: resolve(scenario.browser.executable_path),
executablePath: scenario.browser.executable_path,
headless: scenario.browser.headless,
acceptDownloads: false,
viewport: {
+19 -4
View File
@@ -24,16 +24,22 @@ import { runCliJavaScriptApplicationAnalysis } from "./javascriptApplicationAnal
import { withCommandCancellation } from "./commandCancellation.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
import type { ElectronActiveObservationPort } from "../application/javascript/ElectronActiveObservationPort.js";
/** Register CLI equivalents of the Electron MCP tools. */
export const registerElectronCommands = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
environment: Readonly<Record<string, string | undefined>>,
options: {
readonly createActiveProvider?: (
environment: Readonly<Record<string, string | undefined>>,
) => ElectronActiveObservationPort;
} = {},
): void => {
registerElectronObservationCommands(cli, logger);
registerElectronFuseCommand(cli, logger);
registerElectronActiveCommand(cli, logger, environment);
registerElectronActiveCommand(cli, logger, environment, options);
registerJavaScriptApplicationCommand(cli, logger);
registerJavaScriptRuntimeReconciliationCommand(cli, logger);
};
@@ -86,6 +92,11 @@ const registerElectronActiveCommand = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
environment: Readonly<Record<string, string | undefined>>,
options: {
readonly createActiveProvider?: (
environment: Readonly<Record<string, string | undefined>>,
) => ElectronActiveObservationPort;
},
): void => {
cli.command(CLI_COMMANDS.captureElectronScenario, {
description: "Run one owned Electron scenario with cancellable actions",
@@ -122,9 +133,13 @@ const registerElectronActiveCommand = (
);
const { captureElectronScenario } =
await import("../application/javascript/ElectronActiveObservationService.js");
const { createElectronScenarioProvider } =
await import("../composition/electronScenario.js");
const provider = createElectronScenarioProvider(environment);
const provider =
options.createActiveProvider === undefined
? await import("../composition/electronScenario.js").then(
({ createElectronScenarioProvider }) =>
createElectronScenarioProvider(environment),
)
: options.createActiveProvider(environment);
const result = await finishAnalysisCommand(
() => captureElectronScenario(provider, parsed.data, { signal }),
{
+13 -3
View File
@@ -11,6 +11,7 @@ import { projectAnalysisError } from "./domain/analysisErrorProjection.js";
import { BrowserObservationError } from "./domain/browserObservationError.js";
import { analysisInputErrorFromIssues } from "./domain/inputIssueProjection.js";
import { browserScenarioSchema } from "./domain/browserScenario.js";
import type { BrowserScenarioCapturePort } from "./application/BrowserScenarioCapturePort.js";
import type { JsonValue } from "./domain/jsonValue.js";
import type { Logger } from "pino";
@@ -21,6 +22,11 @@ export const registerBrowserScenarioCommands = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
environment: Readonly<Record<string, string | undefined>>,
options: {
readonly createProvider?: (
environment: Readonly<Record<string, string | undefined>>,
) => BrowserScenarioCapturePort;
} = {},
): void => {
cli.command(CLI_COMMANDS.captureBrowserScenario, {
description:
@@ -48,9 +54,13 @@ export const registerBrowserScenarioCommands = (
),
);
// Load Playwright only for this command, not during CLI startup.
const { createBrowserScenarioProvider } =
await import("./composition/browserScenario.js");
const provider = createBrowserScenarioProvider(environment);
const provider =
options.createProvider === undefined
? await import("./composition/browserScenario.js").then(
({ createBrowserScenarioProvider }) =>
createBrowserScenarioProvider(environment),
)
: options.createProvider(environment);
let result:
| Awaited<ReturnType<typeof captureBrowserScenario>>
| undefined;
+2 -2
View File
@@ -1,4 +1,4 @@
import { resolve } from "node:path";
import { absoluteCallerPath } from "./filesystem/callerPath.js";
import { sealOwnedJsonInput } from "./application/OwnedJsonInput.js";
import { readJsonInputFile } from "./application/JsonInputFile.js";
@@ -141,7 +141,7 @@ const resolveJsonPath = (
typeof entry === "string" &&
entry.trim().length > 0 &&
!URL_REFERENCE.test(entry)
? resolve(entry)
? absoluteCallerPath(entry)
: entry,
];
return [key, resolveJsonPath(entry, tail)];
+47 -1
View File
@@ -1,4 +1,6 @@
import { resolve } from "node:path";
import { mkdir, mkdtemp, rm, symlink } from "node:fs/promises";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, it } from "vitest";
@@ -57,6 +59,50 @@ describe("CLI JSON path normalization", () => {
expect(input.browser.executable_path).toBe(" browser ");
});
it.skipIf(process.platform === "win32")(
"keeps POSIX symlink-parent traversal for each selected JSON path",
async () => {
const root = await mkdtemp(join(tmpdir(), "rea-json-caller-path-"));
const originalCwd = process.cwd();
try {
await mkdir(join(root, "real/inner"), { recursive: true });
await symlink(join(root, "real/inner"), join(root, "link"));
process.chdir(root);
const electronInput = {
executable_path: "link/../tool",
application_path: "link/../app",
application_root: "link/../root",
};
const normalizedElectron = resolveCliJsonPaths(electronInput, paths);
expect(normalizedElectron).toEqual({
executable_path: `${root}/link/../tool`,
application_path: `${root}/link/../app`,
application_root: `${root}/link/../root`,
});
expect(electronInput.executable_path).toBe("link/../tool");
expect(
electronActiveObservationInputSchema.safeParse(normalizedElectron)
.success,
).toBe(true);
const browserInput = {
browser: { executable_path: "link/../browser", label: "keep" },
};
expect(
resolveCliJsonPaths(browserInput, [["browser", "executable_path"]]),
).toEqual({
browser: {
executable_path: `${root}/link/../browser`,
label: "keep",
},
});
expect(browserInput.browser.executable_path).toBe("link/../browser");
} finally {
process.chdir(originalCwd);
await rm(root, { recursive: true, force: true });
}
},
);
it.each([
"file:///opt/electron",
"FILE:electron",
@@ -0,0 +1,62 @@
import { mkdir, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { toolContract } from "../../src/contracts/toolContracts.js";
import { parseEvidence } from "../../src/domain/evidence.js";
import { analysisViewResultSchema } from "../../src/domain/analysisView/analysisView.js";
import {
analysisViewJavaScriptEvidence,
analysisViewLayoutEvidence,
} from "../fixtures/analysisView.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest } from "../support/cli/cliFixture.js";
cliTest
.skipIf(process.platform === "win32")
.for(["relative", "absolute"] as const)(
"reads selected analysis-view Evidence through a %s caller path containing a symlink parent",
async (pathKind, { cli }) => {
const root = await createTestTempDirectory(
"rea-analysis-view-caller-path-",
);
await mkdir(join(root, "real", "inner"), { recursive: true });
await symlink("real/inner", join(root, "link"));
const selected = analysisViewJavaScriptEvidence();
const decoy = analysisViewLayoutEvidence();
expect(selected.evidence_id).not.toBe(decoy.evidence_id);
await writeFile(
join(root, "real", "evidence.json"),
JSON.stringify(selected),
);
await writeFile(join(root, "evidence.json"), JSON.stringify(decoy));
const sourcePath =
pathKind === "relative"
? "link/../evidence.json"
: `${root}/link/../evidence.json`;
const queryPath = join(root, "query.json");
await writeFile(
queryPath,
JSON.stringify({
source: { kind: "evidence-file", path: sourcePath },
view: { kind: "summary" },
}),
);
const result = await cli.run({
arguments: ["inspect-analysis-view", queryPath, "--json"],
cwd: root,
});
expect(result.exitCode, result.stdout + result.stderr).toBe(0);
const contractResult = toolContract(
"inspect_analysis_view",
).outputSchema.parse(result.json);
const output = parseEvidence(contractResult);
const view = analysisViewResultSchema.parse(output.normalized_result);
expect(view.kind).toBe("summary");
expect(view.parent_evidence_id).toBe(selected.evidence_id);
expect(output.evidence_links).toEqual([selected.evidence_id]);
},
);
@@ -0,0 +1,232 @@
import {
chmod,
mkdir,
mkdtemp,
readFile,
realpath,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Cli } from "incur";
import pino from "pino";
import { expect, it } from "vitest";
import type { BrowserScenarioCapturePort } from "../../../src/application/BrowserScenarioCapturePort.js";
import type { ElectronActiveObservationPort } from "../../../src/application/javascript/ElectronActiveObservationPort.js";
import { registerBrowserScenarioCommands } from "../../../src/cliBrowserScenarioCommands.js";
import { registerAnalysisViewCommands } from "../../../src/cli/analysisViewCommands.js";
import { registerElectronCommands } from "../../../src/cli/electronCommands.js";
import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { err } from "../../../src/domain/result.js";
import {
analysisViewJavaScriptEvidence,
analysisViewLayoutEvidence,
} from "../../fixtures/analysisView.js";
const providerIdentity = {
id: "test.path-boundary",
name: "Path boundary test provider",
version: null,
} as const;
const serveJson = async (
cli: ReturnType<typeof Cli.create>,
command: string,
input: unknown,
): Promise<unknown> => {
let stdout = "";
await cli.serve([command, JSON.stringify(input), "--json"], {
env: {},
stdout: (text) => {
stdout += text;
},
exit: () => undefined,
});
return JSON.parse(stdout) as unknown;
};
it.skipIf(process.platform === "win32")(
"keeps caller-selected JSON paths through the public CLI and provider boundaries",
async () => {
const root = await mkdtemp(join(tmpdir(), "rea-cli-json-path-"));
const originalCwd = process.cwd();
const originalExitCode = process.exitCode;
try {
await mkdir(join(root, "real/inner"), { recursive: true });
await mkdir(join(root, "real/app"), { recursive: true });
await mkdir(join(root, "real/root"), { recursive: true });
await mkdir(join(root, "app"), { recursive: true });
await mkdir(join(root, "root"), { recursive: true });
await writeFile(join(root, "real/tool"), "selected executable");
await writeFile(join(root, "tool"), "lexical sibling");
await chmod(join(root, "real/tool"), 0o700);
await chmod(join(root, "tool"), 0o700);
await symlink(join(root, "real/inner"), join(root, "link"));
process.chdir(root);
const electronInputs: string[][] = [];
const electronProvider: ElectronActiveObservationPort = {
identity: () => providerIdentity,
capture: async (input) => {
electronInputs.push([
input.executable_path,
input.application_path,
input.application_root,
]);
return err(
new AnalysisCapabilityUnavailableError(
providerIdentity.id,
"capture_electron_scenario",
"intentional inert test provider",
),
);
},
close: async () => undefined,
};
const electronCli = Cli.create("rea-path-electron-test", {
sync: false,
});
registerElectronCommands(
electronCli,
pino({ level: "silent" }),
{},
{
createActiveProvider: () => electronProvider,
},
);
for (const path of ["link/../", `${root}/link/../`]) {
const output = await serveJson(
electronCli,
"capture-electron-scenario",
{
executable_path: `${path}tool`,
application_path: `${path}app`,
application_root: `${path}root`,
actions: [],
},
);
expect(output).toMatchObject({ code: "capability_unavailable" });
}
const expectedElectronPaths = [
await realpath(join(root, "real/tool")),
await realpath(join(root, "real/app")),
await realpath(join(root, "real/root")),
];
expect(electronInputs).toEqual([
expectedElectronPaths,
expectedElectronPaths,
]);
const browserInputs: string[] = [];
const browserProvider: BrowserScenarioCapturePort = {
identity: () => providerIdentity,
captureScenario: async (scenario) => {
if (scenario.browser.mode === "launch")
browserInputs.push(scenario.browser.executable_path);
return err(
new AnalysisCapabilityUnavailableError(
providerIdentity.id,
"capture_browser_scenario",
"intentional inert test provider",
),
);
},
close: async () => undefined,
};
const browserCli = Cli.create("rea-path-browser-test", {
sync: false,
});
registerBrowserScenarioCommands(
browserCli,
pino({ level: "silent" }),
{},
{ createProvider: () => browserProvider },
);
for (const executablePath of [
"link/../browser",
`${root}/link/../browser`,
]) {
const output = await serveJson(browserCli, "capture-browser-scenario", {
browser: { mode: "launch", executable_path: executablePath },
start_url: { url: "https://example.test" },
actions: [
{
step_id: "settle",
action: "wait_for_timeout",
duration_ms: 1,
},
],
});
expect(output).toMatchObject({ code: "capability_unavailable" });
}
expect(browserInputs).toEqual([
`${root}/link/../browser`,
`${root}/link/../browser`,
]);
} finally {
process.chdir(originalCwd);
process.exitCode = originalExitCode;
await rm(root, { recursive: true, force: true });
}
},
);
it.skipIf(process.platform === "win32")(
"reads the caller-selected Evidence file through the registered CLI",
async () => {
const root = await mkdtemp(join(tmpdir(), "rea-cli-evidence-path-"));
const originalCwd = process.cwd();
const originalExitCode = process.exitCode;
try {
await mkdir(join(root, "real/inner"), { recursive: true });
await symlink(join(root, "real/inner"), join(root, "link"));
process.chdir(root);
const selectedEvidence = analysisViewLayoutEvidence();
const siblingEvidence = analysisViewJavaScriptEvidence();
await mkdir(join(root, "real"), { recursive: true });
await writeFile(
join(root, "real/evidence.json"),
JSON.stringify(selectedEvidence),
);
await writeFile(
join(root, "evidence.json"),
JSON.stringify(siblingEvidence),
);
expect(
parseEvidence(
JSON.parse(await readFile(`${root}/link/../evidence.json`, "utf8")),
).evidence_id,
).toBe(selectedEvidence.evidence_id);
const analysisViewCli = Cli.create("rea-path-evidence-test", {
sync: false,
});
registerAnalysisViewCommands(analysisViewCli, pino({ level: "silent" }));
for (const path of [
"link/../evidence.json",
`${root}/link/../evidence.json`,
]) {
const output = parseEvidence(
await serveJson(analysisViewCli, "inspect-analysis-view", {
source: { kind: "evidence-file", path },
view: { kind: "summary" },
}),
);
expect(output.parameters.parent_evidence_id, path).toBe(
selectedEvidence.evidence_id,
);
expect(output.evidence_links, path).toEqual([
selectedEvidence.evidence_id,
]);
}
} finally {
process.chdir(originalCwd);
process.exitCode = originalExitCode;
await rm(root, { recursive: true, force: true });
}
},
);