fix(electron): attribute unresolved paths to selected inputs

Resolve each caller-selected Electron path once at the application boundary, collect field-specific failures with original causes, and pass canonical paths directly to the provider. Update direct provider consumers and route the real verifier through the shared application workflow.

Validation: public CLI/MCP invalid-path acceptance, canonicalization boundary, provider tests, and cancellation/cleanup checks. Live Electron launch was not verified. This does not address the separate Playwright launch-rejection crash in #2269.

Fixes #2272
This commit is contained in:
morluto 2026-10-13 04:12:40 +08:00
parent 049132ab43
commit b7169c8b74
8 changed files with 186 additions and 92 deletions

View File

@ -4,7 +4,11 @@ import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { PlaywrightElectronActiveProvider } from "../dist/browser/PlaywrightElectronActiveProvider.js";
import { electronActiveObservationInputSchema } from "../dist/domain/javascript/electronActiveObservation.js";
import { captureElectronScenario } from "../dist/application/javascript/ElectronActiveObservationService.js";
import {
electronActiveObservationInputSchema,
electronActiveObservationResultSchema,
} from "../dist/domain/javascript/electronActiveObservation.js";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
import { verifyElectronToolContracts } from "./verify/electron/tool-contracts.mjs";
@ -50,61 +54,65 @@ const input = electronActiveObservationInputSchema.parse({
],
});
const verifierRun = createVerifierRun();
const result = await new PlaywrightElectronActiveProvider(process.env).capture(
const result = await captureElectronScenario(
new PlaywrightElectronActiveProvider(process.env),
input,
);
if (!result.ok) throw result.error;
const capture = electronActiveObservationResultSchema.parse(
result.value.normalized_result,
);
const output = {
verifier_run: await completeVerifierRun(verifierRun),
electron: result.value.application,
actions: result.value.actions,
processes: result.value.processes,
ipc: result.value.ipc,
timeline: result.value.timeline,
windows: result.value.windows,
electron: capture.application,
actions: capture.actions,
processes: capture.processes,
ipc: capture.ipc,
timeline: capture.timeline,
windows: capture.windows,
verified:
result.value.coverage.status === "partial_attach" &&
result.value.actions.every(({ status }) => status === "completed") &&
result.value.actions.some(
capture.coverage.status === "partial_attach" &&
capture.actions.every(({ status }) => status === "completed") &&
capture.actions.some(
({ kind, window_index }) =>
kind === "renderer-reload" && window_index === 1,
) &&
result.value.actions.some(
capture.actions.some(
({ kind, window_index }) =>
kind === "renderer-crash" && window_index === 1,
) &&
result.value.windows.length >= 2 &&
result.value.ipc.events.some(
capture.windows.length >= 2 &&
capture.ipc.events.some(
({ kind, channel }) =>
kind === "main-handler-invocation" && channel === "readiness:echo",
) &&
result.value.timeline.events.some(
capture.timeline.events.some(
({ kind, event, phase }) =>
kind === "window-lifecycle" &&
event === "created" &&
phase === "completed",
) &&
result.value.timeline.events.some(
capture.timeline.events.some(
({ kind, event }) =>
kind === "process-lifecycle" && event === "utility-process-fork",
) &&
result.value.timeline.events.some(
capture.timeline.events.some(
({ kind, event, phase }) =>
kind === "shell-attempt" &&
event === "openExternal" &&
phase === "blocked",
) &&
result.value.timeline.events.some(
capture.timeline.events.some(
({ kind, event, phase }) =>
kind === "preload" && event === "configured" && phase === "completed",
) &&
result.value.timeline.events.some(
capture.timeline.events.some(
({ kind, event }) =>
kind === "protocol" &&
(event === "open-url" || event === "second-instance"),
) &&
result.value.timeline.events.some(
capture.timeline.events.some(
({ kind, event }) =>
kind === "process-lifecycle" && event === "child.spawn",
),

View File

@ -1,18 +1,11 @@
import { digestCanonicalValue } from "../../domain/canonicalDigest.js";
import type { ProviderIdentity } from "../AnalysisProvider.js";
import type { CanonicalElectronActiveObservationInput } from "./ElectronActiveObservationPort.js";
import type { Evidence, EvidenceObservation } from "../../domain/evidence.js";
import { createEvidence } from "../../domain/evidence.js";
import type {
ElectronActiveObservationInput,
ElectronActiveObservationResult,
} from "../../domain/javascript/electronActiveObservation.js";
import type { ElectronActiveObservationResult } from "../../domain/javascript/electronActiveObservation.js";
import { jsonObjectSchema, jsonValueSchema } from "../../domain/jsonValue.js";
type CanonicalElectronActiveObservationInput =
ElectronActiveObservationInput & {
readonly application_root: string;
};
/** Commit the selected Electron experiment and its observed result. */
export const createElectronActiveEvidence = (
input: CanonicalElectronActiveObservationInput,

View File

@ -9,11 +9,15 @@ import type {
} from "../../domain/javascript/electronActiveObservation.js";
import type { Result } from "../../domain/result.js";
/** Caller selections resolved once before entering the runtime adapter. */
export type CanonicalElectronActiveObservationInput =
ElectronActiveObservationInput & { readonly application_root: string };
/** Provider-neutral boundary for owned Electron runtime experiments. */
export interface ElectronActiveObservationPort {
identity(): ProviderIdentity;
capture(
input: ElectronActiveObservationInput,
input: CanonicalElectronActiveObservationInput,
options?: ExecutionOptions,
): Promise<Result<ElectronActiveObservationResult, AnalysisError>>;
}

View File

@ -2,57 +2,80 @@ import { dirname } from "node:path";
import { realpath } from "node:fs/promises";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { ElectronActiveObservationPort } from "./ElectronActiveObservationPort.js";
import type {
CanonicalElectronActiveObservationInput,
ElectronActiveObservationPort,
} from "./ElectronActiveObservationPort.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisInputError,
type AnalysisInputIssue,
} from "../../domain/analysisErrorCore.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { Evidence } from "../../domain/evidence.js";
import type { ElectronActiveObservationInput } from "../../domain/javascript/electronActiveObservation.js";
import { err, type Result } from "../../domain/result.js";
import { err, ok, type Result } from "../../domain/result.js";
import { createElectronActiveEvidence } from "./ElectronActiveEvidence.js";
const OPERATION = "capture_electron_scenario" as const;
const PROVIDER_ID = "rea-playwright-electron-active";
type CanonicalElectronActiveObservationInput =
ElectronActiveObservationInput & {
readonly application_root: string;
};
const resolveInputPath = async (
field: "executable_path" | "application_path" | "application_root",
selected: string,
): Promise<
Result<
string,
{ readonly issue: AnalysisInputIssue; readonly cause: unknown }
>
> => {
try {
return ok(await realpath(selected));
} catch (cause: unknown) {
return err({
issue: {
path: [field],
reason: "invalid_value",
message: `Cannot resolve ${field} ${selected}: ${cause instanceof Error ? cause.message : String(cause)}`,
},
cause,
});
}
};
const canonicalizeInput = async (
input: ElectronActiveObservationInput,
): Promise<Result<CanonicalElectronActiveObservationInput, AnalysisError>> => {
try {
const [executablePath, applicationPath] = await Promise.all([
realpath(input.executable_path),
realpath(input.application_path),
]);
const applicationRoot =
input.application_root === undefined
? dirname(applicationPath)
: await realpath(input.application_root);
return {
ok: true,
value: {
...input,
executable_path: executablePath,
application_path: applicationPath,
application_root: applicationRoot,
},
};
} catch (cause: unknown) {
const [executable, application, root] = await Promise.all([
resolveInputPath("executable_path", input.executable_path),
resolveInputPath("application_path", input.application_path),
input.application_root === undefined
? Promise.resolve(ok(undefined))
: resolveInputPath("application_root", input.application_root),
]);
if (!executable.ok || !application.ok || !root.ok) {
const failures = [executable, application, root].flatMap((result) =>
result.ok ? [] : [result.error],
);
return err(
new AnalysisInputError(OPERATION, { cause }, [
new AnalysisInputError(
OPERATION,
{
path: ["application_path"],
reason: "invalid_format",
message:
"Electron executable, application, and root must resolve to existing paths",
cause: new AggregateError(
failures.map(({ cause }) => cause),
"Electron paths could not be resolved",
),
},
]),
failures.map(({ issue }) => issue),
),
);
}
return ok({
...input,
executable_path: executable.value,
application_path: application.value,
application_root: root.value ?? dirname(application.value),
});
};
/** Canonicalize request paths and execute one provider-owned Electron run. */

View File

@ -1,4 +1,5 @@
import { afterEach, expect, it, vi } from "vitest";
import { dirname } from "node:path";
import * as processOwnership from "../process/ProcessOwnership.js";
import * as electronActions from "./PlaywrightElectronActiveActions.js";
@ -28,12 +29,13 @@ it("passes the selected environment and ownership token to Electron launch", asy
throw new Error("launch boundary reached");
},
);
const result = await provider.capture(
electronActiveObservationInputSchema.parse({
const result = await provider.capture({
...electronActiveObservationInputSchema.parse({
executable_path: process.execPath,
application_path: process.execPath,
}),
);
application_root: dirname(process.execPath),
});
expect(result.ok).toBe(false);
expect(launchedEnvironment).toEqual({
@ -151,12 +153,13 @@ it("retains the partial capture without taskkill when Windows lineage is unavail
);
try {
const result = await provider.capture(
electronActiveObservationInputSchema.parse({
const result = await provider.capture({
...electronActiveObservationInputSchema.parse({
executable_path: process.execPath,
application_path: process.execPath,
}),
);
application_root: dirname(process.execPath),
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected unverified cleanup to fail");

View File

@ -1,5 +1,3 @@
import { realpath } from "node:fs/promises";
import { dirname } from "node:path";
import { randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url";
@ -9,11 +7,11 @@ import type {
ExecutionOptions,
ProviderIdentity,
} from "../application/AnalysisProvider.js";
import type { ElectronActiveObservationPort } from "../application/javascript/ElectronActiveObservationPort.js";
import type {
ElectronActiveObservationInput,
ElectronActiveObservationResult,
} from "../domain/javascript/electronActiveObservation.js";
CanonicalElectronActiveObservationInput,
ElectronActiveObservationPort,
} from "../application/javascript/ElectronActiveObservationPort.js";
import type { ElectronActiveObservationResult } from "../domain/javascript/electronActiveObservation.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
@ -70,7 +68,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
}
async capture(
input: ElectronActiveObservationInput,
input: CanonicalElectronActiveObservationInput,
options: ExecutionOptions = {},
): Promise<Result<ElectronActiveObservationResult, AnalysisError>> {
let application: ElectronApplication | undefined;
@ -89,7 +87,11 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
throw new BrowserObservationError(OPERATION, "cancelled");
await prepareProcessOwnershipInspection(options.signal);
const startupDeadline = Date.now() + STARTUP_TIMEOUT_MS;
paths = await canonicalPaths(input);
paths = {
executable: input.executable_path,
application: input.application_path,
root: input.application_root,
};
const captureBaseline =
await systemProcessOwnershipHost.captureBaseline?.(options.signal);
if (options.signal?.aborted)
@ -353,20 +355,6 @@ export const cleanupElectronProcesses = async (
};
};
const canonicalPaths = async (
input: ElectronActiveObservationInput,
): Promise<ElectronPaths> => {
const [executable, application] = await Promise.all([
realpath(input.executable_path),
realpath(input.application_path),
]);
const root =
input.application_root === undefined
? dirname(application)
: await realpath(input.application_root);
return { executable, application, root };
};
const safeElectronEnvironment = (
environment: Readonly<Record<string, string | undefined>>,
runId: string,

View File

@ -0,0 +1,69 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest(
"reports every unresolved Electron path through CLI and MCP without launching",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-electron-invalid-paths-");
const application = join(root, "main.js");
await writeFile(application, "module.exports = {};\n");
const missing = {
executable_path: join(root, "missing-electron"),
application_path: join(root, "missing-application"),
application_root: join(root, "missing-root"),
};
const requestPath = join(root, "input.json");
await writeFile(requestPath, JSON.stringify(missing));
const response = await cli.run({
arguments: ["capture-electron-scenario", requestPath, "--format", "json"],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(response.exitCode).toBe(1);
const expectedIssues = Object.entries(missing).map(([field, path]) => ({
path: [field],
reason: "invalid_value",
message: expect.stringContaining(path),
}));
expect(response.json).toMatchObject({
code: "invalid_request",
details: { issues: expectedIssues },
});
expect(response.stdout).toContain("ENOENT");
const { call } = await connectLocalToolsMcp();
for (const [field, path] of Object.entries(missing)) {
const result = await call("capture_electron_scenario", {
executable_path: process.execPath,
application_path: application,
application_root: root,
[field]: path,
});
expect(result.isError).toBe(true);
const content = result.content.find((item) => item.type === "text");
if (content?.type !== "text") throw new Error("Missing error response");
expect(JSON.parse(content.text)).toMatchObject({
error: {
code: "invalid_request",
details: {
issues: [
{
path: [field],
reason: "invalid_value",
message: expect.stringContaining(path),
},
],
},
},
});
expect(content.text).toContain("ENOENT");
}
const session = await call("binary_session", {});
expect(session.isError, JSON.stringify(session)).not.toBe(true);
},
);

View File

@ -903,7 +903,7 @@ it.skipIf(process.platform === "win32")(
);
it.skipIf(process.platform === "win32")(
"maps cancellation during native preparation before Electron path resolution",
"maps cancellation during native preparation before Electron launch",
async () => {
const directory = await mkdtemp(
join(tmpdir(), "rea-electron-token-abort-test-"),
@ -926,7 +926,10 @@ it.skipIf(process.platform === "win32")(
application_path: "/missing/application.js",
});
captureOutcome = new PlaywrightElectronActiveProvider({})
.capture(input, { signal: controller.signal })
.capture(
{ ...input, application_root: process.cwd() },
{ signal: controller.signal },
)
.then(
(value) => ({ state: "result" as const, value }),
(cause: unknown) => ({ state: "rejected" as const, cause }),
@ -1053,7 +1056,10 @@ it.skipIf(process.platform === "win32")(
application_root: process.cwd(),
});
captureOutcome = new PlaywrightElectronActiveProvider({})
.capture(input, { signal: controller.signal })
.capture(
{ ...input, application_root: process.cwd() },
{ signal: controller.signal },
)
.then(
(value) => ({ state: "result" as const, value }),
(cause: unknown) => ({ state: "rejected" as const, cause }),