diff --git a/packages/coding-agent/src/tools/browser/launch.ts b/packages/coding-agent/src/tools/browser/launch.ts index c371e5eac..c8c892347 100644 --- a/packages/coding-agent/src/tools/browser/launch.ts +++ b/packages/coding-agent/src/tools/browser/launch.ts @@ -133,7 +133,7 @@ let chromiumExecutablePromise: Promise | undefined; export async function ensureChromiumExecutable(): Promise { const envPath = process.env.PUPPETEER_EXECUTABLE_PATH; if (envPath) return envPath; - const sysChrome = resolveSystemChromium(); + const sysChrome = await resolveSystemChromium(); if (sysChrome) return sysChrome; if (chromiumExecutablePromise) return chromiumExecutablePromise; @@ -193,7 +193,25 @@ let resolvedChromium: string | null | undefined; // undefined = unchecked; null function isExecutableFile(p: string): boolean { try { const st = fs.statSync(p); - return st.isFile(); + if (!st.isFile()) return false; + if (process.platform === "win32") return true; + fs.accessSync(p, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +async function isChromiumExecutable(p: string): Promise { + if (!isExecutableFile(p)) return false; + try { + const proc = Bun.spawn([p, "--version"], { + stdout: "pipe", + stderr: "ignore", + }); + const stdout = await new Response(proc.stdout).text(); + await proc.exited; + return proc.exitCode === 0 && /Chrom|Edg/i.test(stdout); } catch { return false; } @@ -278,13 +296,13 @@ function systemChromiumCandidates( return candidates; } -function resolveSystemChromium(): string | undefined { +async function resolveSystemChromium(): Promise { if (resolvedChromium !== undefined) return resolvedChromium ?? undefined; const seen = new Set(); for (const candidate of systemChromiumCandidates()) { if (!candidate || seen.has(candidate)) continue; seen.add(candidate); - if (isExecutableFile(candidate)) { + if (await isChromiumExecutable(candidate)) { resolvedChromium = candidate; logger.debug("Using system Chrome/Chromium", { path: candidate }); return candidate; @@ -894,6 +912,10 @@ export function systemChromiumCandidatesForTest( return systemChromiumCandidates(platform, home, which); } +export async function chromiumExecutableProbeForTest(executablePath: string): Promise { + return isChromiumExecutable(executablePath); +} + export function stealthIgnoreDefaultArgsForTest(executablePath: string | undefined): string[] { return stealthIgnoreDefaultArgs(executablePath); } diff --git a/packages/coding-agent/test/tools/browser-launch.test.ts b/packages/coding-agent/test/tools/browser-launch.test.ts index 0026a43fc..47539dc65 100644 --- a/packages/coding-agent/test/tools/browser-launch.test.ts +++ b/packages/coding-agent/test/tools/browser-launch.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; import * as path from "node:path"; import { + chromiumExecutableProbeForTest, stealthIgnoreDefaultArgsForTest, systemChromiumCandidatesForTest, } from "@oh-my-pi/pi-coding-agent/tools/browser/launch"; @@ -97,6 +99,26 @@ describe("system Chromium candidates", () => { }); describe("browser executable selection", () => { + it.skipIf(process.platform === "win32")( + "rejects executable wrappers that are not Chromium-family browsers", + async () => { + const tempDir = TempDir.createSync("@browser-probe-"); + try { + const wrapper = path.join(tempDir.path(), "google-chrome"); + const chromium = path.join(tempDir.path(), "chromium"); + await Bun.write(wrapper, "#!/bin/sh\necho browser bridge\n"); + await Bun.write(chromium, "#!/bin/sh\necho Chromium 123.0\n"); + fs.chmodSync(wrapper, 0o755); + fs.chmodSync(chromium, 0o755); + + await expect(chromiumExecutableProbeForTest(wrapper)).resolves.toBe(false); + await expect(chromiumExecutableProbeForTest(chromium)).resolves.toBe(true); + } finally { + await tempDir.remove(); + } + }, + ); + it("honors PUPPETEER_EXECUTABLE_PATH before a detected Windows system Chrome", async () => { const tempDir = TempDir.createSync("@browser-executable-"); try {