diff --git a/docs/tools/browser.md b/docs/tools/browser.md index ccd3d667b5..c2efdd94e4 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -48,7 +48,7 @@ | `viewport` | `{ width: number; height: number; scale?: number }` | No | Requested viewport. For headless launch this becomes the initial viewport; for a page it is applied with `page.setViewport()`. `scale` maps to Puppeteer `deviceScaleFactor`. | | `wait_until` | `"load" \| "domcontentloaded" \| "networkidle0" \| "networkidle2"` | No | Navigation wait condition. Defaults to `"networkidle2"` where omitted. | | `dialogs` | `"accept" \| "dismiss"` | No | Installs a page `dialog` handler that auto-accepts or auto-dismisses dialogs. Omitted means no handler. | -| `app` | `{ path?: string; cdp_url?: string; browser?: "chrome"; user_data_dir?: string; profile_directory?: string; background?: boolean; no_focus?: boolean; cdp_port?: number; args?: string[]; target?: string }` | No | Selects browser kind. No `app` uses the session `browser.headless` setting. `app.path` alone is resolved against the session cwd and used as the executable path for spawn/attach reuse. `app.cdp_url` connects to an existing CDP endpoint. `app.browser: "chrome"` selects guarded saved-profile mode and requires `path`, `user_data_dir`, and `profile_directory`. `args` are appended only when spawning `app.path` or a Chrome profile. `target` is used for attached/spawned/profile page selection. | +| `app` | `{ path?: string; cdp_url?: string; browser?: "chrome"; user_data_dir?: string; profile_directory?: string; background?: boolean; no_focus?: boolean; cdp_port?: number; args?: string[]; target?: string }` | No | Selects browser kind. No `app` uses the session `browser.headless` setting. `app.path` alone is resolved against the session cwd and used as the executable path for spawn/attach reuse. `app.cdp_url` connects to an existing CDP endpoint. `app.browser: "chrome"` selects guarded saved-profile mode: `path` defaults to installed Chrome/Chromium and `profile_directory` defaults to `"Default"`, while `user_data_dir` is required and must be a non-default Chrome data directory because Chrome 136+ disables remote debugging for its default data directory. Non-Chrome executables and default Chrome data roots are rejected. `args` are appended only when spawning `app.path` or a Chrome profile. `target` is used for attached/spawned/profile page selection. | ### `action: "close"` @@ -92,7 +92,7 @@ The tool returns one result per call; no streaming partial output is emitted fro 1. `BrowserTool.execute()` (`packages/coding-agent/src/tools/browser.ts`) abort-checks, clamps `timeout` via `clampTimeout("browser", ...)`, defaults `name` to `"main"`, and dispatches `open`, `close`, `act`, or `run`. 2. `open` resolves browser kind with `resolveBrowserKind()`: - `app.cdp_url` → `{ kind: "connected" }` after trimming trailing slashes. - - `app.browser: "chrome"` → `{ kind: "chrome-profile" }` after resolving `path` and `user_data_dir` against session cwd and copying `profile_directory`, `background`, `no_focus`, and optional `cdp_port`. + - `app.browser: "chrome"` → `{ kind: "chrome-profile" }`. `path` defaults to installed Chrome/Chromium (`resolveSystemChromeForProfile()`, admitting only Chrome/Chromium brands) and `profile_directory` defaults to `"Default"`. `user_data_dir` is required, resolved against the session cwd, and rejected when it resolves (including through a symlink) to a platform default Stable/Beta/Dev/Canary/Chromium root; trusted Linux environment overrides plus Flatpak and Snap defaults are included. Chrome 136+ does not honor remote-debugging switches for default Chrome data directories. Explicit Edge, Brave, Vivaldi, Opera, and unknown browser executables are rejected before profile fields are resolved. `background`, `no_focus`, and optional `cdp_port` are copied through. - `app.path` → `{ kind: "spawned" }` after resolving against session cwd. - otherwise → `{ kind: "headless", headless: session.settings.get("browser.headless") }`. 3. `open` rejects reusing the same tab name across different browser kinds (`sameBrowserKind()`); callers must close first. @@ -114,9 +114,9 @@ The tool returns one result per call; no streaming partial output is emitted fro 8. `WorkerCore.#init()` (`packages/coding-agent/src/tools/browser/tab-worker.ts`) connects back to the browser websocket endpoint. Headless mode opens a new page, applies stealth patches, applies viewport, installs dialog handling if requested, and optionally navigates. Attach mode resolves the requested target page and optionally installs dialog handling. 9. On success the worker sends `ready` with `{ url, title, viewport, targetId }`; the supervisor stores a `TabSession`, increments browser-handle refcount with `holdBrowser()`, and keeps the tab in a process-global `Map`. -### Existing Chrome profile mode +### Existing non-default Chrome profile mode -Use this mode when automation needs cookies and login state from a saved Chrome profile without risking the daily Chrome process: +Use this mode for a dedicated, persistent Chrome data root that already contains the automation profile and login state. Chrome 136+ rejects remote debugging against the browser's default data root, so do not point this mode at the daily Chrome root. Create and sign in to a separate root first, close that Chrome instance, then let GJC reopen it with the guarded CDP lifecycle: ```json { @@ -124,9 +124,8 @@ Use this mode when automation needs cookies and login state from a saved Chrome "name": "work-browser", "app": { "browser": "chrome", - "path": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "user_data_dir": "~/Library/Application Support/Google/Chrome", - "profile_directory": "Profile 10", + "user_data_dir": "~/Library/Application Support/GJC/Chrome Automation", + "profile_directory": "Default", "background": true, "no_focus": true, "target": "example.com" @@ -137,6 +136,7 @@ Use this mode when automation needs cookies and login state from a saved Chrome Security and lifecycle rules: - CDP is bound to `127.0.0.1`; do not expose logged-in profile CDP ports on a public interface. A CDP client has full browser-account access. +- `user_data_dir` must be a separate non-default data root. Platform Stable/Beta/Dev/Canary/Chromium defaults, Linux environment/Flatpak/Snap defaults, and aliases to them are rejected before launch. Use `app.cdp_url` only for an already-authorized endpoint that you intentionally started and control. - Saved-profile and attached-CDP automation can read and act with that profile's cookies and authenticated accounts. Use it only when that credentialed access is intentional. - Never use generic `app.path` spawning for a daily Chrome profile: it may kill stale same-path processes. Use explicit `app.browser: "chrome"` profile mode, which applies the ownership guards below. - A matching already-running profile is reused only when its localhost CDP endpoint responds. A matching profile running normally without CDP is refused with remediation text; GJC does not kill or relaunch it. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b756c9d24a..537cec8e2f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -21,6 +21,7 @@ ### Changed - Updated every bundled GLM model profile (`glm-eco`, `glm-medium`, and `glm-pro`) from ZAI GLM-5.2 to GLM-5.3. +- `browser` Chrome profile mode (`app.browser: "chrome"`) now defaults an omitted `path` to installed Chrome/Chromium and an omitted `profile_directory` to `"Default"`. `user_data_dir` remains explicit and must be non-default because Chrome 136+ disables remote debugging for default Chrome data directories; Stable/Beta/Dev/Canary/Chromium roots (including trusted Linux environment, Flatpak, and Snap defaults), symlink aliases to them, and non-Chrome browser executables are rejected with remediation instead of timing out or risking cross-brand profile access. Profile and Windows executable discovery use trusted environment sources that ignore repository `.env`, platform path semantics are preserved, executable/profile-root canonicalization races stalled filesystem work against cancellation, the canonical Snap Chromium launcher is admitted only when it resolves to `/usr/bin/snap`, and wrapper-launched Linux Chrome processes are reused through an abort-aware asynchronous `/proc` scan only after kernel executable identity plus exact profile/loopback-CDP guards pass. Cancellation propagates before, during, and after CDP probing, and omitted profile names render as `Default` in the TUI. - Defense-in-depth: when an Anthropic-origin assistant transcript message carrying directly adjacent `thinking`/`redacted_thinking` blocks is persisted, a single bounded warn is emitted per session manager instance — but only in development/test builds, never in production. The diagnostic names only the envelope shape (block count, adjacency presence, provider), never raw thinking text, signatures, redacted payloads, or transcript-path metadata. Storage is never mutated — the send-boundary collapse remains the wire source of truth; this is a read-only observation that helps surface upstream producers of the rejected shape (#4443). ### Fixed - Post-merge repair for #4542: `CHAT_DAEMON_GENERATIONS.discord` 64→65 and `.slack` 67→68 so the `SessionRouter` initial attachment replay change is generation-fenced for already-running Discord and Slack daemons. The semantic guard manifest is regenerated with the corrected generation and declaration digests. diff --git a/packages/coding-agent/src/prompts/tools/browser.md b/packages/coding-agent/src/prompts/tools/browser.md index 8ef2d84af0..18b250e10d 100644 --- a/packages/coding-agent/src/prompts/tools/browser.md +++ b/packages/coding-agent/src/prompts/tools/browser.md @@ -8,7 +8,7 @@ Drives a real Chromium tab with full puppeteer access via JS execution. - `act` — run a list of structured `actions` against an existing tab without writing JS (preferred for routine navigation/interaction). Each step is `{ verb, … }`; verbs: `navigate {url, wait_until?}`, `click {id|selector}`, `type {id|selector, text}`, `fill {selector, value}`, `select {selector, values}`, `press {key, selector?}`, `scroll {dx?, dy?}`, `back`, `wait {selector?|ms?}`, `observe {viewport_only?, include_all?}`, `extract {format?}`, `screenshot`. Address elements by the numeric `id` from a prior `observe` (preferred) or a selector. Steps run in order; the tool returns per-step results. - `run` — execute JS against an existing tab. `code` is the body of an async function with `page`, `browser`, `tab`, `display`, `assert`, `wait` in scope. The return value is JSON-stringified into the tool result; `display(value)` calls accumulate text/images. Use `run` only when an `act` verb does not cover what you need. - Tabs survive across `run` calls and across in-process subagents. Open once, reuse many times. -- Browser kinds: no `app` launches headless Chromium; `app.path` reuses CDP or kills stale same-path processes before spawning — NEVER use it for a daily Chrome profile; use explicit `app.browser: "chrome"` profile mode instead. Saved-profile/CDP automation has access to that profile's cookies and authenticated accounts. Profile mode refuses a matching non-CDP Chrome instead of killing/relaunching it, and `kill: true` can terminate only a Chrome process GJC launched; `app.cdp_url` is externally owned and disconnect-only. CDP must stay on `127.0.0.1`: it grants full browser-account access. +- Browser kinds: no `app` launches headless Chromium; `app.path` reuses CDP or kills stale same-path processes before spawning — NEVER use it for a daily Chrome profile; use explicit `app.browser: "chrome"` profile mode instead. In profile mode, `path` defaults to installed Chrome/Chromium and `profile_directory` defaults to `"Default"`, but `user_data_dir` must name a separate non-default Chrome data directory: Chrome 136+ disables remote debugging for its default data directory. Only Chrome/Chromium executables are admitted; Edge, Brave, Vivaldi, Opera, unknown browser brands, and default Chrome data roots are rejected. Use `app.cdp_url` to attach to an already-authorized browser. Saved-profile/CDP automation has access to that profile's cookies and authenticated accounts. Profile mode refuses a matching non-CDP Chrome instead of killing/relaunching it, and `kill: true` can terminate only a Chrome process GJC launched; `app.cdp_url` is externally owned and disconnect-only. CDP must stay on `127.0.0.1`: it grants full browser-account access. - Inside `run`, `tab` exposes high-level helpers (`goto`, `observe`, `id`, `click`, `type`, `fill`, `press`, `waitFor`, `screenshot`, `extract`, …); reach for `page` (raw puppeteer Page) when they don't cover it. - Selectors accept CSS as well as puppeteer query handlers: `aria/Sign in`, `text/Continue`, `xpath/…`, `pierce/…`. - Runtime diagnostics are opt-in: pass `diagnostics: true` to `open` to subscribe the tab to page `Runtime.exceptionThrown` and `console.error` events. The next successful `act`/`run` response then includes at most 20 `runtimeDiagnostics` entries plus `runtimeDiagnosticsDropped`, then drains them. Entries contain only kind, time, origin-only URL, line/column, and a built-in error class from a fixed allowlist — never path segments, query strings, messages, console arguments, values, or stacks. Output is byte-bounded and marks truncation explicitly. diff --git a/packages/coding-agent/src/tools/browser.ts b/packages/coding-agent/src/tools/browser.ts index 4fb5335bda..c05e79d2b5 100644 --- a/packages/coding-agent/src/tools/browser.ts +++ b/packages/coding-agent/src/tools/browser.ts @@ -1,9 +1,13 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@gajae-code/agent-core"; import { prompt, untilAborted } from "@gajae-code/utils"; import * as z from "zod/v4"; import browserDescription from "../prompts/tools/browser.md" with { type: "text" }; import type { ToolSession } from "../sdk"; import { type BrowserActionStep, compileActionSteps } from "./browser/actions"; +import { isChromeProfileExecutableForLaunch, isEdgeExecutable, resolveSystemChromeForProfile } from "./browser/launch"; +import { chromeUserDataRoots, defaultDiscoveryEnv } from "./browser/profile-discovery"; import { acquireBrowser, type BrowserHandle, type BrowserKind, type BrowserKindTag } from "./browser/registry"; import type { Observation, ScreenshotResult } from "./browser/tab-protocol"; import { acquireTab, dropHeadlessTabs, getTab, releaseAllTabs, releaseTab, runInTab } from "./browser/tab-supervisor"; @@ -19,11 +23,17 @@ export type { Observation, ObservationEntry } from "./browser/tab-protocol"; const DEFAULT_TAB_NAME = "main"; const appSchema = z.object({ - path: z.string().describe("binary path to spawn").optional(), + path: z.string().describe("binary path to spawn (default: the installed Chrome/Chromium)").optional(), cdp_url: z.string().describe("existing cdp endpoint").optional(), browser: z.enum(["chrome"]).describe("existing browser profile mode").optional(), - user_data_dir: z.string().describe("Chrome user data directory containing profiles").optional(), - profile_directory: z.string().describe("Chrome profile directory name, e.g. Profile 10").optional(), + user_data_dir: z + .string() + .describe("non-default Chrome user data directory containing profiles (required for Chrome 136+ CDP)") + .optional(), + profile_directory: z + .string() + .describe('Chrome profile directory name, e.g. "Profile 10" (default "Default")') + .optional(), background: z.boolean().describe("prefer background/hidden Chrome profile launch when supported").optional(), no_focus: z.boolean().describe("avoid focusing Chrome during profile launch when supported").optional(), cdp_port: z.number().int().positive().describe("local CDP port for launched Chrome profile").optional(), @@ -116,30 +126,111 @@ export interface BrowserToolDetails { meta?: OutputMeta; } -export function resolveBrowserKindForTest(params: BrowserParams, session: ToolSession): BrowserKind { - return resolveBrowserKind(params, session); +export function resolveBrowserKindForTest( + params: BrowserParams, + session: ToolSession, + signal?: AbortSignal, +): Promise { + return resolveBrowserKind(params, session, signal); } -function resolveBrowserKind(params: BrowserParams, session: ToolSession): BrowserKind { +/** + * Resolve guarded Chrome profile mode. The executable falls back to the + * installed Chrome/Chromium and the profile directory to `"Default"`. + * Chrome 136+ refuses remote debugging against its default data directory, so + * callers must explicitly provide a non-default user data directory. + */ +async function resolveChromeProfileKind( + app: NonNullable, + session: ToolSession, + signal?: AbortSignal, +): Promise { + const profileDirectory = app.profile_directory ?? "Default"; + const exe = app.path ? resolveToCwd(app.path, session.cwd) : resolveSystemChromeForProfile(); + if (!exe) { + throw new ToolError( + 'No Chrome/Chromium executable found for app.browser "chrome". Install Chrome, or pass app.path with the binary path.', + ); + } + const canonicalExe = await canonicalPath(exe, process.platform, signal); + if (!isChromeProfileExecutableForLaunch(exe, canonicalExe)) { + throw new ToolError( + isEdgeExecutable(canonicalExe) + ? 'app.path for app.browser "chrome" must be Google Chrome or Chromium, not Microsoft Edge. Use Edge with app.path spawn mode and a separate profile, or pass a Chrome/Chromium executable.' + : 'app.path for app.browser "chrome" must be a Google Chrome or Chromium executable. Other Chromium-based browsers must use app.path spawn mode with their own separate profile.', + ); + } + if (!app.user_data_dir) { + throw new ToolError( + 'app.user_data_dir is required for app.browser "chrome". Chrome 136+ disables remote debugging for the default Chrome data directory; pass a separate non-default user data directory, or attach to an already-authorized browser with app.cdp_url.', + ); + } + const userDataDir = resolveToCwd(app.user_data_dir, session.cwd); + if (await isDefaultChromeUserDataDir(userDataDir, signal)) { + throw new ToolError( + `Refusing Chrome's default user data directory ${JSON.stringify(userDataDir)}. Chrome 136+ disables remote debugging there; pass a separate non-default app.user_data_dir, or use app.cdp_url for an already-authorized browser.`, + ); + } + return { + kind: "chrome-profile", + path: exe, + userDataDir, + profileDirectory, + background: app.background ?? false, + noFocus: app.no_focus ?? false, + cdpPort: app.cdp_port, + }; +} + +async function canonicalPath( + candidate: string, + platform: NodeJS.Platform = process.platform, + signal?: AbortSignal, +): Promise { + let resolved = platform === "win32" ? path.win32.resolve(candidate) : path.resolve(candidate); + if (platform === process.platform) { + throwIfAborted(signal); + try { + resolved = signal ? await untilAborted(signal, () => fs.realpath(resolved)) : await fs.realpath(resolved); + } catch {} + throwIfAborted(signal); + } + return platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function isDefaultChromeUserDataDir(candidate: string, signal?: AbortSignal): Promise { + return isDefaultChromeUserDataDirForTest( + candidate, + chromeUserDataRoots(defaultDiscoveryEnv(() => false)), + process.platform, + signal, + ); +} + +export async function isDefaultChromeUserDataDirForTest( + candidate: string, + roots: readonly string[], + platform: NodeJS.Platform = process.platform, + signal?: AbortSignal, +): Promise { + const canonicalCandidate = await canonicalPath(candidate, platform, signal); + for (const root of roots) { + if ((await canonicalPath(root, platform, signal)) === canonicalCandidate) return true; + } + return false; +} + +async function resolveBrowserKind( + params: BrowserParams, + session: ToolSession, + signal?: AbortSignal, +): Promise { const app = params.app; if (app?.cdp_url) { return { kind: "connected", cdpUrl: app.cdp_url.replace(/\/+$/, "") }; } if (app?.browser === "chrome") { - if (!app.path) throw new ToolError('app.path is required when app.browser is "chrome".'); - if (!app.user_data_dir) throw new ToolError('app.user_data_dir is required when app.browser is "chrome".'); - if (!app.profile_directory) - throw new ToolError('app.profile_directory is required when app.browser is "chrome".'); - const exe = resolveToCwd(app.path, session.cwd); - return { - kind: "chrome-profile", - path: exe, - userDataDir: resolveToCwd(app.user_data_dir, session.cwd), - profileDirectory: app.profile_directory, - background: app.background ?? false, - noFocus: app.no_focus ?? false, - cdpPort: app.cdp_port, - }; + return resolveChromeProfileKind(app, session, signal); } if (app?.path) { const exe = resolveToCwd(app.path, session.cwd); @@ -217,7 +308,7 @@ export class BrowserTool implements AgentTool> { - const kind = resolveBrowserKind(params, this.session); + const kind = await resolveBrowserKind(params, this.session, signal); details.browser = kind.kind; // If a tab with this name already exists on a different browser kind, fail fast — caller must close first. diff --git a/packages/coding-agent/src/tools/browser/attach.ts b/packages/coding-agent/src/tools/browser/attach.ts index 18c43b03e0..b24ecc85d6 100644 --- a/packages/coding-agent/src/tools/browser/attach.ts +++ b/packages/coding-agent/src/tools/browser/attach.ts @@ -1,8 +1,10 @@ +import * as fs from "node:fs/promises"; import * as net from "node:net"; import * as path from "node:path"; import { nativeProcessBindings } from "@gajae-code/utils/native-process"; import type { Browser, Page } from "puppeteer-core"; import { ToolError, throwIfAborted } from "../tool-errors"; +import { isChromeProfileExecutable } from "./launch"; const ATTACH_TARGET_SKIP_PATTERN = /request[\s_-]?handler|devtools|background[\s_-]?(?:page|host)|service[\s_-]?worker/i; @@ -133,8 +135,10 @@ async function probeCdpAt(port: number, signal?: AbortSignal): Promise try { const res = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: probeSignal }); await res.body?.cancel(); + throwIfAborted(signal); return res.ok; } catch { + throwIfAborted(signal); return false; } } @@ -181,6 +185,7 @@ export async function findReusableCdp( if (await probeCdpAt(port, signal)) { return { cdpUrl: `http://127.0.0.1:${port}`, pid: proc.pid }; } + throwIfAborted(signal); } return null; } @@ -196,10 +201,64 @@ export async function findRunningChromeProfile( profile: { userDataDir: string; profileDirectory: string }, signal?: AbortSignal, ): Promise { - const candidates = nativeProcessBindings() - .Process.fromPath(exe) - .filter(p => p.status() === nativeProcessBindings().ProcessStatus.Running); + return findRunningChromeProfileWithOptions(exe, profile, signal, {}); +} + +interface ProfileProcessScanOptions { + platform?: NodeJS.Platform; + linuxPids?: readonly number[]; + linuxExecutablePaths?: ReadonlyMap; + signal?: AbortSignal; +} + +async function liveLinuxPids(): Promise { + try { + const entries = await fs.readdir("/proc", { withFileTypes: true }); + return entries + .filter(entry => entry.isDirectory() && /^\d+$/.test(entry.name)) + .map(entry => Number.parseInt(entry.name, 10)); + } catch { + return []; + } +} + +async function linuxExecutablePath(pid: number, options: ProfileProcessScanOptions): Promise { + const injected = options.linuxExecutablePaths?.get(pid); + if (injected) return injected; + try { + return await fs.readlink(`/proc/${pid}/exe`); + } catch { + return null; + } +} + +async function findRunningChromeProfileWithOptions( + exe: string, + profile: { userDataDir: string; profileDirectory: string }, + signal: AbortSignal | undefined, + options: ProfileProcessScanOptions, +): Promise { + const bindings = nativeProcessBindings(); + const candidates = bindings.Process.fromPath(exe).filter(p => p.status() === bindings.ProcessStatus.Running); + const seenPids = new Set(candidates.map(candidate => candidate.pid)); + if ((options.platform ?? process.platform) === "linux" && candidates.length === 0) { + throwIfAborted(signal); + const linuxPids = options.linuxPids ?? (await liveLinuxPids()); + throwIfAborted(signal); + for (const pid of linuxPids) { + throwIfAborted(signal); + if (seenPids.has(pid)) continue; + const candidate = bindings.Process.fromPid(pid); + if (!candidate || candidate.status() !== bindings.ProcessStatus.Running) continue; + const executablePath = await linuxExecutablePath(pid, options); + throwIfAborted(signal); + if (!executablePath || !isChromeProfileExecutable(executablePath)) continue; + seenPids.add(pid); + candidates.push(candidate); + } + } for (const proc of candidates) { + throwIfAborted(signal); let args: string[]; try { args = proc.args(); @@ -216,12 +275,21 @@ export async function findRunningChromeProfile( if (await probeCdpAt(port, signal)) { return { pid: proc.pid, cdpUrl: `http://127.0.0.1:${port}` }; } + throwIfAborted(signal); } return { pid: proc.pid, cdpUrl: null }; } return null; } +export function findRunningChromeProfileForTest( + exe: string, + profile: { userDataDir: string; profileDirectory: string }, + options: ProfileProcessScanOptions, +): Promise { + return findRunningChromeProfileWithOptions(exe, profile, options.signal, options); +} + /** * Pick the best page target on an attached browser. Without a matcher, prefer * a page that doesn't look like a helper window (devtools, request handler, diff --git a/packages/coding-agent/src/tools/browser/launch.ts b/packages/coding-agent/src/tools/browser/launch.ts index e0f7a77d32..256217cbc8 100644 --- a/packages/coding-agent/src/tools/browser/launch.ts +++ b/packages/coding-agent/src/tools/browser/launch.ts @@ -108,12 +108,18 @@ export function resolveBrowserEnvOverridesForTest(): { proxy: string | undefined; proxyBypassLoopback: boolean; ignoreCertErrors: boolean; + programFiles: string | undefined; + programFilesX86: string | undefined; + localAppData: string | undefined; } { return { executablePath: trustedBrowserEnv("PUPPETEER_EXECUTABLE_PATH"), proxy: trustedBrowserEnv("PUPPETEER_PROXY"), proxyBypassLoopback: browserLaunchFlagEnabled("PUPPETEER_PROXY_BYPASS_LOOPBACK"), ignoreCertErrors: browserLaunchFlagEnabled("PUPPETEER_PROXY_IGNORE_CERT_ERRORS"), + programFiles: trustedBrowserEnv("ProgramFiles"), + programFilesX86: trustedBrowserEnv("ProgramFiles(x86)"), + localAppData: trustedBrowserEnv("LOCALAPPDATA"), }; } @@ -208,7 +214,16 @@ function systemChromiumCandidates(): string[] { break; } case "linux": { - const names = ["google-chrome-stable", "google-chrome", "chromium", "chromium-browser", "chrome"]; + const names = [ + "google-chrome-stable", + "google-chrome", + "google-chrome-beta", + "google-chrome-unstable", + "google-chrome-canary", + "chromium", + "chromium-browser", + "chrome", + ]; for (const name of names) { const found = $which(name); if (found) candidates.push(found); @@ -216,6 +231,9 @@ function systemChromiumCandidates(): string[] { candidates.push( "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", + "/usr/bin/google-chrome-beta", + "/usr/bin/google-chrome-unstable", + "/usr/bin/google-chrome-canary", "/usr/bin/chromium", "/usr/bin/chromium-browser", "/snap/bin/chromium", @@ -232,13 +250,20 @@ function systemChromiumCandidates(): string[] { break; } case "win32": { - const programFiles = process.env.ProgramFiles ?? "C:\\Program Files"; - const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)"; - const localAppData = process.env.LOCALAPPDATA ?? path.join(home, "AppData\\Local"); + const programFiles = trustedBrowserEnv("ProgramFiles") ?? "C:\\Program Files"; + const programFilesX86 = trustedBrowserEnv("ProgramFiles(x86)") ?? "C:\\Program Files (x86)"; + const localAppData = trustedBrowserEnv("LOCALAPPDATA") ?? path.join(home, "AppData\\Local"); candidates.push( path.join(programFiles, "Google\\Chrome\\Application\\chrome.exe"), path.join(programFilesX86, "Google\\Chrome\\Application\\chrome.exe"), path.join(localAppData, "Google\\Chrome\\Application\\chrome.exe"), + path.join(programFiles, "Google\\Chrome Beta\\Application\\chrome.exe"), + path.join(programFilesX86, "Google\\Chrome Beta\\Application\\chrome.exe"), + path.join(localAppData, "Google\\Chrome Beta\\Application\\chrome.exe"), + path.join(programFiles, "Google\\Chrome Dev\\Application\\chrome.exe"), + path.join(programFilesX86, "Google\\Chrome Dev\\Application\\chrome.exe"), + path.join(localAppData, "Google\\Chrome Dev\\Application\\chrome.exe"), + path.join(localAppData, "Google\\Chrome SxS\\Application\\chrome.exe"), path.join(programFiles, "Chromium\\Application\\chrome.exe"), path.join(localAppData, "Chromium\\Application\\chrome.exe"), path.join(programFiles, "Microsoft\\Edge\\Application\\msedge.exe"), @@ -250,22 +275,70 @@ function systemChromiumCandidates(): string[] { return candidates; } -function resolveSystemChromium(): string | undefined { - if (resolvedChromium !== undefined) return resolvedChromium ?? undefined; +function firstExecutableCandidate(candidates: string[], accept: (candidate: string) => boolean): string | undefined { const seen = new Set(); - for (const candidate of systemChromiumCandidates()) { + for (const candidate of candidates) { if (!candidate || seen.has(candidate)) continue; seen.add(candidate); - if (isExecutableFile(candidate)) { - resolvedChromium = candidate; - logger.debug("Using system Chrome/Chromium", { path: candidate }); - return candidate; - } + if (accept(candidate) && isExecutableFile(candidate)) return candidate; } - resolvedChromium = null; return undefined; } +function resolveSystemChromium(): string | undefined { + if (resolvedChromium !== undefined) return resolvedChromium ?? undefined; + const found = firstExecutableCandidate(systemChromiumCandidates(), () => true); + resolvedChromium = found ?? null; + if (found) logger.debug("Using system Chrome/Chromium", { path: found }); + return found; +} + +/** Edge is Chromium-based but keeps its own profile format under its own user data root. */ +const EDGE_EXECUTABLE_PATTERN = + /(?:^|[/\\])(?:msedge(?:\.exe)?|microsoft-edge(?:-(?:stable|beta|dev|canary))?|com\.microsoft\.Edge|Microsoft Edge(?: Beta| Dev| Canary)?)$/i; + +/** True for a Microsoft Edge executable path (excluded from Chrome profile mode). */ +export function isEdgeExecutable(candidate: string): boolean { + return EDGE_EXECUTABLE_PATTERN.test(candidate); +} + +const CHROME_PROFILE_EXECUTABLE_PATTERN = + /(?:^|[/\\])(?:google-chrome(?:-(?:stable|beta|unstable|canary))?|chromium(?:-browser)?|chrome(?:\.exe)?|Google Chrome(?: Beta| Dev| Canary)?|Chromium|com\.google\.Chrome|org\.chromium\.Chromium)$/i; + +/** True only for executable names owned by Google Chrome or Chromium. */ +export function isChromeProfileExecutable(candidate: string): boolean { + return CHROME_PROFILE_EXECUTABLE_PATTERN.test(candidate); +} + +/** + * Validate the executable identity used for profile mode. Most symlinks are + * judged by their resolved target so a renamed cross-brand browser cannot pass + * by alias. Snap's canonical `/snap/bin/chromium` launcher is the exception: + * it resolves to the generic `/usr/bin/snap` dispatcher by design. + */ +export function isChromeProfileExecutableForLaunch(candidate: string, resolvedCandidate: string): boolean { + return ( + (path.posix.normalize(candidate) === "/snap/bin/chromium" && + path.posix.normalize(resolvedCandidate) === "/usr/bin/snap") || + isChromeProfileExecutable(resolvedCandidate) + ); +} + +let resolvedProfileChrome: string | null | undefined; // undefined = unchecked; null = not found + +/** + * Installed Chrome/Chromium executable for saved-profile mode, or undefined when + * none is present. Edge is excluded on purpose: a discovered Chrome user data + * directory must never be opened by a different browser brand. + */ +export function resolveSystemChromeForProfile(): string | undefined { + if (resolvedProfileChrome !== undefined) return resolvedProfileChrome ?? undefined; + const found = firstExecutableCandidate(systemChromiumCandidates(), isChromeProfileExecutable); + resolvedProfileChrome = found ?? null; + if (found) logger.debug("Using system Chrome/Chromium for profile mode", { path: found }); + return found; +} + export interface LaunchHeadlessOptions { headless: boolean; viewport?: { width: number; height: number; deviceScaleFactor?: number }; diff --git a/packages/coding-agent/src/tools/browser/profile-discovery.ts b/packages/coding-agent/src/tools/browser/profile-discovery.ts index 6b4710cd60..620a79501a 100644 --- a/packages/coding-agent/src/tools/browser/profile-discovery.ts +++ b/packages/coding-agent/src/tools/browser/profile-discovery.ts @@ -10,6 +10,7 @@ import * as os from "node:os"; import * as path from "node:path"; +import { $credentialEnv } from "@gajae-code/utils/env"; export interface DiscoveryEnv { platform: NodeJS.Platform; @@ -18,19 +19,55 @@ export interface DiscoveryEnv { exists: (p: string) => boolean; /** Windows LOCALAPPDATA override (tests / non-default installs). */ localAppData?: string; + /** Linux Chrome-specific default user-data override. */ + chromeUserDataDir?: string; + /** Linux Chrome config-home override (takes precedence over XDG_CONFIG_HOME). */ + chromeConfigHome?: string; + /** Linux XDG config-home override. */ + xdgConfigHome?: string; +} + +function discoveryPath(env: DiscoveryEnv): typeof path.posix { + return env.platform === "win32" ? path.win32 : path.posix; } /** Candidate Chrome user-data roots for the platform (most common first). */ export function chromeUserDataRoots(env: DiscoveryEnv): string[] { + const platformPath = discoveryPath(env); switch (env.platform) { case "darwin": - return [path.join(env.home, "Library", "Application Support", "Google", "Chrome")]; + return [ + platformPath.join(env.home, "Library", "Application Support", "Google", "Chrome"), + platformPath.join(env.home, "Library", "Application Support", "Google", "Chrome Beta"), + platformPath.join(env.home, "Library", "Application Support", "Google", "Chrome Dev"), + platformPath.join(env.home, "Library", "Application Support", "Google", "Chrome Canary"), + platformPath.join(env.home, "Library", "Application Support", "Chromium"), + ]; case "win32": { - const localAppData = env.localAppData ?? path.join(env.home, "AppData", "Local"); - return [path.join(localAppData, "Google", "Chrome", "User Data")]; + const localAppData = env.localAppData ?? platformPath.join(env.home, "AppData", "Local"); + return [ + platformPath.join(localAppData, "Google", "Chrome", "User Data"), + platformPath.join(localAppData, "Google", "Chrome Beta", "User Data"), + platformPath.join(localAppData, "Google", "Chrome Dev", "User Data"), + platformPath.join(localAppData, "Google", "Chrome SxS", "User Data"), + platformPath.join(localAppData, "Chromium", "User Data"), + ]; + } + default: { + const configHome = env.chromeConfigHome ?? env.xdgConfigHome ?? platformPath.join(env.home, ".config"); + return [ + ...(env.chromeUserDataDir ? [env.chromeUserDataDir] : []), + platformPath.join(configHome, "google-chrome"), + platformPath.join(configHome, "google-chrome-beta"), + platformPath.join(configHome, "google-chrome-unstable"), + platformPath.join(configHome, "google-chrome-canary"), + platformPath.join(configHome, "chromium"), + platformPath.join(env.home, ".var", "app", "com.google.Chrome", "config", "google-chrome"), + platformPath.join(env.home, ".var", "app", "org.chromium.Chromium", "config", "chromium"), + platformPath.join(env.home, "snap", "chromium", "common", "chromium"), + platformPath.join(env.home, "snap", "chromium", "current", ".config", "chromium"), + ]; } - default: - return [path.join(env.home, ".config", "google-chrome"), path.join(env.home, ".config", "chromium")]; } } @@ -48,8 +85,9 @@ export function discoverDefaultChromeProfile( env: DiscoveryEnv, profileDirectory = "Default", ): DiscoveredProfile | null { + const platformPath = discoveryPath(env); for (const userDataDir of chromeUserDataRoots(env)) { - const profileDir = path.join(userDataDir, profileDirectory); + const profileDir = platformPath.join(userDataDir, profileDirectory); if (env.exists(profileDir)) { return { userDataDir, profileDirectory, profileDir }; } @@ -59,5 +97,17 @@ export function discoverDefaultChromeProfile( /** Convenience wrapper using the live OS environment + fs. */ export function defaultDiscoveryEnv(exists: (p: string) => boolean): DiscoveryEnv { - return { platform: process.platform, home: os.homedir(), exists }; + const localAppData = $credentialEnv("LOCALAPPDATA"); + const chromeUserDataDir = $credentialEnv("CHROME_USER_DATA_DIR"); + const chromeConfigHome = $credentialEnv("CHROME_CONFIG_HOME"); + const xdgConfigHome = $credentialEnv("XDG_CONFIG_HOME"); + return { + platform: process.platform, + home: os.homedir(), + exists, + ...(localAppData ? { localAppData } : {}), + ...(chromeUserDataDir ? { chromeUserDataDir } : {}), + ...(chromeConfigHome ? { chromeConfigHome } : {}), + ...(xdgConfigHome ? { xdgConfigHome } : {}), + }; } diff --git a/packages/coding-agent/src/tools/browser/render.ts b/packages/coding-agent/src/tools/browser/render.ts index cc77680831..0dc044a960 100644 --- a/packages/coding-agent/src/tools/browser/render.ts +++ b/packages/coding-agent/src/tools/browser/render.ts @@ -42,7 +42,7 @@ interface BrowserRenderContext { function describeBrowser(args: BrowserRenderArgs, details: BrowserToolDetails | undefined): string | undefined { if (args.app?.cdp_url) return `connected ${args.app.cdp_url}`; - if (args.app?.browser === "chrome") return `Chrome profile ${args.app.profile_directory ?? ""}`; + if (args.app?.browser === "chrome") return `Chrome profile ${args.app.profile_directory ?? "Default"}`; if (args.app?.path) return `spawned ${shortenPath(args.app.path)}`; switch (details?.browser) { case "headless": @@ -56,6 +56,13 @@ function describeBrowser(args: BrowserRenderArgs, details: BrowserToolDetails | } } +export function describeBrowserForTest( + args: BrowserRenderArgs, + details: BrowserToolDetails | undefined, +): string | undefined { + return describeBrowser(args, details); +} + function tabLabel(args: BrowserRenderArgs, details: BrowserToolDetails | undefined): string { const name = details?.name ?? args.name ?? "main"; return `tab ${JSON.stringify(name)}`; diff --git a/packages/coding-agent/src/tools/tool-catalog.generated.ts b/packages/coding-agent/src/tools/tool-catalog.generated.ts index 1975825b29..e441d5b427 100644 --- a/packages/coding-agent/src/tools/tool-catalog.generated.ts +++ b/packages/coding-agent/src/tools/tool-catalog.generated.ts @@ -1073,7 +1073,7 @@ export const TOOL_CATALOG: Readonly> = { "browser": { "name": "browser", "label": "Browser", - "description": "Drives a real Chromium tab with full puppeteer access via JS execution.\n\n\n- For static web content (articles, docs, issues/PRs, JSON, PDFs, feeds), prefer the `read` tool with a URL. Use this tool only when you need JS execution, authentication, or interactive actions.\n- Four actions:\n - `open` — acquire (or reuse) a named tab. `name` defaults to `\"main\"`. Optional `url`, `viewport`, and `dialogs: \"accept\" | \"dismiss\"` (auto-handles `alert`/`confirm`/`beforeunload`). The `app` field selects the browser kind (spawned binary, saved Chrome profile, or existing CDP endpoint); omitted means headless Chromium with stealth patches.\n - `close` — release a tab by `name`, or every tab with `all: true`. `kill: true` also terminates a spawned-app process tree.\n - `act` — run a list of structured `actions` against an existing tab without writing JS (preferred for routine navigation/interaction). Each step is `{ verb, … }`; verbs: `navigate {url, wait_until?}`, `click {id|selector}`, `type {id|selector, text}`, `fill {selector, value}`, `select {selector, values}`, `press {key, selector?}`, `scroll {dx?, dy?}`, `back`, `wait {selector?|ms?}`, `observe {viewport_only?, include_all?}`, `extract {format?}`, `screenshot`. Address elements by the numeric `id` from a prior `observe` (preferred) or a selector. Steps run in order; the tool returns per-step results.\n - `run` — execute JS against an existing tab. `code` is the body of an async function with `page`, `browser`, `tab`, `display`, `assert`, `wait` in scope. The return value is JSON-stringified into the tool result; `display(value)` calls accumulate text/images. Use `run` only when an `act` verb does not cover what you need.\n- Tabs survive across `run` calls and across in-process subagents. Open once, reuse many times.\n- Browser kinds: no `app` launches headless Chromium; `app.path` reuses CDP or kills stale same-path processes before spawning — NEVER use it for a daily Chrome profile; use explicit `app.browser: \"chrome\"` profile mode instead. Saved-profile/CDP automation has access to that profile's cookies and authenticated accounts. Profile mode refuses a matching non-CDP Chrome instead of killing/relaunching it, and `kill: true` can terminate only a Chrome process GJC launched; `app.cdp_url` is externally owned and disconnect-only. CDP must stay on `127.0.0.1`: it grants full browser-account access.\n- Inside `run`, `tab` exposes high-level helpers (`goto`, `observe`, `id`, `click`, `type`, `fill`, `press`, `waitFor`, `screenshot`, `extract`, …); reach for `page` (raw puppeteer Page) when they don't cover it.\n- Selectors accept CSS as well as puppeteer query handlers: `aria/Sign in`, `text/Continue`, `xpath/…`, `pierce/…`.\n- Runtime diagnostics are opt-in: pass `diagnostics: true` to `open` to subscribe the tab to page `Runtime.exceptionThrown` and `console.error` events. The next successful `act`/`run` response then includes at most 20 `runtimeDiagnostics` entries plus `runtimeDiagnosticsDropped`, then drains them. Entries contain only kind, time, origin-only URL, line/column, and a built-in error class from a fixed allowlist — never path segments, query strings, messages, console arguments, values, or stacks. Output is byte-bounded and marks truncation explicitly.\n- Full reference — helpers, browser kinds, CDP/security details, and more examples — read `gjc://tools/browser.md`.\n\n\n\n- You MUST call `open` before `run` or `act`. Neither implicitly creates a tab.\n- You MUST observe before taking a screenshot to understand page state; screenshot only when visual appearance matters.\n- After a `tab.goto()` or any navigation, prior element ids from `tab.observe()` are invalidated. Re-observe before referencing them.\n- `code` runs with full Node access. Treat it as your code, not sandboxed code.\n\n\n\n# Open a tab and read structured page data\n`{\"action\":\"open\",\"name\":\"docs\",\"url\":\"https://example.com\"}`\n`{\"action\":\"act\",\"name\":\"docs\",\"actions\":[{\"verb\":\"observe\"}]}`\n\n# Click an observed element, then fill and submit a form\n`{\"action\":\"act\",\"name\":\"docs\",\"actions\":[{\"verb\":\"click\",\"id\":12},{\"verb\":\"fill\",\"selector\":\"input[name=email]\",\"value\":\"me@example.com\"},{\"verb\":\"click\",\"selector\":\"text/Continue\"}]}`\n\n# Use `run` only when `act` has no suitable verb\n`{\"action\":\"run\",\"name\":\"docs\",\"code\":\"const count = await page.locator('canvas').count(); return { count };\"}`\n\n\n\n- Per call: any `display(value)` outputs (text/images) followed by the JSON-stringified return value of the `code` function. `run` always produces at least a status line.\n", + "description": "Drives a real Chromium tab with full puppeteer access via JS execution.\n\n\n- For static web content (articles, docs, issues/PRs, JSON, PDFs, feeds), prefer the `read` tool with a URL. Use this tool only when you need JS execution, authentication, or interactive actions.\n- Four actions:\n - `open` — acquire (or reuse) a named tab. `name` defaults to `\"main\"`. Optional `url`, `viewport`, and `dialogs: \"accept\" | \"dismiss\"` (auto-handles `alert`/`confirm`/`beforeunload`). The `app` field selects the browser kind (spawned binary, saved Chrome profile, or existing CDP endpoint); omitted means headless Chromium with stealth patches.\n - `close` — release a tab by `name`, or every tab with `all: true`. `kill: true` also terminates a spawned-app process tree.\n - `act` — run a list of structured `actions` against an existing tab without writing JS (preferred for routine navigation/interaction). Each step is `{ verb, … }`; verbs: `navigate {url, wait_until?}`, `click {id|selector}`, `type {id|selector, text}`, `fill {selector, value}`, `select {selector, values}`, `press {key, selector?}`, `scroll {dx?, dy?}`, `back`, `wait {selector?|ms?}`, `observe {viewport_only?, include_all?}`, `extract {format?}`, `screenshot`. Address elements by the numeric `id` from a prior `observe` (preferred) or a selector. Steps run in order; the tool returns per-step results.\n - `run` — execute JS against an existing tab. `code` is the body of an async function with `page`, `browser`, `tab`, `display`, `assert`, `wait` in scope. The return value is JSON-stringified into the tool result; `display(value)` calls accumulate text/images. Use `run` only when an `act` verb does not cover what you need.\n- Tabs survive across `run` calls and across in-process subagents. Open once, reuse many times.\n- Browser kinds: no `app` launches headless Chromium; `app.path` reuses CDP or kills stale same-path processes before spawning — NEVER use it for a daily Chrome profile; use explicit `app.browser: \"chrome\"` profile mode instead. In profile mode, `path` defaults to installed Chrome/Chromium and `profile_directory` defaults to `\"Default\"`, but `user_data_dir` must name a separate non-default Chrome data directory: Chrome 136+ disables remote debugging for its default data directory. Only Chrome/Chromium executables are admitted; Edge, Brave, Vivaldi, Opera, unknown browser brands, and default Chrome data roots are rejected. Use `app.cdp_url` to attach to an already-authorized browser. Saved-profile/CDP automation has access to that profile's cookies and authenticated accounts. Profile mode refuses a matching non-CDP Chrome instead of killing/relaunching it, and `kill: true` can terminate only a Chrome process GJC launched; `app.cdp_url` is externally owned and disconnect-only. CDP must stay on `127.0.0.1`: it grants full browser-account access.\n- Inside `run`, `tab` exposes high-level helpers (`goto`, `observe`, `id`, `click`, `type`, `fill`, `press`, `waitFor`, `screenshot`, `extract`, …); reach for `page` (raw puppeteer Page) when they don't cover it.\n- Selectors accept CSS as well as puppeteer query handlers: `aria/Sign in`, `text/Continue`, `xpath/…`, `pierce/…`.\n- Runtime diagnostics are opt-in: pass `diagnostics: true` to `open` to subscribe the tab to page `Runtime.exceptionThrown` and `console.error` events. The next successful `act`/`run` response then includes at most 20 `runtimeDiagnostics` entries plus `runtimeDiagnosticsDropped`, then drains them. Entries contain only kind, time, origin-only URL, line/column, and a built-in error class from a fixed allowlist — never path segments, query strings, messages, console arguments, values, or stacks. Output is byte-bounded and marks truncation explicitly.\n- Full reference — helpers, browser kinds, CDP/security details, and more examples — read `gjc://tools/browser.md`.\n\n\n\n- You MUST call `open` before `run` or `act`. Neither implicitly creates a tab.\n- You MUST observe before taking a screenshot to understand page state; screenshot only when visual appearance matters.\n- After a `tab.goto()` or any navigation, prior element ids from `tab.observe()` are invalidated. Re-observe before referencing them.\n- `code` runs with full Node access. Treat it as your code, not sandboxed code.\n\n\n\n# Open a tab and read structured page data\n`{\"action\":\"open\",\"name\":\"docs\",\"url\":\"https://example.com\"}`\n`{\"action\":\"act\",\"name\":\"docs\",\"actions\":[{\"verb\":\"observe\"}]}`\n\n# Click an observed element, then fill and submit a form\n`{\"action\":\"act\",\"name\":\"docs\",\"actions\":[{\"verb\":\"click\",\"id\":12},{\"verb\":\"fill\",\"selector\":\"input[name=email]\",\"value\":\"me@example.com\"},{\"verb\":\"click\",\"selector\":\"text/Continue\"}]}`\n\n# Use `run` only when `act` has no suitable verb\n`{\"action\":\"run\",\"name\":\"docs\",\"code\":\"const count = await page.locator('canvas').count(); return { count };\"}`\n\n\n\n- Per call: any `display(value)` outputs (text/images) followed by the JSON-stringified return value of the `code` function. `run` always produces at least a status line.\n", "parameters": { "type": "object", "properties": { @@ -1100,7 +1100,7 @@ export const TOOL_CATALOG: Readonly> = { "properties": { "path": { "type": "string", - "description": "binary path to spawn" + "description": "binary path to spawn (default: the installed Chrome/Chromium)" }, "cdp_url": { "type": "string", @@ -1115,11 +1115,11 @@ export const TOOL_CATALOG: Readonly> = { }, "user_data_dir": { "type": "string", - "description": "Chrome user data directory containing profiles" + "description": "non-default Chrome user data directory containing profiles (required for Chrome 136+ CDP)" }, "profile_directory": { "type": "string", - "description": "Chrome profile directory name, e.g. Profile 10" + "description": "Chrome profile directory name, e.g. \"Profile 10\" (default \"Default\")" }, "background": { "type": "boolean", diff --git a/packages/coding-agent/test/fixtures/browser-env-probe.ts b/packages/coding-agent/test/fixtures/browser-env-probe.ts index 725e37c72a..deebaac5e6 100644 --- a/packages/coding-agent/test/fixtures/browser-env-probe.ts +++ b/packages/coding-agent/test/fixtures/browser-env-probe.ts @@ -3,5 +3,17 @@ // the env module parses `projectEnv` at load time from `process.cwd()`, so the // trust boundary can only be exercised from a separate process. import { resolveBrowserEnvOverridesForTest } from "@gajae-code/coding-agent/tools/browser/launch"; +import { defaultDiscoveryEnv } from "@gajae-code/coding-agent/tools/browser/profile-discovery"; -console.log(JSON.stringify(resolveBrowserEnvOverridesForTest())); +const discovery = defaultDiscoveryEnv(() => false); +console.log( + JSON.stringify({ + ...resolveBrowserEnvOverridesForTest(), + profileEnv: { + localAppData: discovery.localAppData, + chromeUserDataDir: discovery.chromeUserDataDir, + chromeConfigHome: discovery.chromeConfigHome, + xdgConfigHome: discovery.xdgConfigHome, + }, + }), +); diff --git a/packages/coding-agent/test/tools/browser-chrome-profile.test.ts b/packages/coding-agent/test/tools/browser-chrome-profile.test.ts index 7ef001a21f..ad24e64f94 100644 --- a/packages/coding-agent/test/tools/browser-chrome-profile.test.ts +++ b/packages/coding-agent/test/tools/browser-chrome-profile.test.ts @@ -5,15 +5,21 @@ import * as path from "node:path"; import { Process, ProcessStatus } from "@gajae-code/natives"; import type { Browser } from "puppeteer-core"; import type { ToolSession } from "../../src/sdk"; -import { type BrowserParams, resolveBrowserKindForTest } from "../../src/tools/browser"; +import { + type BrowserParams, + isDefaultChromeUserDataDirForTest, + resolveBrowserKindForTest, +} from "../../src/tools/browser"; import * as attach from "../../src/tools/browser/attach"; import { argsMatchChromeProfileForTest, findCdpAddressInArgsForTest, findCdpPortInArgsForTest, + findRunningChromeProfileForTest, isSafeCdpAddressForTest, } from "../../src/tools/browser/attach"; import * as launch from "../../src/tools/browser/launch"; +import { chromeUserDataRoots } from "../../src/tools/browser/profile-discovery"; import { type AcquireBrowserOptions, type BrowserHandle, @@ -22,6 +28,7 @@ import { openChromeProfileHandle, releaseBrowser, } from "../../src/tools/browser/registry"; +import { describeBrowserForTest } from "../../src/tools/browser/render"; function makeSession(cwd: string): ToolSession { return { @@ -73,6 +80,47 @@ describe("Chrome profile browser mode (#809)", () => { vi.restoreAllMocks(); }); + it("never treats Edge as the Chrome binary for saved-profile mode", () => { + expect(launch.isEdgeExecutable("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge")).toBe(true); + expect(launch.isEdgeExecutable("C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe")).toBe(true); + expect(launch.isEdgeExecutable("/usr/bin/microsoft-edge-stable")).toBe(true); + expect(launch.isEdgeExecutable("/usr/bin/microsoft-edge-beta")).toBe(true); + expect(launch.isEdgeExecutable("/usr/bin/microsoft-edge-dev")).toBe(true); + expect(launch.isEdgeExecutable("/var/lib/flatpak/exports/bin/com.microsoft.Edge")).toBe(true); + expect(launch.isEdgeExecutable("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")).toBe(false); + expect(launch.isEdgeExecutable("/usr/bin/chromium")).toBe(false); + }); + + it("accepts only Chrome and Chromium executable brands for profile mode", () => { + for (const executable of [ + "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "/usr/bin/google-chrome-unstable", + "/usr/bin/chromium-browser", + "/var/lib/flatpak/exports/bin/com.google.Chrome", + ]) { + expect(launch.isChromeProfileExecutable(executable)).toBe(true); + } + for (const executable of ["/usr/bin/brave-browser", "/usr/bin/vivaldi", "/usr/bin/opera", "/usr/bin/firefox"]) { + expect(launch.isChromeProfileExecutable(executable)).toBe(false); + } + }); + + it("preserves the canonical Snap Chromium launcher without trusting arbitrary aliases", () => { + expect(launch.isChromeProfileExecutableForLaunch("/snap/bin/chromium", "/usr/bin/snap")).toBe(true); + expect(launch.isChromeProfileExecutableForLaunch("/snap/bin/chromium", "/usr/bin/microsoft-edge")).toBe(false); + expect(launch.isChromeProfileExecutableForLaunch("/tmp/chromium", "/usr/bin/brave-browser")).toBe(false); + expect(launch.isChromeProfileExecutableForLaunch("/usr/bin/google-chrome", "/opt/google/chrome/chrome")).toBe( + true, + ); + }); + + it("renders the effective Default profile name when it is omitted", () => { + expect(describeBrowserForTest({ action: "open", app: { browser: "chrome" } }, undefined)).toBe( + "Chrome profile Default", + ); + }); + it("parses Chromium CDP and profile argv forms", () => { expect(findCdpPortInArgsForTest(["--remote-debugging-port=9222"])).toBe(9222); expect(findCdpPortInArgsForTest(["--remote-debugging-port", "9223"])).toBe(9223); @@ -129,7 +177,7 @@ describe("Chrome profile browser mode (#809)", () => { ]); }); - it("resolves app.browser chrome config using repo-consistent snake_case fields", () => { + it("resolves app.browser chrome config using repo-consistent snake_case fields", async () => { const params: BrowserParams = { action: "open", app: { @@ -142,7 +190,7 @@ describe("Chrome profile browser mode (#809)", () => { cdp_port: 9444, }, }; - const kind = resolveBrowserKindForTest(params, makeSession("/work")); + const kind = await resolveBrowserKindForTest(params, makeSession("/work")); expect(kind).toEqual({ kind: "chrome-profile", @@ -153,6 +201,228 @@ describe("Chrome profile browser mode (#809)", () => { noFocus: true, cdpPort: 9444, }); + expect(params.app).toEqual({ + browser: "chrome", + path: "bin/google-chrome", + user_data_dir: "profiles/chrome", + profile_directory: "Profile 10", + background: true, + no_focus: true, + cdp_port: 9444, + }); + }); + + it("defaults the executable and profile name when a non-default user data directory is explicit", async () => { + vi.spyOn(launch, "resolveSystemChromeForProfile").mockReturnValue("/usr/bin/google-chrome"); + + const kind = await resolveBrowserKindForTest( + { action: "open", app: { browser: "chrome", user_data_dir: "profiles/automation" } }, + makeSession("/work"), + ); + + expect(kind).toEqual({ + kind: "chrome-profile", + path: "/usr/bin/google-chrome", + userDataDir: path.join("/work", "profiles/automation"), + profileDirectory: "Default", + background: false, + noFocus: false, + cdpPort: undefined, + }); + }); + + it("requires an explicit user data directory with Chrome 136 remediation", async () => { + vi.spyOn(launch, "resolveSystemChromeForProfile").mockReturnValue("/usr/bin/google-chrome"); + await expect( + resolveBrowserKindForTest( + { action: "open", app: { browser: "chrome", profile_directory: "Profile 10" } }, + makeSession("/work"), + ), + ).rejects.toThrow(/Chrome 136\+ disables remote debugging.*app\.cdp_url/); + }); + + it("errors with remediation when no Chrome binary is installed", async () => { + vi.spyOn(launch, "resolveSystemChromeForProfile").mockReturnValue(undefined); + + await expect( + resolveBrowserKindForTest( + { action: "open", app: { browser: "chrome", user_data_dir: "profiles/automation" } }, + makeSession("/work"), + ), + ).rejects.toThrow(/No Chrome\/Chromium executable found/); + }); + + it("rejects an explicit default Chrome user data directory on every platform", async () => { + // Drive the refusal through the injectable seam with explicit platforms and + // homes: ambient `os.homedir()` on the host only proves the host's own + // default root (the darwin branch never lists `~/.config/google-chrome`, + // so the old form failed on macOS while the guard was correct). + const matrix: Array<{ platform: NodeJS.Platform; home: string; defaultRoot: string }> = [ + { + platform: "darwin", + home: "/Users/u", + defaultRoot: path.posix.join("/Users/u", "Library", "Application Support", "Google", "Chrome"), + }, + { + platform: "win32", + home: "C:\\Users\\u", + defaultRoot: "C:\\Users\\u\\AppData\\Local\\Google\\Chrome\\User Data", + }, + { + platform: "linux", + home: "/home/u", + defaultRoot: path.posix.join("/home/u", ".config", "google-chrome"), + }, + ]; + for (const entry of matrix) { + expect(await isDefaultChromeUserDataDirForTest(entry.defaultRoot, [entry.defaultRoot], entry.platform)).toBe( + true, + ); + } + // End-to-end refusal keeps exercising the live resolution path, but with the + // host's actual platform default root instead of a Linux-only spelling. + const hostRoot = chromeUserDataRoots({ platform: process.platform, home: os.homedir(), exists: () => false })[0]!; + vi.spyOn(launch, "resolveSystemChromeForProfile").mockReturnValue("/usr/bin/google-chrome"); + await expect( + resolveBrowserKindForTest( + { + action: "open", + app: { browser: "chrome", user_data_dir: hostRoot }, + }, + makeSession("/work"), + ), + ).rejects.toThrow(/Refusing Chrome's default user data directory/); + }); + + it("recognizes symlink aliases and case-insensitive Windows aliases of default roots", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "chrome-default-root-")); + const target = path.join(root, "actual"); + const alias = path.join(root, "alias"); + await fs.mkdir(target); + await fs.symlink(target, alias); + try { + expect(await isDefaultChromeUserDataDirForTest(alias, [target])).toBe(true); + expect( + await isDefaultChromeUserDataDirForTest( + "c:\\users\\u\\appdata\\local\\google\\chrome\\user data", + ["C:\\Users\\U\\AppData\\Local\\Google\\Chrome\\User Data"], + "win32", + ), + ).toBe(true); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("cancels Chrome profile resolution without waiting for a stalled realpath", async () => { + const stalled = Promise.withResolvers(); + vi.spyOn(fs, "realpath").mockReturnValue(stalled.promise); + const controller = new AbortController(); + const resolution = resolveBrowserKindForTest( + { + action: "open", + app: { browser: "chrome", path: "/usr/bin/google-chrome", user_data_dir: "/tmp/gjc-chrome" }, + }, + makeSession("/work"), + controller.signal, + ); + controller.abort(); + + await expect(resolution).rejects.toThrow(/aborted/i); + }); + + it("rejects every supported Chrome channel root while allowing custom roots", async () => { + const matrix = [ + { + platform: "darwin" as const, + home: "/Users/u", + expectedChannels: ["Chrome", "Chrome Beta", "Chrome Dev", "Chrome Canary", "Chromium"], + }, + { + platform: "win32" as const, + home: "C:\\Users\\u", + localAppData: "C:\\Users\\u\\AppData\\Local", + expectedChannels: ["Chrome", "Chrome Beta", "Chrome Dev", "Chrome SxS", "Chromium"], + }, + { + platform: "linux" as const, + home: "/home/u", + expectedChannels: [ + "google-chrome", + "google-chrome-beta", + "google-chrome-unstable", + "google-chrome-canary", + "chromium", + "com.google.Chrome", + "org.chromium.Chromium", + "snap/chromium/common/chromium", + "snap/chromium/current/.config/chromium", + ], + }, + ]; + + for (const entry of matrix) { + const roots = chromeUserDataRoots({ + platform: entry.platform, + home: entry.home, + exists: () => false, + ...(entry.localAppData ? { localAppData: entry.localAppData } : {}), + }); + expect(roots).toHaveLength(entry.expectedChannels.length); + for (const [index, root] of roots.entries()) { + expect(root).toContain(entry.expectedChannels[index]!); + expect(await isDefaultChromeUserDataDirForTest(root, roots, entry.platform)).toBe(true); + } + const customRoot = entry.platform === "win32" ? "D:\\automation\\chrome" : "/tmp/automation-chrome"; + expect(await isDefaultChromeUserDataDirForTest(customRoot, roots, entry.platform)).toBe(false); + } + }); + + it("rejects an explicitly supplied Edge executable before launch", async () => { + await expect( + resolveBrowserKindForTest( + { + action: "open", + app: { + browser: "chrome", + path: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + user_data_dir: "/tmp/chrome-automation", + }, + }, + makeSession("/work"), + ), + ).rejects.toThrow(/not Microsoft Edge/); + }); + + it("rejects an explicit Linux Edge path before checking omitted profile fields", async () => { + await expect( + resolveBrowserKindForTest( + { action: "open", app: { browser: "chrome", path: "/usr/bin/microsoft-edge-stable" } }, + makeSession("/work"), + ), + ).rejects.toThrow(/not Microsoft Edge/); + }); + + it("rejects other Chromium browser brands before checking omitted profile fields", async () => { + for (const executable of ["/usr/bin/brave-browser", "/usr/bin/vivaldi", "/usr/bin/opera"]) { + await expect( + resolveBrowserKindForTest( + { action: "open", app: { browser: "chrome", path: executable } }, + makeSession("/work"), + ), + ).rejects.toThrow(/must be a Google Chrome or Chromium executable/); + } + }); + + it("allows Chrome and Chromium executables with custom data roots", async () => { + for (const exe of ["/usr/bin/google-chrome-beta", "/usr/bin/chromium"]) { + expect( + await resolveBrowserKindForTest( + { action: "open", app: { browser: "chrome", path: exe, user_data_dir: "/tmp/gjc-chrome" } }, + makeSession("/work"), + ), + ).toMatchObject({ path: exe, userDataDir: "/tmp/gjc-chrome", profileDirectory: "Default" }); + } }); it("refuses an already-running matching profile without attachable CDP", async () => { @@ -183,6 +453,135 @@ describe("Chrome profile browser mode (#809)", () => { ).resolves.toEqual({ pid: 123, cdpUrl: "http://127.0.0.1:9222" }); }); + it("reuses a wrapper-launched Linux Chrome process by guarded profile arguments", async () => { + vi.spyOn(Process, "fromPath").mockReturnValue([]); + vi.spyOn(Process, "fromPid").mockImplementation(pid => + pid === 321 + ? ({ + pid, + status: () => ProcessStatus.Running, + args: () => [ + "/opt/google/chrome/chrome", + "--user-data-dir=/tmp/gjc-chrome", + "--profile-directory=Default", + "--remote-debugging-port=9222", + "--remote-debugging-address=127.0.0.1", + ], + } as Process) + : null, + ); + mockSuccessfulCdpProbe(); + + await expect( + findRunningChromeProfileForTest( + "/usr/bin/google-chrome", + { userDataDir: "/tmp/gjc-chrome", profileDirectory: "Default" }, + { + platform: "linux", + linuxPids: [321], + linuxExecutablePaths: new Map([[321, "/opt/google/chrome/chrome"]]), + }, + ), + ).resolves.toEqual({ pid: 321, cdpUrl: "http://127.0.0.1:9222" }); + }); + + it("does not reuse a non-Chrome process that spoofs profile arguments", async () => { + vi.spyOn(Process, "fromPath").mockReturnValue([]); + vi.spyOn(Process, "fromPid").mockReturnValue({ + pid: 654, + status: () => ProcessStatus.Running, + args: () => [ + "/opt/google/chrome/chrome", + "--user-data-dir=/tmp/gjc-chrome", + "--profile-directory=Default", + "--remote-debugging-port=9222", + ], + } as Process); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + await expect( + findRunningChromeProfileForTest( + "/snap/bin/chromium", + { userDataDir: "/tmp/gjc-chrome", profileDirectory: "Default" }, + { + platform: "linux", + linuxPids: [654], + linuxExecutablePaths: new Map([[654, "/usr/bin/brave-browser"]]), + }, + ), + ).resolves.toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("stops the Linux fallback before inspecting processes when aborted", async () => { + vi.spyOn(Process, "fromPath").mockReturnValue([]); + const fromPidSpy = vi.spyOn(Process, "fromPid"); + const controller = new AbortController(); + controller.abort(); + + await expect( + findRunningChromeProfileForTest( + "/snap/bin/chromium", + { userDataDir: "/tmp/gjc-chrome", profileDirectory: "Default" }, + { platform: "linux", linuxPids: [321], signal: controller.signal }, + ), + ).rejects.toThrow(/aborted/i); + expect(fromPidSpy).not.toHaveBeenCalled(); + }); + + it("propagates cancellation that arrives during a CDP probe", async () => { + mockRunningChromeProcess([ + "--user-data-dir=/Users/me/Library/Application Support/Google/Chrome", + "--profile-directory=Profile 10", + "--remote-debugging-port=9222", + ]); + const controller = new AbortController(); + const abortingFetch = (() => { + controller.abort(); + return Promise.reject(new DOMException("Aborted", "AbortError")); + }) as unknown as typeof fetch; + vi.spyOn(globalThis, "fetch").mockImplementation(abortingFetch); + + await expect( + attach.findRunningChromeProfile( + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + { + userDataDir: "/Users/me/Library/Application Support/Google/Chrome", + profileDirectory: "Profile 10", + }, + controller.signal, + ), + ).rejects.toThrow(/aborted/i); + }); + + it("propagates cancellation that arrives while closing a successful CDP response", async () => { + mockRunningChromeProcess([ + "--user-data-dir=/Users/me/Library/Application Support/Google/Chrome", + "--profile-directory=Profile 10", + "--remote-debugging-port=9222", + ]); + const controller = new AbortController(); + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + body: { + cancel: async () => { + controller.abort(); + }, + }, + } as unknown as Response); + + await expect( + attach.findRunningChromeProfile( + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + { + userDataDir: "/Users/me/Library/Application Support/Google/Chrome", + profileDirectory: "Profile 10", + }, + controller.signal, + ), + ).rejects.toThrow(/aborted/i); + }); + it("reuses matching profile CDP when remote debugging address is localhost", async () => { mockRunningChromeProcess([ "--user-data-dir=/Users/me/Library/Application Support/Google/Chrome", diff --git a/packages/coding-agent/test/tools/browser-profile-reuse.test.ts b/packages/coding-agent/test/tools/browser-profile-reuse.test.ts index 639507bb4a..7e6b034768 100644 --- a/packages/coding-agent/test/tools/browser-profile-reuse.test.ts +++ b/packages/coding-agent/test/tools/browser-profile-reuse.test.ts @@ -29,8 +29,72 @@ describe("profile-discovery", () => { it("lists platform-appropriate roots", () => { expect(chromeUserDataRoots(env({ platform: "linux", home: "/home/x", existing: [] }))).toEqual([ "/home/x/.config/google-chrome", + "/home/x/.config/google-chrome-beta", + "/home/x/.config/google-chrome-unstable", + "/home/x/.config/google-chrome-canary", "/home/x/.config/chromium", + "/home/x/.var/app/com.google.Chrome/config/google-chrome", + "/home/x/.var/app/org.chromium.Chromium/config/chromium", + "/home/x/snap/chromium/common/chromium", + "/home/x/snap/chromium/current/.config/chromium", ]); + expect( + chromeUserDataRoots({ + platform: "linux", + home: "/home/x", + exists: () => false, + chromeUserDataDir: "/srv/chrome-default", + chromeConfigHome: "/srv/chrome-config", + xdgConfigHome: "/srv/xdg-ignored", + }), + ).toEqual([ + "/srv/chrome-default", + "/srv/chrome-config/google-chrome", + "/srv/chrome-config/google-chrome-beta", + "/srv/chrome-config/google-chrome-unstable", + "/srv/chrome-config/google-chrome-canary", + "/srv/chrome-config/chromium", + "/home/x/.var/app/com.google.Chrome/config/google-chrome", + "/home/x/.var/app/org.chromium.Chromium/config/chromium", + "/home/x/snap/chromium/common/chromium", + "/home/x/snap/chromium/current/.config/chromium", + ]); + expect(chromeUserDataRoots(env({ platform: "darwin", home: "/Users/x", existing: [] }))).toEqual([ + "/Users/x/Library/Application Support/Google/Chrome", + "/Users/x/Library/Application Support/Google/Chrome Beta", + "/Users/x/Library/Application Support/Google/Chrome Dev", + "/Users/x/Library/Application Support/Google/Chrome Canary", + "/Users/x/Library/Application Support/Chromium", + ]); + expect( + chromeUserDataRoots({ + platform: "win32", + home: "C:\\Users\\x", + localAppData: "C:\\Users\\x\\AppData\\Local", + exists: () => false, + }), + ).toEqual([ + "C:\\Users\\x\\AppData\\Local\\Google\\Chrome\\User Data", + "C:\\Users\\x\\AppData\\Local\\Google\\Chrome Beta\\User Data", + "C:\\Users\\x\\AppData\\Local\\Google\\Chrome Dev\\User Data", + "C:\\Users\\x\\AppData\\Local\\Google\\Chrome SxS\\User Data", + "C:\\Users\\x\\AppData\\Local\\Chromium\\User Data", + ]); + }); + + it("joins profile paths with the requested platform semantics", () => { + const root = "C:\\Users\\x\\AppData\\Local\\Google\\Chrome Beta\\User Data"; + const profileDir = `${root}\\Profile 2`; + const found = discoverDefaultChromeProfile( + { + platform: "win32", + home: "C:\\Users\\x", + localAppData: "C:\\Users\\x\\AppData\\Local", + exists: candidate => candidate === profileDir, + }, + "Profile 2", + ); + expect(found).toEqual({ userDataDir: root, profileDirectory: "Profile 2", profileDir }); }); }); diff --git a/packages/coding-agent/test/tools/browser/launch-env-trust.test.ts b/packages/coding-agent/test/tools/browser/launch-env-trust.test.ts index 271599b29c..26adfefaa3 100644 --- a/packages/coding-agent/test/tools/browser/launch-env-trust.test.ts +++ b/packages/coding-agent/test/tools/browser/launch-env-trust.test.ts @@ -25,6 +25,12 @@ const BROWSER_KEYS = [ "PUPPETEER_PROXY", "PUPPETEER_PROXY_BYPASS_LOOPBACK", "PUPPETEER_PROXY_IGNORE_CERT_ERRORS", + "LOCALAPPDATA", + "CHROME_USER_DATA_DIR", + "CHROME_CONFIG_HOME", + "XDG_CONFIG_HOME", + "ProgramFiles", + "ProgramFiles(x86)", ] as const; interface BrowserEnvOverrides { @@ -32,6 +38,15 @@ interface BrowserEnvOverrides { proxy: string | undefined; proxyBypassLoopback: boolean; ignoreCertErrors: boolean; + profileEnv: { + localAppData?: string; + chromeUserDataDir?: string; + chromeConfigHome?: string; + xdgConfigHome?: string; + }; + programFiles?: string; + programFilesX86?: string; + localAppData?: string; } const tempDirs: string[] = []; @@ -83,6 +98,7 @@ describe("browser launch env trust boundary", () => { proxy: undefined, proxyBypassLoopback: false, ignoreCertErrors: false, + profileEnv: {}, }); }); @@ -120,4 +136,42 @@ describe("browser launch env trust boundary", () => { "/opt/chrome/chrome", ); }); + + it("ignores profile discovery roots planted by the project .env", async () => { + const cwd = projectDir( + "LOCALAPPDATA=/repo/windows\nCHROME_USER_DATA_DIR=/repo/data\nCHROME_CONFIG_HOME=/repo/chrome\nXDG_CONFIG_HOME=/repo/xdg\n", + ); + expect((await resolveIn(cwd)).profileEnv).toEqual({}); + }); + + it("ignores Windows executable roots planted by the project .env", async () => { + const cwd = projectDir("ProgramFiles=/repo/programs\nLOCALAPPDATA=/repo/local\n"); + const resolved = await resolveIn(cwd); + expect(resolved.programFiles).toBeUndefined(); + expect(resolved.localAppData).toBeUndefined(); + }); + + it("honors profile discovery roots inherited from the launching shell", async () => { + const resolved = await resolveIn(projectDir(), { + LOCALAPPDATA: "/trusted/windows", + CHROME_USER_DATA_DIR: "/trusted/data", + CHROME_CONFIG_HOME: "/trusted/chrome", + XDG_CONFIG_HOME: "/trusted/xdg", + }); + expect(resolved.profileEnv).toEqual({ + localAppData: "/trusted/windows", + chromeUserDataDir: "/trusted/data", + chromeConfigHome: "/trusted/chrome", + xdgConfigHome: "/trusted/xdg", + }); + }); + + it("honors Windows executable roots inherited from the launching shell", async () => { + const resolved = await resolveIn(projectDir(), { + ProgramFiles: "C:\\Trusted\\Programs", + LOCALAPPDATA: "C:\\Trusted\\Local", + }); + expect(resolved.programFiles).toBe("C:\\Trusted\\Programs"); + expect(resolved.localAppData).toBe("C:\\Trusted\\Local"); + }); });