diff --git a/src/inspector/JavaScriptRuntimeScope.ts b/src/inspector/JavaScriptRuntimeScope.ts index 1bc51a13b0..16fe10d267 100644 --- a/src/inspector/JavaScriptRuntimeScope.ts +++ b/src/inspector/JavaScriptRuntimeScope.ts @@ -58,7 +58,7 @@ export const authorizeRuntimeLocation = async ( asarHeaderAdmission: AsarHeaderAdmission = DEFAULT_ASAR_HEADER_ADMISSION, ): Promise => { throwIfRuntimeObservationCancelled(signal, operation); - if (value.startsWith("file:")) { + if (URL.parse(value)?.protocol === "file:") { const filePath = await authorizedElectronFile( value, signal, diff --git a/tests/acceptance/tools/inspectorFileUrlIdentity.test.ts b/tests/acceptance/tools/inspectorFileUrlIdentity.test.ts new file mode 100644 index 0000000000..e36bc6659a --- /dev/null +++ b/tests/acceptance/tools/inspectorFileUrlIdentity.test.ts @@ -0,0 +1,131 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { expect, it, onTestFinished } from "vitest"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it("preserves file URL scheme spelling and script identity after real Inspector state changes", async () => { + const root = await createTestTempDirectory("rea-inspector-file-url-"); + const source = join(root, "source #参数%.cjs"); + const runner = join(root, "runner.cjs"); + const sourceText = "globalThis.fileUrlProbe = true;\n"; + await writeFile(source, sourceText); + await writeFile( + runner, + ` + const { readFileSync } = require("node:fs"); + const vm = require("node:vm"); + const scripts = []; + process.on("message", (urls) => { + for (const filename of urls) { + const script = new vm.Script(readFileSync(process.argv[2], "utf8"), { filename }); + scripts.push(script); + script.runInThisContext(); + } + process.send({ loaded: scripts.length }); + }); + setInterval(() => {}, 1000); + `, + ); + const child = spawn( + process.execPath, + ["--inspect=127.0.0.1:0", runner, source], + { + stdio: ["ignore", "ignore", "pipe", "ipc"], + }, + ); + onTestFinished(async () => { + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, "exit"); + child.kill("SIGTERM"); + await exited; + } + }); + let stderr = ""; + if (child.stderr === null) throw new Error("Expected piped Inspector stderr"); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const address = /ws:\/\/127\.0\.0\.1:\d+\/[^\s]+/u; + await expect.poll(() => stderr, { timeout: 5000 }).toMatch(address); + const socket = address.exec(stderr)?.[0]; + if (socket === undefined) throw new Error("Node omitted its Inspector URL"); + const endpoint = `http://127.0.0.1:${new URL(socket).port}`; + const load = async (urls: readonly string[]) => { + const loaded = once(child, "message"); + child.send(urls); + await loaded; + }; + const originalUrl = pathToFileURL(source).href; + await load([originalUrl]); + const { call } = await connectLocalToolsMcp(); + const listed = await call("list_javascript_runtime_targets", { + inspector_endpoint: endpoint, + }); + expect(listed.isError, JSON.stringify(listed.content)).not.toBe(true); + const discovery = toolContract( + "list_javascript_runtime_targets", + ).outputSchema.parse(listed.structuredContent); + const targetId = discovery.normalized_result.targets[0]?.target_id; + if (targetId === undefined) throw new Error("Node target was not listed"); + const request = { + inspector_endpoint: endpoint, + target_id: targetId, + observation_ms: 25, + }; + const observe = async () => { + const response = await call("observe_javascript_runtime", request); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + return toolContract("observe_javascript_runtime").outputSchema.parse( + response.structuredContent, + ); + }; + const first = await observe(); + const initialScript = first.normalized_result.scripts.items.find( + (script) => script.reported_url === originalUrl, + ); + expect(initialScript).toMatchObject({ + location: { kind: "file", file_path: source }, + }); + + const addedUrls = [ + originalUrl.replace(/^file:/u, "FILE:"), + `${originalUrl.replace(/^file:/u, "FiLe:")}?instance=2#selected`, + ` \t${originalUrl}\t `, + ]; + await load(addedUrls); + const second = await observe(); + const expectedUrls = [originalUrl, ...addedUrls]; + const scripts = second.normalized_result.scripts.items.filter((script) => + expectedUrls.includes(script.reported_url ?? ""), + ); + expect(scripts.map((script) => script.reported_url)).toEqual( + expect.arrayContaining(expectedUrls), + ); + expect(scripts).toHaveLength(4); + expect(new Set(scripts.map((script) => script.script_key)).size).toBe(4); + for (const script of scripts) + expect(script.location).toEqual({ kind: "file", file_path: source }); + expect( + scripts.find((script) => script.reported_url === originalUrl)?.script_key, + ).toBe(initialScript?.script_key); + + const retained = await call("get_evidence_bundle", {}); + expect(retained.isError, JSON.stringify(retained.content)).not.toBe(true); + const records = toolContract("get_evidence_bundle").outputSchema.parse( + retained.structuredContent, + ).result.records; + const original = records.find( + (record) => record.evidence_id === first.evidence_id, + ); + if (original === undefined || !("normalized_result" in original)) + throw new Error("Original Inspector Evidence was not retained"); + expect(original.predicate_type).toBe(first.predicate_type); + expect(original.normalized_result).toEqual(first.normalized_result); + expect(await readFile(source, "utf8")).toBe(sourceText); +}, 30_000);