diff --git a/.gitignore b/.gitignore index 0db200bfb..6a3ae8915 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,4 @@ docs/throughput-*.json # test/ios-qa-swift-build.test.ts; regenerates on every run — never commit) ios-qa/scripts/gen-accessors-tool/.build/ ios-qa/scripts/gen-accessors-tool/Package.resolved +.gstack/ diff --git a/BROWSER.md b/BROWSER.md index 63d278a9a..75cd99b9e 100644 --- a/BROWSER.md +++ b/BROWSER.md @@ -952,6 +952,42 @@ A running daemon with config A meeting a new invocation with config B exits 1 with a `browse disconnect` hint instead of silently restarting and dropping tab state, cookies, or sessions. +### Proxy from environment + +Without `--proxy`, `$B` picks the proxy up from the environment (#915). The +first one set wins: + +1. `--proxy ` +2. `BROWSE_PROXY_URL` (`BROWSE_PROXY_URL=direct` turns off steps 3-5) +3. `HTTPS_PROXY`, then `https_proxy` +4. `HTTP_PROXY`, then `http_proxy` +5. `ALL_PROXY`, then `all_proxy` +6. no proxy (a direct connection) + +Values from the environment follow the same credential policy as `--proxy`. +`socks5h://` is treated as `socks5://`, because Chromium's SOCKS5 already +resolves names through the proxy. `NO_PROXY` (or `no_proxy`) is translated to +Chromium's bypass list: `example.com` and `.example.com` cover the domain and +its subdomains, and CIDR blocks and `host:port` entries pass through. +Loopback (`localhost`, `127.0.0.1`, `[::1]`) is always bypassed, for every +proxy source. When a server starts, `$B` prints one line naming the proxy and +where it came from, with credentials redacted. + +- **`warning: HTTPS_PROXY is not a proxy URL browse can use (...)`.** The + variable doesn't parse as `scheme://host:port`: a curl-style `host:port`, an + unsupported scheme, or a bad port. `$B` connects directly instead of failing + every command. Fix: `export HTTPS_PROXY=http://host:port` (or `socks5://`), + or set `BROWSE_PROXY_URL=direct` to silence the warning. A bad `--proxy` or + `BROWSE_PROXY_URL` is still an error, because those are explicit browse + settings and a silent direct connection could expose your real IP address. +- **`the running browse daemon was started with different proxy settings ( differs), so it was not reused.`** + The proxy and the normalized `NO_PROXY` rules are part of the daemon config, + so a session whose environment differs (even by `NO_PROXY` alone) does not + silently reuse a daemon that routes differently. The refusal prints the + daemon's settings and this session's. Fix: run `browse stop` to restart the + daemon with this session's settings (stop works whatever the proxy settings + are), or set `BROWSE_PROXY_URL=direct` when the running daemon has no proxy. + **Stealth scope (Layer C, always on).** Every context — headless `launch`, `--headed`/`--proxy`, `handoff`, and the `useragent`/`viewport --scale` rebuild (`recreateContext`) — gets the full Layer C mask, no opt-in flag. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d6a19495..14269c851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## [1.91.86.0] - 2026-10-11 + +**browse follows the proxy your shell already uses, writes PDFs on Windows machines whose temp dir is an 8.3 short path, and stops spawning `--config=NUL` children that crashed Bun 1.3.10 (Wave 9, lane E).** + +### What this means for you + +- **Proxy from the environment (#915).** Without `--proxy`, browse takes the proxy from `BROWSE_PROXY_URL`, then `HTTPS_PROXY`, `HTTP_PROXY` and `ALL_PROXY` (upper case before lower case). `BROWSE_PROXY_URL=direct` opts out. `NO_PROXY` becomes Chromium's bypass list, and loopback is always bypassed. The proxy and the `NO_PROXY` rules are part of the daemon config, so a session with a different environment is refused with the variable named instead of silently reusing a daemon that routes differently. The refusal offers `browse stop`, which now stops any daemon whatever its proxy or headed settings. A standard variable that doesn't parse (curl-style `host:port`) prints a warning and browse connects directly; a bad `--proxy` or `BROWSE_PROXY_URL` still fails, so a typo never quietly exposes your IP. `socks5h://` works as `socks5://`. See BROWSER.md, "Proxy from environment". +- **8.3 temp paths on Windows (#3104).** `make-pdf` and `browse pdf` failed with `Path must be within: C:\Users\BURCIN~1\...` because the safe directories and the target went through different realpath functions. Both sides now use one (`fs.realpathSync.native`), resolved paths compare exactly, and only a tail that doesn't exist yet is case-folded on Windows. +- **No more Bun crash windows on Windows (#3085).** The terminal agent, the native-cookie supervisor and its worker passed `--config=NUL`, which panicked Bun 1.3.10 at flag parsing; the respawn loop turned that into a crash window every few seconds. All three now get an empty `bunfig.toml` created in a private directory and re-checked to be zero bytes before each spawn. POSIX keeps `/dev/null`. + +### Itemized changes + +#### Changed +- `browse/src/proxy-config.ts`: `resolveProxy` (precedence, `direct`, cred policy for env URLs, warning fallback), `noProxyToChromiumRules`, `withLoopbackBypass`, `serverProxyEnv`; `socks5h` accepted as `socks5`; `computeConfigHash` takes the `NO_PROXY` rules (unchanged hash when there are none). +- `browse/src/cli.ts`: resolved proxy source and bypass reach every server launch (start, crash restart, headed connect and its supervisor); the startup line names the source; `proxyMismatchLines` refusal; `browse stop` skips the config-mismatch check. +- `browse/src/server.ts`, `browse/src/browser-manager.ts`: Chromium gets the bypass rules; the state file records the redacted proxy, its source and its bypass. +- `browse/src/path-security.ts`: `resolveRealPath`, one realpath for safe directories and targets. `browse/src/write-commands.ts`: `cookie-import` uses `validateReadPath`. +- `browse/src/bun-child-config.ts`: `emptyBunConfig()`; `bunChildFlags()` replaces `BUN_CHILD_FLAGS`. + +#### For contributors +- `browse/test/proxy-env.test.ts`, `browse/test/proxy-env-daemon.test.ts` (a local proxy across launch, reuse, a NO_PROXY-only change, `direct` and restart), `browse/test/path-security-realpath.test.ts`, `browse/test/bun-child-config.test.ts`. + +Thanks @ningfeemic-dev (#916) +Thanks @bkermen (#3104) +Thanks @adammartin0087 (#3085) + ## [1.91.85.0] - 2026-10-11 **`/cso` works on Windows Git 2.56 and gets Docker on macOS, `/test-audit` and `/ship` loop correctly under zsh, and the deploy skills stop at the honest point (Wave 9 Lane D).** diff --git a/VERSION b/VERSION index 70e0abf62..4279168d0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.85.0 +1.91.86.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 64c333f7a..bcc38ef39 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.85.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.86.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 29f9d0636..711fb6c03 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -321,7 +321,7 @@ export class BrowserManager { // at startup based on BROWSE_PROXY_URL. For SOCKS5 with auth, server.ts // points this at the local bridge (socks5://127.0.0.1:); for // HTTP/HTTPS or unauth SOCKS5, it's the upstream URL directly. - private proxyConfig: { server: string; username?: string; password?: string } | null = null; + private proxyConfig: { server: string; bypass?: string; username?: string; password?: string } | null = null; private pages: Map = new Map(); private tabSessions: Map = new Map(); private activeTabId: number = 0; @@ -562,7 +562,7 @@ export class BrowserManager { * launchHeaded(). Called by server.ts at startup once the (optional) SOCKS5 * bridge is up. */ - setProxyConfig(cfg: { server: string; username?: string; password?: string } | null): void { + setProxyConfig(cfg: { server: string; bypass?: string; username?: string; password?: string } | null): void { this.proxyConfig = cfg; } diff --git a/browse/src/bun-child-config.ts b/browse/src/bun-child-config.ts new file mode 100644 index 000000000..045a97571 --- /dev/null +++ b/browse/src/bun-child-config.ts @@ -0,0 +1,35 @@ +/** + * The `--config` file for every Bun child browse spawns (#3085). The child + * runs in the user's project, where Bun would otherwise load that project's + * bunfig.toml, whose preload runs code. POSIX passes /dev/null. Windows has no + * /dev/null and `--config=NUL` panicked Bun 1.3.10 while parsing the flag, so + * Windows gets an empty bunfig created exclusively in a private directory and + * re-checked to be a zero-byte regular file before each spawn; anything else + * is replaced, so the file cannot become a preload vector. + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +let emptyConfig: string | null = null; + +function isEmptyRegularFile(file: string): boolean { + try { + const stat = fs.lstatSync(file); + return stat.isFile() && stat.size === 0; + } catch { + return false; + } +} + +export function emptyBunConfig(platform: NodeJS.Platform = process.platform): string { + if (platform !== 'win32') return '/dev/null'; + if (emptyConfig && isEmptyRegularFile(emptyConfig)) return emptyConfig; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-bunfig-')); + const file = path.join(dir, 'bunfig.toml'); + fs.closeSync(fs.openSync(file, 'wx', 0o600)); + if (!isEmptyRegularFile(file)) throw new Error(`empty Bun config ${file} is not a zero-byte file`); + process.once('exit', () => fs.rmSync(dir, { recursive: true, force: true })); + emptyConfig = file; + return file; +} diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 8f2f065ca..d774d23d5 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -16,9 +16,9 @@ import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-h import { readPidStartTime, readPidCmdline } from './xvfb'; import { writeSecureFile, mkdirSecure } from './file-permissions'; import { resolveConfig, ensureStateDir, readVersionHash, isPairAgentEnabled, resolveChromiumProfile } from './config'; -import { parseProxyConfig, computeConfigHash, ProxyConfigError } from './proxy-config'; +import { resolveProxy, computeConfigHash, ProxyConfigError, PROXY_TROUBLESHOOTING } from './proxy-config'; import { redactProxyUrl } from './proxy-redact'; -import { spawnTerminalAgent, BUN_CHILD_FLAGS } from './terminal-agent-control'; +import { spawnTerminalAgent, bunChildFlags } from './terminal-agent-control'; import { profileOwner, ensureProjectProfile, runProfilesCommand } from './chromium-profiles'; // Zero side effects on import (documented invariant in token-registry.ts) — // safe to pull the shared pairing default into the CLI. @@ -188,8 +188,12 @@ interface ServerState { serverPath: string; binaryVersion?: string; mode?: 'launched' | 'headed'; - /** Hash of (proxyUrl + headed flag), used by D2 daemon-mismatch check. */ + /** Hash of (proxyUrl + headed flag + NO_PROXY rules), used by D2 daemon-mismatch check. */ configHash?: string; + /** #915: the daemon's redacted proxy, its source and its Chromium bypass rules. */ + proxy?: string; + proxySource?: string; + proxyBypass?: string; /** Xvfb child PID for cleanup on disconnect. */ xvfbPid?: number; xvfbStartTime?: string; @@ -465,6 +469,47 @@ function reportDaemonBusyAndExit(pid: number): never { process.exit(1); } +/** + * #915 refusal for a daemon whose proxy differs from this session's when + * either side came from the environment: names the variable, the two + * settings and the remedies. Null leaves the --proxy/--headed refusals as they + * were (a headed difference always uses those). + */ +export function proxyMismatchLines(flags: GlobalFlags, state: ServerState, env: NodeJS.ProcessEnv = process.env): string[] | null { + if ((state.mode === 'headed') !== flags.headed) return null; + const fromEnv = (source?: string | null) => !!source && source !== '--proxy'; + const optedOut = env.BROWSE_PROXY_URL === 'direct' && !flags.proxyUrl; + if (!fromEnv(flags.proxySource) && !fromEnv(state.proxySource) && !flags.proxyWarning && !optedOut) return null; + const describe = (proxy: string | undefined, source: string | null | undefined, bypass: string | null | undefined) => + proxy ? `proxy ${proxy} from ${source ?? 'an older browse'}${bypass ? `, bypassing ${bypass}` : ''}` : 'no proxy'; + const daemon = describe(state.proxy, state.proxySource, state.proxyBypass); + const session = flags.proxyUrl + ? describe(flags.redactedProxyUrl, flags.proxySource, flags.proxyBypass) + : `no proxy${optedOut ? ' (BROWSE_PROXY_URL=direct)' : flags.proxyWarning ? ' (a proxy variable did not parse)' : ''}`; + const sameProxy = !!state.proxy && state.proxy === (flags.proxyUrl ? flags.redactedProxyUrl : undefined); + const variable = sameProxy ? 'NO_PROXY' : fromEnv(flags.proxySource) ? flags.proxySource : state.proxySource ?? 'BROWSE_PROXY_URL'; + const remedies = [`run 'browse stop' to restart the daemon with this session's proxy settings`]; + if (!state.proxy) remedies.push(`or set BROWSE_PROXY_URL=direct to use the running daemon without a proxy`); + else remedies.push(`or run this session with the daemon's ${variable} so the settings match`); + return [ + `the running browse daemon was started with different proxy settings (${variable} differs), so it was not reused.`, + ` daemon: ${daemon}`, + ` session: ${session}`, + `fix: ${remedies.join(', ')}.`, + `see ${PROXY_TROUBLESHOOTING}`, + ]; +} + +/** The server env for this invocation's resolved proxy (#915); empty when direct. */ +function proxyServerEnv(flags: Partial> | null | undefined): Record { + if (!flags?.proxyUrl) return {}; + return { + BROWSE_PROXY_URL: flags.proxyUrl, + ...(flags.proxySource ? { BROWSE_PROXY_SOURCE: flags.proxySource } : {}), + ...(flags.proxyBypass ? { BROWSE_PROXY_BYPASS: flags.proxyBypass } : {}), + }; +} + /** * Build the env for an auto-restart after a crash. headed/proxy/configHash are * reapplied from THIS invocation OR the persisted server state, so a restart @@ -475,8 +520,7 @@ export function buildRestartEnv( globalFlags: GlobalFlags | null | undefined, oldState: ServerState | null, ): Record { - const env: Record = {}; - if (globalFlags?.proxyUrl) env.BROWSE_PROXY_URL = globalFlags.proxyUrl; + const env: Record = proxyServerEnv(globalFlags); if (globalFlags?.headed || oldState?.mode === 'headed') env.BROWSE_HEADED = '1'; const configHash = globalFlags?.configHash || oldState?.configHash; if (configHash) env.BROWSE_CONFIG_HASH = configHash; @@ -489,7 +533,7 @@ export function buildRestartEnv( * the same port, watchdog setting, proxy and config hash. Pure + exported for tests. */ export function buildHeadedServerEnv( - globalFlags: Pick, + globalFlags: Pick & Partial>, ): Record { return { BROWSE_HEADED: '1', @@ -503,7 +547,7 @@ export function buildHeadedServerEnv( // Apply --proxy from this invocation if present. Without this, // `browse --proxy connect` would launch headed Chromium // bypassing the SOCKS bridge entirely. - ...(globalFlags.proxyUrl ? { BROWSE_PROXY_URL: globalFlags.proxyUrl } : {}), + ...proxyServerEnv(globalFlags), ...(globalFlags.configHash ? { BROWSE_CONFIG_HASH: globalFlags.configHash } : {}), }; } @@ -720,7 +764,7 @@ async function startServer(extraEnv?: Record): Promise { const state = readState(); const desiredHash = flags?.configHash; - const extraEnv: Record = {}; - if (flags?.proxyUrl) extraEnv.BROWSE_PROXY_URL = flags.proxyUrl; + const extraEnv: Record = proxyServerEnv(flags); if (flags?.headed) extraEnv.BROWSE_HEADED = '1'; if (desiredHash) extraEnv.BROWSE_CONFIG_HASH = desiredHash; @@ -864,8 +907,11 @@ async function ensureServer(flags?: GlobalFlags): Promise { // hint. No silent restart — that would drop tab state, cookies, and // logged-in sessions without warning. if (desiredHash && state.configHash && state.configHash !== desiredHash) { - // #3030: a caller that passed no flags never asked to "apply" any. - if (flags?.proxyUrl || flags?.headed) { + const proxyLines = flags && proxyMismatchLines(flags, state); + if (proxyLines) { + for (const line of proxyLines) console.error(`[browse] ${line}`); + } else if (flags?.proxyUrl || flags?.headed) { + // #3030: a caller that passed no flags never asked to "apply" any. console.error(`[browse] existing daemon has different config (proxy/headed mismatch).`); console.error(`[browse] run 'browse disconnect' first to apply --proxy/--headed.`); } else { @@ -952,8 +998,9 @@ async function ensureServer(flags?: GlobalFlags): Promise { if (state && state.pid) { await killServer(state.pid); } + if (flags?.proxyWarning) console.error(`[browse] warning: ${flags.proxyWarning}`); if (flags?.redactedProxyUrl && flags.redactedProxyUrl !== '') { - console.error(`[browse] Starting server with proxy ${flags.redactedProxyUrl}${flags.headed ? ' (headed)' : ''}...`); + console.error(`[browse] Starting server with proxy ${flags.redactedProxyUrl} from ${flags.proxySource}${flags.headed ? ' (headed)' : ''}...`); } else if (flags?.headed) { console.error('[browse] Starting server in headed mode...'); } else { @@ -1260,8 +1307,14 @@ function hasFlag(args: string[], flag: string): boolean { export interface GlobalFlags { /** Cleaned argv with --proxy/--headed stripped out. */ args: string[]; - /** Resolved BROWSE_PROXY_URL (with creds embedded) or null. */ + /** Resolved proxy URL (with creds embedded) or null for direct. */ proxyUrl: string | null; + /** '--proxy' or the env var proxyUrl came from (#915); null when direct. */ + proxySource: string | null; + /** Chromium bypass rules (NO_PROXY plus loopback); null when direct. */ + proxyBypass: string | null; + /** Why a standard proxy variable was ignored, printed when a server starts. */ + proxyWarning: string | null; /** Whether --headed was passed. */ headed: boolean; /** Hash of (proxy + headed) for daemon-mismatch check. */ @@ -1309,28 +1362,20 @@ export function extractGlobalFlags(rawArgs: string[], env: NodeJS.ProcessEnv): G out.push(arg); } - // Compose the canonical proxyUrl with creds resolved from argv+env. - let canonicalProxyUrl: string | null = null; - if (proxyUrl) { - const parsed = parseProxyConfig({ - proxyUrl, - envUser: env.BROWSE_PROXY_USER, - envPass: env.BROWSE_PROXY_PASS, - }); - // Re-encode with resolved creds embedded (server reads BROWSE_PROXY_URL - // from env — env passes to child process safely without ps-aux exposure). - const rebuilt = new URL(proxyUrl); - rebuilt.username = parsed.userId ? encodeURIComponent(parsed.userId) : ''; - rebuilt.password = parsed.password ? encodeURIComponent(parsed.password) : ''; - canonicalProxyUrl = rebuilt.toString(); - } + // Resolve --proxy, BROWSE_PROXY_URL or the standard proxy variables, with + // creds embedded (server reads BROWSE_PROXY_URL from env, which passes to + // the child without ps-aux exposure). + const proxy = resolveProxy(proxyUrl, env); return { args: out, - proxyUrl: canonicalProxyUrl, + proxyUrl: proxy.url, + proxySource: proxy.source, + proxyBypass: proxy.bypass, + proxyWarning: proxy.warning, headed, - configHash: computeConfigHash({ proxyUrl: canonicalProxyUrl, headed }), - redactedProxyUrl: redactProxyUrl(canonicalProxyUrl), + configHash: computeConfigHash({ proxyUrl: proxy.url, headed, noProxyRules: proxy.noProxyRules }), + redactedProxyUrl: redactProxyUrl(proxy.url), forceRestart, }; } @@ -1853,6 +1898,8 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: safeUnlinkQuiet(config.stateFile); console.log('Launching headed Chromium with extension + terminal agent...'); + if (globalFlags.proxyWarning) console.error(`[browse] warning: ${globalFlags.proxyWarning}`); + if (globalFlags.proxyUrl) console.error(`[browse] Using proxy ${globalFlags.redactedProxyUrl} from ${globalFlags.proxySource}`); try { // Start server in headed mode with extension auto-loaded const newState = await startServer(buildHeadedServerEnv(globalFlags)); @@ -2126,6 +2173,9 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: pairAgentPreexistingDaemonAlive = Boolean(preState?.pid && isProcessAlive(preState.pid)); } + // `stop` wants no daemon, so it stops one started with other proxy/headed + // settings instead of refusing (the #915 refusal offers `browse stop`). + if (command === 'stop') globalFlags = { ...globalFlags, configHash: '' }; let state = await ensureServer(globalFlags); // ─── Pair-Agent (post-server, pre-dispatch) ────────────── diff --git a/browse/src/cookie-import-native-integrity.ts b/browse/src/cookie-import-native-integrity.ts index ed8105dc7..43afb9c41 100644 --- a/browse/src/cookie-import-native-integrity.ts +++ b/browse/src/cookie-import-native-integrity.ts @@ -12,6 +12,7 @@ export const NATIVE_CODE_INPUTS = Object.freeze([ 'browse/src/cookie-import-native-integrity.ts', 'browse/src/cookie-import-native-job.ts', 'browse/src/cookie-import-native-worker.ts', + 'browse/src/bun-child-config.ts', 'browse/src/bun-polyfill.cjs', 'browse/scripts/build-node-server.sh', '.github/scripts/run-cookie-native-qualification.ps1', diff --git a/browse/src/cookie-import-native-worker.ts b/browse/src/cookie-import-native-worker.ts index afcc52686..1d04e2afe 100644 --- a/browse/src/cookie-import-native-worker.ts +++ b/browse/src/cookie-import-native-worker.ts @@ -3,6 +3,7 @@ import { createInterface } from 'node:readline'; import { writeSync } from 'node:fs'; import { createNativeCookieJob, joinNativeCookieJob, nativeCookieDiagnostic, parseNativeCookieDiagnostic, type NativeCookieDiagnostic, type NativeCookieJob } from './cookie-import-native-job'; import type { PlaywrightCookie } from './cookie-import-browser'; +import { emptyBunConfig } from './bun-child-config'; export interface NativeCookieRequest { nodeExecutable: string; @@ -131,7 +132,7 @@ export async function superviseNativeCookieImport( } function startMember(request: NativeCookieRequest, jobName: string, mode = '--member'): NativeCookieMember { - const child = spawn(process.execPath, ['--no-env-file', '--no-install', '--no-macros', '--config=NUL', import.meta.path, mode], { + const child = spawn(process.execPath, ['--no-env-file', '--no-install', '--no-macros', `--config=${emptyBunConfig()}`, import.meta.path, mode], { env: nativeCookieEnvironment(process.env), stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, diff --git a/browse/src/cookie-import-native.ts b/browse/src/cookie-import-native.ts index 95ccbc6bc..6fc0c41cb 100644 --- a/browse/src/cookie-import-native.ts +++ b/browse/src/cookie-import-native.ts @@ -4,6 +4,7 @@ import { createRequire } from 'node:module'; import { release } from 'node:os'; import path from 'node:path'; import { CookieImportError, normalizeCookieDomain, type PlaywrightCookie } from './cookie-import-browser'; +import { emptyBunConfig } from './bun-child-config'; import { hashNativeFile, nativeCodeHashes, nativeCodeMatches, readNativeQualifications, type NativeQualifiedBuild } from './cookie-import-native-integrity'; type BrowserName = 'Chrome' | 'Chromium' | 'Brave' | 'Edge'; @@ -119,7 +120,7 @@ export async function importNativeCookies(options: { } if (Date.now() >= deadline) throw new CookieImportError('Native cookie import exceeded its operation deadline.', 'native_timeout'); const env = Object.fromEntries(Object.entries(process.env).filter(([key, value]) => /^(systemroot|windir|temp|tmp|userprofile|localappdata|appdata|programfiles|programfiles\(x86\)|programdata|path|pathext)$/i.test(key) && typeof value === 'string')); - const worker = spawn(bunExecutable, ['--no-env-file', '--no-install', '--no-macros', '--config=NUL', path.join(import.meta.dir, 'cookie-import-native-worker.ts')], { + const worker = spawn(bunExecutable, ['--no-env-file', '--no-install', '--no-macros', `--config=${emptyBunConfig()}`, path.join(import.meta.dir, 'cookie-import-native-worker.ts')], { env, stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true, diff --git a/browse/src/path-security.ts b/browse/src/path-security.ts index ff224d524..a20423da7 100644 --- a/browse/src/path-security.ts +++ b/browse/src/path-security.ts @@ -16,20 +16,45 @@ * classic TEMP_DIR plus os.tmpdir(), which differ on macOS and under * TMPDIR-honoring CI/sandbox environments) * 4. TEMP_ONLY = [TEMP_DIR] for remote file serving (prevents project file exfil) + * 5. Safe directories and targets go through the same realpath (#3104): + * on Windows `fs.realpathSync` keeps an 8.3 short name such as + * C:\Users\BURCIN~1 while `fs.realpathSync.native` expands it, so mixing + * them rejected paths inside the temp dir. Resolved paths compare exactly; + * only a tail that does not exist yet is case-folded on Windows. */ import * as fs from 'fs'; import * as path from 'path'; import { TEMP_DIR, TEMP_DIRS, isPathWithin } from './platform'; -// Resolve safe directories through realpathSync to handle symlinks (e.g., macOS /tmp → /private/tmp) -export const SAFE_DIRECTORIES = [...TEMP_DIRS, process.cwd()].map(d => { - try { return fs.realpathSync(d); } catch { return d; } -}); +const realpath = fs.realpathSync.native; +const foldMissing = (component: string) => process.platform === 'win32' ? component.toLowerCase() : component; -const TEMP_ONLY = [TEMP_DIR].map(d => { - try { return fs.realpathSync(d); } catch { return d; } -}); +/** + * Resolve the longest existing prefix of `filePath` with the one realpath + * function and append the missing tail, case-folded on Windows. Throws for + * resolution errors other than ENOENT. + */ +export function resolveRealPath(filePath: string): string { + const absolute = path.resolve(filePath); + try { + return realpath(absolute); + } catch (err: any) { + const parent = path.dirname(absolute); + if (err?.code !== 'ENOENT' || parent === absolute) throw err; + return path.join(resolveRealPath(parent), foldMissing(path.basename(absolute))); + } +} + +const resolveSafeDir = (dir: string) => { + try { return resolveRealPath(dir); } catch { return path.resolve(dir); } +}; + +// Resolved like every target, so symlinks (macOS /tmp → /private/tmp) and +// Windows 8.3 names compare equal. +export const SAFE_DIRECTORIES = [...TEMP_DIRS, process.cwd()].map(resolveSafeDir); + +const TEMP_ONLY = [TEMP_DIR].map(resolveSafeDir); /** * Validate a file path for writing (screenshot, pdf, download, scrape, archive). @@ -52,15 +77,16 @@ export function validateOutputPath(filePath: string): void { resolved = path.dirname(resolved); continue; } - resolved = path.join(resolved, component); + const next = path.join(resolved, component); try { - fs.lstatSync(resolved); + fs.lstatSync(next); } catch (err: any) { + resolved = path.join(resolved, foldMissing(component)); if (err?.code === 'ENOENT' && SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir))) continue; reject(); } try { - resolved = fs.realpathSync.native(resolved); + resolved = realpath(next); } catch { reject(); } @@ -71,21 +97,11 @@ export function validateOutputPath(filePath: string): void { /** Validate a file path for reading (eval command). */ export function validateReadPath(filePath: string): void { - const resolved = path.resolve(filePath); let realPath: string; try { - realPath = fs.realpathSync(resolved); + realPath = resolveRealPath(filePath); } catch (err: any) { - if (err.code === 'ENOENT') { - try { - const dir = fs.realpathSync(path.dirname(resolved)); - realPath = path.join(dir, path.basename(resolved)); - } catch { - realPath = resolved; - } - } else { - throw new Error(`Cannot resolve real path: ${filePath} (${err.code})`); - } + throw new Error(`Cannot resolve real path: ${filePath} (${err.code})`); } const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(realPath, dir)); if (!isSafe) { @@ -95,10 +111,9 @@ export function validateReadPath(filePath: string): void { /** Validate a file path for remote serving (GET /file). TEMP_DIR only, not cwd. */ export function validateTempPath(filePath: string): void { - const resolved = path.resolve(filePath); let realPath: string; try { - realPath = fs.realpathSync(resolved); + realPath = realpath(path.resolve(filePath)); } catch (err: any) { if (err.code === 'ENOENT') { throw new Error('File not found'); diff --git a/browse/src/proxy-config.ts b/browse/src/proxy-config.ts index 161475825..eb6efb7a1 100644 --- a/browse/src/proxy-config.ts +++ b/browse/src/proxy-config.ts @@ -2,9 +2,14 @@ * Parse + validate proxy config from CLI flags and environment. * * Used by: - * cli.ts — to detect cred-mixing, daemon-mismatch, and forward to server + * cli.ts — to resolve the proxy (resolveProxy), detect cred-mixing and + * daemon-mismatch, and forward to server * server.ts — to spawn the bridge and pass proxy to chromium.launch * + * Proxy precedence (#915): --proxy, then BROWSE_PROXY_URL, then HTTPS_PROXY, + * HTTP_PROXY and ALL_PROXY (upper case first, then lower case), then direct. + * BROWSE_PROXY_URL=direct opts out of the standard variables. + * * Cred policy (D9): if BOTH the URL embeds creds AND the env vars * BROWSE_PROXY_USER/PASS are set, refuse with a clear error. No silent * override — debugging confusion is worse than a one-time setup error. @@ -41,6 +46,8 @@ export function parseProxyConfig(opts: { proxyUrl: string; envUser?: string; envPass?: string; + /** Where proxyUrl came from, named in the cred-mixing hint. */ + source?: string; }): ParsedProxyConfig { let url: URL; try { @@ -52,7 +59,8 @@ export function parseProxyConfig(opts: { ); } - const scheme = url.protocol.replace(':', ''); + // socks5h (remote DNS) is what Chromium's socks5 already does. + const scheme = url.protocol === 'socks5h:' ? 'socks5' : url.protocol.replace(':', ''); if (scheme !== 'socks5' && scheme !== 'http' && scheme !== 'https') { throw new ProxyConfigError( 'use socks5://, http://, or https://', @@ -89,7 +97,7 @@ export function parseProxyConfig(opts: { // wins over a fresh --proxy URL, the user can't tell why. if (urlHasCreds && envHasCreds) { throw new ProxyConfigError( - 'unset BROWSE_PROXY_USER/PASS or remove user:pass@ from --proxy', + `unset BROWSE_PROXY_USER/PASS or remove user:pass@ from ${opts.source ?? '--proxy'}`, `proxy creds set in both env (BROWSE_PROXY_USER) and URL — pick one source`, ); } @@ -135,9 +143,15 @@ export function toUpstreamConfig(cfg: ParsedProxyConfig): UpstreamConfig { export function computeConfigHash(opts: { proxyUrl: string | null | undefined; headed: boolean; + /** Bypass rules derived from NO_PROXY (resolveProxy's noProxyRules). */ + noProxyRules?: string | null; }): string { const proxyKey = canonicalizeProxyUrl(opts.proxyUrl); - const input = JSON.stringify({ proxy: proxyKey, headed: opts.headed }); + const input = JSON.stringify({ + proxy: proxyKey, + headed: opts.headed, + ...(proxyKey && opts.noProxyRules ? { noProxy: opts.noProxyRules } : {}), + }); return createHash('sha256').update(input).digest('hex').slice(0, 16); } @@ -153,3 +167,117 @@ function canonicalizeProxyUrl(input: string | null | undefined): string | null { return ''; } } + +export const STANDARD_PROXY_VARS = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'] as const; +const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]']; +export const PROXY_TROUBLESHOOTING = 'https://github.com/garrytan/gstack/blob/main/BROWSER.md#proxy-from-environment'; + +export interface ResolvedProxy { + /** Proxy URL with resolved creds embedded, or null for a direct connection. */ + url: string | null; + /** '--proxy' or the env var the proxy came from; null when direct. */ + source: string | null; + /** NO_PROXY translated to Chromium bypass rules (sorted, comma-separated), or null. */ + noProxyRules: string | null; + /** Rules Chromium receives: noProxyRules plus loopback; null when direct. */ + bypass: string | null; + /** Set when a standard proxy variable was ignored because it does not parse. */ + warning: string | null; +} + +/** + * Translate a curl-style NO_PROXY list to Chromium bypass rules: `example.com` + * and `.example.com` cover the domain and its subdomains, `*` bypasses all, + * CIDR blocks and `host:port` pass through, and bare IPv6 gets brackets. + * Loopback names are left out: withLoopbackBypass always adds them, and the + * CLI appends them to NO_PROXY for its own daemon calls (#494). + */ +export function noProxyToChromiumRules(noProxy: string | undefined): string[] { + const rules = new Set(); + for (const raw of (noProxy ?? '').toLowerCase().split(/[\s,]+/)) { + if (!raw) continue; + if (raw === '*' || raw.includes('/')) { rules.add(raw); continue; } + let host = raw; + let port = ''; + const bracket = raw.match(/^(\[[0-9a-f:.]+\])(:\d+)?$/); + if (bracket) [host, port] = [bracket[1], bracket[2] ?? '']; + else if (raw.split(':').length > 2) { + if (!/^[0-9a-f:.]+$/.test(raw)) continue; + host = `[${raw}]`; + } + else if (raw.includes(':')) [host, port] = [raw.slice(0, raw.lastIndexOf(':')), raw.slice(raw.lastIndexOf(':'))]; + if (port && !/^:\d+$/.test(port)) continue; + if (!port && LOOPBACK_HOSTS.includes(host)) continue; + if (host.startsWith('*.')) { rules.add(host + port); continue; } + host = host.replace(/^\./, ''); + if (!host) continue; + rules.add(host + port); + if (!host.startsWith('[') && !/^\d+(\.\d+){3}$/.test(host)) rules.add(`*.${host}${port}`); + } + return [...rules].sort(); +} + +/** + * Chromium bypass rules for a proxied session: the NO_PROXY rules plus + * loopback. Naming loopback also stops Playwright from adding `<-loopback>`, + * which would send localhost through the proxy. + */ +export function withLoopbackBypass(rules: string[]): string { + return [...new Set([...rules, ...LOOPBACK_HOSTS])].join(','); +} + +/** + * The daemon's view of the proxy the CLI resolved: BROWSE_PROXY_URL ('direct' + * means none), its source, and the bypass rules (computed from NO_PROXY when + * a caller other than the CLI set only BROWSE_PROXY_URL). + */ +export function serverProxyEnv(env: NodeJS.ProcessEnv): { url: string | undefined; source: string; bypass: string } { + return { + url: env.BROWSE_PROXY_URL === 'direct' ? undefined : env.BROWSE_PROXY_URL || undefined, + source: env.BROWSE_PROXY_SOURCE || 'BROWSE_PROXY_URL', + bypass: env.BROWSE_PROXY_BYPASS || withLoopbackBypass(noProxyToChromiumRules(env.NO_PROXY || env.no_proxy)), + }; +} + +/** + * Resolve the proxy for this invocation (#915). Env URLs go through + * parseProxyConfig, so the cred policy applies. A standard variable that does + * not parse (curl-style `host:port`, an unsupported scheme) is ignored with a + * warning instead of failing every command; --proxy and BROWSE_PROXY_URL are + * explicit browse settings and still fail closed. + */ +export function resolveProxy(flagUrl: string | null, env: NodeJS.ProcessEnv): ResolvedProxy { + const direct = (warning: string | null = null): ResolvedProxy => ({ url: null, source: null, noProxyRules: null, bypass: null, warning }); + let source: string; + let raw: string; + if (flagUrl) { + [source, raw] = ['--proxy', flagUrl]; + } else if (env.BROWSE_PROXY_URL) { + if (env.BROWSE_PROXY_URL === 'direct') return direct(); + [source, raw] = ['BROWSE_PROXY_URL', env.BROWSE_PROXY_URL]; + } else { + const name = STANDARD_PROXY_VARS.find(key => env[key]); + if (!name) return direct(); + [source, raw] = [name, env[name]!]; + try { + parseProxyConfig({ proxyUrl: raw }); + } catch (err) { + if (!(err instanceof ProxyConfigError)) throw err; + return direct(`${source} is not a proxy URL browse can use (${err.message}; ${err.hint}), so browse connects directly. ` + + `fix: export ${source}=http://host:port (or socks5://), or set BROWSE_PROXY_URL=direct to silence this. see ${PROXY_TROUBLESHOOTING}`); + } + } + const parsed = parseProxyConfig({ proxyUrl: raw, envUser: env.BROWSE_PROXY_USER, envPass: env.BROWSE_PROXY_PASS, source }); + const url = new URL(raw); + if (url.protocol === 'socks5h:') url.protocol = 'socks5:'; + url.username = parsed.userId ? encodeURIComponent(parsed.userId) : ''; + url.password = parsed.password ? encodeURIComponent(parsed.password) : ''; + const rules = noProxyToChromiumRules(env.NO_PROXY || env.no_proxy); + return { + url: url.toString(), + source, + noProxyRules: rules.length ? rules.join(',') : null, + bypass: withLoopbackBypass(rules), + warning: null, + }; +} diff --git a/browse/src/server.ts b/browse/src/server.ts index 6810dc818..048eb37af 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -49,7 +49,7 @@ import { isProcessAlive } from './error-handling'; import { allowedExtensionOrigin } from './extension-id'; import { sanitizeBody, stripLoneSurrogates } from './sanitize'; import { startSocksBridge, testUpstream, type BridgeHandle } from './socks-bridge'; -import { parseProxyConfig, toUpstreamConfig, ProxyConfigError } from './proxy-config'; +import { parseProxyConfig, toUpstreamConfig, ProxyConfigError, serverProxyEnv } from './proxy-config'; import { writeReceipt } from '../../lib/egress-receipt'; import { redactProxyUrl } from './proxy-redact'; import { type XvfbHandle } from './xvfb'; @@ -1881,20 +1881,18 @@ export async function start() { LOCAL_LISTEN_PORT = port; // ─── Proxy config (D8 + codex F5) ────────────────────────────── - // BROWSE_PROXY_URL is set by the CLI when --proxy was passed. For SOCKS5 - // with auth, we run a local 127.0.0.1 bridge that relays to the - // authenticated upstream (Chromium can't do SOCKS5 auth itself). For - // HTTP/HTTPS or unauthenticated SOCKS5, we pass the URL directly to - // Chromium's proxy.server option. + // The CLI resolves the proxy (--proxy or env, #915). SOCKS5 with auth runs a + // local 127.0.0.1 bridge to the authenticated upstream (Chromium can't do + // SOCKS5 auth itself); HTTP/HTTPS or unauthenticated SOCKS5 pass through. let proxyBridge: BridgeHandle | null = null; - const proxyUrl = process.env.BROWSE_PROXY_URL; + const { url: proxyUrl, source: proxySource, bypass: proxyBypass } = serverProxyEnv(process.env); if (proxyUrl) { let parsed; try { parsed = parseProxyConfig({ proxyUrl, envUser: process.env.BROWSE_PROXY_USER, - envPass: process.env.BROWSE_PROXY_PASS, + envPass: process.env.BROWSE_PROXY_PASS, source: proxySource, }); } catch (err) { if (err instanceof ProxyConfigError) { @@ -1925,15 +1923,15 @@ export async function start() { proxyBridge = await startSocksBridge({ upstream: toUpstreamConfig(parsed) }); console.log(`[browse] [proxy] bridge listening on 127.0.0.1:${proxyBridge.port}`); - browserManager.setProxyConfig({ server: `socks5://127.0.0.1:${proxyBridge.port}` }); + browserManager.setProxyConfig({ server: `socks5://127.0.0.1:${proxyBridge.port}`, bypass: proxyBypass }); } else { // HTTP/HTTPS or unauth SOCKS5 — pass through to Chromium directly. browserManager.setProxyConfig({ - server: `${parsed.scheme}://${parsed.host}:${parsed.port}`, + server: `${parsed.scheme}://${parsed.host}:${parsed.port}`, bypass: proxyBypass, ...(parsed.userId ? { username: parsed.userId } : {}), ...(parsed.password ? { password: parsed.password } : {}), }); - console.log(`[browse] [proxy] using ${redactProxyUrl(proxyUrl)} (pass-through to Chromium)`); + console.log(`[browse] [proxy] using ${redactProxyUrl(proxyUrl)} from ${proxySource} (pass-through to Chromium)`); } // Tear down bridge on shutdown. @@ -2018,6 +2016,7 @@ export async function start() { // D2 daemon-mismatch detection: CLI computes the same hash from its // resolved flags and refuses if it differs from this stored value. ...(process.env.BROWSE_CONFIG_HASH ? { configHash: process.env.BROWSE_CONFIG_HASH } : {}), + ...(proxyUrl ? { proxy: redactProxyUrl(proxyUrl), proxySource, proxyBypass } : {}), // #915 refusal detail // Xvfb child PID + start-time + display so disconnect (or a future // daemon launch on this state file) can validate-then-cleanup orphans // without clobbering a recycled PID. diff --git a/browse/src/terminal-agent-control.ts b/browse/src/terminal-agent-control.ts index c07974910..1b7247838 100644 --- a/browse/src/terminal-agent-control.ts +++ b/browse/src/terminal-agent-control.ts @@ -22,6 +22,7 @@ import { safeUnlink, isProcessAlive } from './error-handling'; import { restrictFilePermissions, mkdirSecure } from './file-permissions'; import { atomicWriteSync } from '../../lib/fs-atomic'; import { readPidCmdline, readPidStartTime } from './xvfb'; +import { emptyBunConfig } from './bun-child-config'; function agentProcessInfo(pid: number): { startTime: string; commandLine: string } { if (!Number.isSafeInteger(pid) || pid <= 0) return { startTime: '', commandLine: '' }; @@ -142,8 +143,11 @@ export function acquireAgentStateLock(stateDir: string, waitMs = 5000, publicati * Flags for every `bun run` child the daemon spawns. The child runs in the * user's project, and Bun would otherwise load that project's .env (which can * set GSTACK_CHROMIUM_NO_SANDBOX) and bunfig.toml (whose preload runs code). + * Built per spawn so the empty config is re-checked each time. */ -export const BUN_CHILD_FLAGS = ['--no-env-file', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`]; +export function bunChildFlags(): string[] { + return ['--no-env-file', `--config=${emptyBunConfig()}`]; +} export function resolveTerminalAgentScript(searchHints: { metaDir?: string; execPath?: string } = {}): string | null { const meta = searchHints.metaDir || __dirname; @@ -204,7 +208,7 @@ export function spawnTerminalAgent(opts: { writeAgentRecord(stateDir, reservation); let proc: any; try { - proc = (Bun as any).spawn(['bun', 'run', ...BUN_CHILD_FLAGS, script, `--agent-gen=${gen}`], { + proc = (Bun as any).spawn(['bun', 'run', ...bunChildFlags(), script, `--agent-gen=${gen}`], { cwd: opts.cwd || process.cwd(), env: { ...process.env, diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index b0e09b86f..7bd23b504 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -17,7 +17,7 @@ import { guardScreenshotPath } from './screenshot-size-guard'; import * as fs from 'fs'; import * as path from 'path'; import type { SetContentWaitUntil } from './tab-session'; -import { TEMP_DIR, isPathWithin } from './platform'; +import { TEMP_DIR } from './platform'; import { SAFE_DIRECTORIES } from './path-security'; import { modifyStyle, undoModification, resetModifications, getModificationHistory } from './cdp-inspector'; import { withCdpSession } from './cdp-bridge'; @@ -668,18 +668,9 @@ export async function handleWriteCommand( case 'cookie-import': { const filePath = args[0]; if (!filePath) throw new Error('Usage: browse cookie-import '); - // Path validation — resolve to absolute and check against safe dirs. - // Fixes #707: relative paths previously bypassed the safe directory check. - // Mirrors validateOutputPath() — resolves symlinks (e.g., macOS /tmp → /private/tmp). - const resolved = path.resolve(filePath); - let resolvedReal = resolved; - try { resolvedReal = fs.realpathSync(resolved); } catch { - // File may not exist yet — resolve parent dir instead - try { resolvedReal = path.join(fs.realpathSync(path.dirname(resolved)), path.basename(resolved)); } catch {} - } - if (!SAFE_DIRECTORIES.some(dir => isPathWithin(resolvedReal, dir))) { - throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`); - } + // Path validation against safe dirs (#707: relative paths included), + // through the same realpath as SAFE_DIRECTORIES (#3104). + validateReadPath(filePath); if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`); const raw = fs.readFileSync(filePath, 'utf-8'); let cookies: any[]; diff --git a/browse/test/bun-child-config.test.ts b/browse/test/bun-child-config.test.ts new file mode 100644 index 000000000..307c012cb --- /dev/null +++ b/browse/test/bun-child-config.test.ts @@ -0,0 +1,83 @@ +/** + * #3085: `--config=NUL` panicked Bun 1.3.10 on Windows at flag parsing, and the + * terminal-agent respawn loop turned that into a crash window every few + * seconds. Every Bun child browse spawns now takes its `--config` from + * emptyBunConfig(): /dev/null on POSIX, and on Windows a zero-byte bunfig in a + * private directory that is re-checked before each spawn. + */ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { emptyBunConfig } from '../src/bun-child-config'; +import { bunChildFlags } from '../src/terminal-agent-control'; + +const SRC = path.resolve(import.meta.dir, '../src'); +const SPAWN_SITES = ['terminal-agent-control.ts', 'cookie-import-native.ts', 'cookie-import-native-worker.ts', 'cli.ts']; + +describe('#3085 empty Bun config for spawned children', () => { + test('POSIX keeps /dev/null', () => { + expect(emptyBunConfig('linux')).toBe('/dev/null'); + expect(emptyBunConfig('darwin')).toBe('/dev/null'); + }); + + test('Windows gets a zero-byte bunfig in a private directory, reused while it stays empty', () => { + const file = emptyBunConfig('win32'); + expect(path.basename(file)).toBe('bunfig.toml'); + expect(path.basename(path.dirname(file))).toStartWith('gstack-bunfig-'); + expect(fs.lstatSync(file).isFile()).toBe(true); + expect(fs.statSync(file).size).toBe(0); + expect(emptyBunConfig('win32')).toBe(file); + }); + + test('a bunfig that gained content or was swapped for a link is replaced before the next spawn', () => { + const tampered = emptyBunConfig('win32'); + fs.writeFileSync(tampered, '[test]\npreload = ["./evil.ts"]\n'); + const fresh = emptyBunConfig('win32'); + expect(fresh).not.toBe(tampered); + expect(fs.statSync(fresh).size).toBe(0); + + if (process.platform === 'win32') return; + fs.rmSync(fresh); + fs.symlinkSync(tampered, fresh); + const third = emptyBunConfig('win32'); + expect(third).not.toBe(fresh); + expect(fs.lstatSync(third).isFile()).toBe(true); + expect(fs.statSync(third).size).toBe(0); + }); + + test("a child started with the helper's config ignores the project's bunfig preload", () => { + const project = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-bunfig-project-')); + try { + fs.writeFileSync(path.join(project, 'preload.ts'), 'console.log("PRELOAD_RAN");\n'); + fs.writeFileSync(path.join(project, 'bunfig.toml'), 'preload = ["./preload.ts"]\n'); + fs.writeFileSync(path.join(project, 'probe.ts'), 'console.log("PROBE_RAN");\n'); + const run = (flags: string[]) => Bun.spawnSync([process.execPath, 'run', ...flags, 'probe.ts'], { cwd: project, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 }).stdout.toString(); + expect(run(['--no-env-file'])).toContain('PRELOAD_RAN'); + for (const platform of ['win32', process.platform] as NodeJS.Platform[]) { + const out = run(['--no-env-file', `--config=${emptyBunConfig(platform)}`]); + expect(out).toContain('PROBE_RAN'); + expect(out).not.toContain('PRELOAD_RAN'); + } + } finally { + fs.rmSync(project, { recursive: true, force: true }); + } + }); + + test('bunChildFlags builds the config flag from the helper on every call', () => { + expect(bunChildFlags()).toEqual(['--no-env-file', `--config=${emptyBunConfig()}`]); + }); + + test('no spawn passes NUL, and every --config goes through the helper', () => { + for (const file of SPAWN_SITES) { + const source = fs.readFileSync(path.join(SRC, file), 'utf8'); + expect(source).not.toMatch(/['"`]NUL['"`]|=NUL\b/); + for (const flag of source.match(/['"`]--config=[^'"`]*/g) ?? []) { + expect(`${file}: ${flag}`).toBe(`${file}: ${flag.slice(0, 1)}--config=\${emptyBunConfig()}`); + } + } + const spawned = SPAWN_SITES.map(file => fs.readFileSync(path.join(SRC, file), 'utf8')); + expect(spawned.filter(source => source.includes('${emptyBunConfig()}')).length).toBe(3); + expect(spawned.filter(source => source.includes('...bunChildFlags()')).length).toBe(2); + }); +}); diff --git a/browse/test/path-security-realpath.test.ts b/browse/test/path-security-realpath.test.ts new file mode 100644 index 000000000..aa3b03267 --- /dev/null +++ b/browse/test/path-security-realpath.test.ts @@ -0,0 +1,89 @@ +/** + * #3104: safe directories and target paths go through one realpath function. + * On Windows `fs.realpathSync` keeps an 8.3 short temp dir (C:\Users\BURCIN~1) + * while `fs.realpathSync.native` expands it, so a make-pdf target inside the + * temp dir was rejected. Each case runs the validator in a child process whose + * temp environment is the alias under test, the way the daemon computes its + * safe directories once at startup. On POSIX the fixture lives under /var/tmp + * because /tmp is always a safe directory there. + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const VALIDATOR = path.resolve(import.meta.dir, '../src/path-security.ts'); +const WIN = process.platform === 'win32'; +let root = ''; + +beforeAll(() => { + root = fs.realpathSync.native(fs.mkdtempSync(path.join(WIN ? os.tmpdir() : path.join(path.sep, 'var', 'tmp'), 'gstack-realpath-'))); + fs.mkdirSync(path.join(root, 'cwd')); + fs.mkdirSync(path.join(root, 'Long Temp Directory')); +}); + +afterAll(() => { + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +function validate(tempDir: string, checks: Array<['output' | 'read', string]>): string[] { + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) if (value !== undefined) env[key] = value; + if (WIN) Object.assign(env, { TEMP: tempDir, TMP: tempDir }); + else env.TMPDIR = tempDir; + const script = ` + const v = require(${JSON.stringify(VALIDATOR)}); + for (const [kind, target] of ${JSON.stringify(checks)}) { + try { (kind === 'output' ? v.validateOutputPath : v.validateReadPath)(target); console.log('OK'); } + catch (e) { console.log('REJECT ' + e.message); } + }`; + const result = Bun.spawnSync([process.execPath, '-e', script], { cwd: path.join(root, 'cwd'), env, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 }); + expect(result.stderr.toString()).toBe(''); + return result.stdout.toString().trim().split(/\r?\n/); +} + +function shortPath(dir: string): string { + const result = Bun.spawnSync(['powershell', '-NoProfile', '-NonInteractive', '-Command', + `(New-Object -ComObject Scripting.FileSystemObject).GetFolder($env:GSTACK_LONG_DIR).ShortPath`], { + env: { ...process.env, GSTACK_LONG_DIR: dir }, stdout: 'pipe', stderr: 'pipe', timeout: 30_000, + }); + return result.stdout.toString().trim(); +} + +describe('#3104 one realpath for safe dirs and targets', () => { + test('a temp dir reached through an alias accepts outputs and reads inside it', () => { + const real = path.join(root, 'Long Temp Directory'); + let alias = path.join(root, 'alias'); + if (WIN) { + alias = shortPath(real); + if (!alias || alias.toLowerCase() === real.toLowerCase()) { + console.log(`[skip] 8.3 short names are disabled on this volume (${real}); the alias case cannot be built`); + return; + } + } else { + fs.symlinkSync(real, alias); + } + const work = path.join(alias, 'gstack-render-browse-probe'); + fs.mkdirSync(work); + fs.writeFileSync(path.join(work, 'pdf-0.json'), '{}'); + expect(validate(alias, [ + ['output', path.join(work, 'artifact-0.pdf')], + ['output', path.join(work, 'new-dir', 'artifact-1.pdf')], + ['read', path.join(work, 'pdf-0.json')], + ['read', path.join(work, 'missing-dir', 'payload.json')], + ['output', path.join(root, 'outside.pdf')], + ])).toEqual(['OK', 'OK', 'OK', 'OK', expect.stringMatching(/^REJECT Path must be within: /)]); + }); + + test('a missing temp dir matches a target that differs only in case on Windows, and only there', () => { + const missing = path.join(root, 'Missing-Temp'); + const [result] = validate(missing, [['output', path.join(root, 'missing-temp', 'artifact.pdf')]]); + if (WIN) expect(result).toBe('OK'); + else expect(result).toMatch(/^REJECT Path must be within: /); + }); + + test('existing components compare exactly after resolution', () => { + const [result] = validate(path.join(root, 'Long Temp Directory'), [['output', path.join(root, 'Long Temp Directory-evil', 'x.pdf')]]); + expect(result).toMatch(/^REJECT Path must be within: /); + }); +}); diff --git a/browse/test/proxy-env-daemon.test.ts b/browse/test/proxy-env-daemon.test.ts new file mode 100644 index 000000000..69e1a00d8 --- /dev/null +++ b/browse/test/proxy-env-daemon.test.ts @@ -0,0 +1,137 @@ +/** + * #915 end to end: the CLI resolves the proxy from the standard variables and + * the daemon's Chromium navigates through it, across launch, reuse and + * restart. A local HTTP proxy answers every proxied request itself, so a page + * at a name that does not resolve loads only through the proxy; a loopback + * page loads directly. NO_PROXY is part of the daemon config, so a change to it + * alone is refused instead of silently reusing the daemon. + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const CLI = path.resolve(import.meta.dir, '../src/cli.ts'); +const PROXIED_HOST = 'proxied.gstack-915.test'; + +let scratch = ''; +let stateFile = ''; +let proxy: http.Server; +let proxyUrl = ''; +let direct: ReturnType; +const proxiedHosts: string[] = []; + +beforeAll(async () => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-proxy-env-')); + stateFile = path.join(scratch, 'state', 'browse.json'); + proxy = http.createServer((req, res) => { + const host = new URL(req.url ?? '/', 'http://unknown').hostname; + proxiedHosts.push(host); + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(`

via proxy for ${host}

`); + }); + proxy.on('connect', (_req, socket) => socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n')); + await new Promise(resolve => proxy.listen(0, '127.0.0.1', () => resolve())); + proxyUrl = `http://127.0.0.1:${(proxy.address() as { port: number }).port}`; + direct = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response('

direct loopback page

', { headers: { 'Content-Type': 'text/html' } }) }); +}); + +afterAll(async () => { + await browse(['stop'], {}); + if (fs.existsSync(stateFile)) { + try { process.kill(JSON.parse(fs.readFileSync(stateFile, 'utf8')).pid, 'SIGKILL'); } catch {} + } + try { direct.stop(true); } catch {} + await new Promise(resolve => proxy.close(() => resolve())); + fs.rmSync(scratch, { recursive: true, force: true }); +}); + +async function browse(args: string[], extraEnv: Record) { + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && !/^(BROWSE_|GSTACK_|NO_PROXY$|no_proxy$|HTTPS?_PROXY$|https?_proxy$|ALL_PROXY$|all_proxy$)/.test(key)) env[key] = value; + } + Object.assign(env, { + HOME: scratch, + GSTACK_HOME: path.join(scratch, '.gstack'), + PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), ...(process.platform === 'darwin' ? ['Library', 'Caches'] : ['.cache']), 'ms-playwright'), + BROWSE_STATE_FILE: stateFile, + BROWSE_PARENT_PID: '0', + }, extraEnv); + const cli = Bun.spawn([process.execPath, CLI, ...args], { cwd: scratch, env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }); + const timer = setTimeout(() => cli.kill('SIGKILL'), 60_000); + const [code, stdout, stderr] = await Promise.all([cli.exited, new Response(cli.stdout).text(), new Response(cli.stderr).text()]); + clearTimeout(timer); + return { code, stdout, stderr, out: stdout + stderr }; +} + +const daemonPid = () => JSON.parse(fs.readFileSync(stateFile, 'utf8')).pid as number; + +describe.skipIf(process.platform === 'win32')('#915 proxy from the environment, across launch, reuse and restart', () => { + let firstPid = 0; + + test('launch: HTTP_PROXY routes Chromium through the proxy and names its source once', async () => { + const r = await browse(['goto', `http://${PROXIED_HOST}/`], { HTTP_PROXY: proxyUrl }); + expect(r.code, r.out).toBe(0); + expect(r.stderr).toContain(`Starting server with proxy ${proxyUrl}/ from HTTP_PROXY...`); + expect(proxiedHosts).toContain(PROXIED_HOST); + const text = await browse(['text'], { HTTP_PROXY: proxyUrl }); + expect(text.stdout).toContain(`via proxy for ${PROXIED_HOST}`); + firstPid = daemonPid(); + const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + expect(state).toMatchObject({ proxy: `${proxyUrl}/`, proxySource: 'HTTP_PROXY', proxyBypass: 'localhost,127.0.0.1,[::1]' }); + }, 120_000); + + test('reuse: the same environment reuses the daemon, and loopback stays direct', async () => { + const before = proxiedHosts.length; + const r = await browse(['goto', `http://127.0.0.1:${direct.port}/`], { http_proxy: proxyUrl }); + expect(r.code, r.out).toBe(0); + expect(r.stderr).not.toContain('Starting server'); + expect(daemonPid()).toBe(firstPid); + expect((await browse(['text'], { http_proxy: proxyUrl })).stdout).toContain('direct loopback page'); + expect(proxiedHosts.length).toBe(before); + }, 120_000); + + test('a NO_PROXY-only change is refused with the variable, both settings and the remedies', async () => { + const r = await browse(['text'], { HTTP_PROXY: proxyUrl, NO_PROXY: PROXIED_HOST }); + expect(r.code).toBe(1); + expect(r.stderr).toContain('different proxy settings (NO_PROXY differs)'); + expect(r.stderr).toContain(`daemon: proxy ${proxyUrl}/ from HTTP_PROXY, bypassing localhost,127.0.0.1,[::1]`); + expect(r.stderr).toContain(`session: proxy ${proxyUrl}/ from HTTP_PROXY, bypassing *.${PROXIED_HOST},${PROXIED_HOST},localhost,127.0.0.1,[::1]`); + expect(r.stderr).toContain("run 'browse stop'"); + expect(r.stderr).toContain('BROWSER.md#proxy-from-environment'); + expect(daemonPid()).toBe(firstPid); + }, 120_000); + + test('BROWSE_PROXY_URL=direct against a proxied daemon is refused, naming the variable', async () => { + const r = await browse(['text'], { HTTP_PROXY: proxyUrl, BROWSE_PROXY_URL: 'direct' }); + expect(r.code).toBe(1); + expect(r.stderr).toContain('(HTTP_PROXY differs)'); + expect(r.stderr).toContain('session: no proxy (BROWSE_PROXY_URL=direct)'); + expect(r.stderr).toContain("run 'browse stop'"); + }, 120_000); + + test('restart: after browse stop, the new NO_PROXY applies to the new daemon', async () => { + expect((await browse(['stop'], {})).code).toBe(0); + const before = proxiedHosts.length; + const r = await browse(['goto', `http://${PROXIED_HOST}/`], { HTTP_PROXY: proxyUrl, NO_PROXY: `${PROXIED_HOST},corp.internal` }); + expect(r.stderr).toContain(`Starting server with proxy ${proxyUrl}/ from HTTP_PROXY...`); + expect(daemonPid()).not.toBe(firstPid); + expect(r.out).not.toContain(`via proxy for ${PROXIED_HOST}`); + expect(proxiedHosts.length).toBe(before); + }, 120_000); + + test('a curl-style HTTPS_PROXY warns and falls back to direct instead of failing', async () => { + expect((await browse(['stop'], {})).code).toBe(0); + const before = proxiedHosts.length; + const r = await browse(['goto', `http://127.0.0.1:${direct.port}/`], { HTTPS_PROXY: 'user:secret@proxy.corp:8080' }); + expect(r.code, r.out).toBe(0); + expect(r.stderr).toContain('warning: HTTPS_PROXY is not a proxy URL browse can use'); + expect(r.stderr).toContain('BROWSE_PROXY_URL=direct'); + expect(r.out).not.toContain('secret'); + expect(r.stderr).toContain('Starting server...'); + expect(proxiedHosts.length).toBe(before); + expect(JSON.parse(fs.readFileSync(stateFile, 'utf8')).proxy).toBeUndefined(); + }, 120_000); +}); diff --git a/browse/test/proxy-env.test.ts b/browse/test/proxy-env.test.ts new file mode 100644 index 000000000..1109c8b24 --- /dev/null +++ b/browse/test/proxy-env.test.ts @@ -0,0 +1,189 @@ +/** + * #915: proxy precedence, NO_PROXY translation, the config hash and the + * mismatch refusal. The daemon round trip is in proxy-env-daemon.test.ts. + */ +import { describe, expect, test } from 'bun:test'; +import { computeConfigHash, noProxyToChromiumRules, parseProxyConfig, resolveProxy, serverProxyEnv, withLoopbackBypass } from '../src/proxy-config'; +import { buildHeadedServerEnv, buildRestartEnv, extractGlobalFlags, proxyMismatchLines, withLoopbackNoProxy } from '../src/cli'; + +describe('resolveProxy precedence', () => { + const env = { + BROWSE_PROXY_URL: 'http://browse.example:1', + HTTPS_PROXY: 'http://https-upper.example:2', + https_proxy: 'http://https-lower.example:3', + HTTP_PROXY: 'http://http-upper.example:4', + http_proxy: 'http://http-lower.example:5', + ALL_PROXY: 'socks5://all-upper.example:6', + all_proxy: 'socks5://all-lower.example:7', + }; + const order: Array<[string, string]> = [ + ['BROWSE_PROXY_URL', 'http://browse.example:1/'], + ['HTTPS_PROXY', 'http://https-upper.example:2/'], + ['https_proxy', 'http://https-lower.example:3/'], + ['HTTP_PROXY', 'http://http-upper.example:4/'], + ['http_proxy', 'http://http-lower.example:5/'], + ['ALL_PROXY', 'socks5://all-upper.example:6'], + ['all_proxy', 'socks5://all-lower.example:7'], + ]; + + test('--proxy wins over every variable', () => { + expect(resolveProxy('http://flag.example:9', env)).toMatchObject({ url: 'http://flag.example:9/', source: '--proxy' }); + }); + + test('then BROWSE_PROXY_URL, HTTPS_PROXY, HTTP_PROXY and ALL_PROXY, upper case before lower case', () => { + const remaining: Record = { ...env }; + for (const [source, url] of order) { + expect(resolveProxy(null, remaining)).toMatchObject({ url, source, warning: null }); + delete remaining[source]; + } + expect(resolveProxy(null, remaining)).toEqual({ url: null, source: null, noProxyRules: null, bypass: null, warning: null }); + }); + + test('BROWSE_PROXY_URL=direct opts out of the standard variables, but not out of --proxy', () => { + expect(resolveProxy(null, { ...env, BROWSE_PROXY_URL: 'direct' })).toMatchObject({ url: null, source: null, warning: null }); + expect(resolveProxy('http://flag.example:9', { ...env, BROWSE_PROXY_URL: 'direct' }).source).toBe('--proxy'); + }); + + test('empty variables count as unset', () => { + expect(resolveProxy(null, { HTTPS_PROXY: '', HTTP_PROXY: 'http://h:8' }).source).toBe('HTTP_PROXY'); + }); + + test('socks5h maps to socks5, which already resolves names through the proxy', () => { + expect(resolveProxy(null, { ALL_PROXY: 'socks5h://u:p@sock.example:1080' }).url).toBe('socks5://u:p@sock.example:1080'); + expect(parseProxyConfig({ proxyUrl: 'socks5h://sock.example' })).toMatchObject({ scheme: 'socks5', port: 1080 }); + }); + + test('env URLs keep the cred policy, and the hint names the variable', () => { + expect(resolveProxy(null, { HTTPS_PROXY: 'http://proxy.example:3128', BROWSE_PROXY_USER: 'USER', BROWSE_PROXY_PASS: 'PASSWORD' }).url).toBe('http://USER:PASSWORD@proxy.example:3128/'); + try { + resolveProxy(null, { HTTPS_PROXY: 'http://USER:PASSWORD@proxy.example:3128', BROWSE_PROXY_USER: 'u' }); + throw new Error('expected the cred policy to refuse'); + } catch (err: any) { + expect(err.message).toMatch(/pick one source/); + expect(err.hint).toBe('unset BROWSE_PROXY_USER/PASS or remove user:pass@ from HTTPS_PROXY'); + } + }); + + test('a standard variable that does not parse warns and falls back to direct, without echoing it', () => { + for (const value of ['proxy.corp:8080', 'user:secret@proxy.corp:8080', 'ftp://proxy.corp:21', 'http://proxy.corp:99999']) { + const resolved = resolveProxy(null, { https_proxy: value }); + expect(resolved).toMatchObject({ url: null, source: null, bypass: null }); + expect(resolved.warning).toStartWith('https_proxy is not a proxy URL browse can use ('); + expect(resolved.warning).toContain('fix: export https_proxy=http://host:port (or socks5://), or set BROWSE_PROXY_URL=direct'); + expect(resolved.warning).toContain('BROWSER.md#proxy-from-environment'); + expect(resolved.warning).not.toContain('secret'); + } + }); + + test('--proxy and BROWSE_PROXY_URL are explicit and still fail closed', () => { + expect(() => resolveProxy('proxy.corp:8080', {})).toThrow(/unsupported proxy scheme/); + expect(() => resolveProxy(null, { BROWSE_PROXY_URL: 'proxy.corp:8080' })).toThrow(/unsupported proxy scheme/); + }); +}); + +describe('NO_PROXY → Chromium bypass rules', () => { + test('curl-style entries become Chromium rules; loopback is always bypassed', () => { + expect(noProxyToChromiumRules('example.com, .corp.internal,*.wild.test 10.0.0.0/8,host.test:8080,::1,fe80::1,[2001:db8::2]:443,192.168.1.5,localhost,127.0.0.1,bad:port:x:')).toEqual([ + '*.corp.internal', '*.example.com', '*.host.test:8080', '*.wild.test', '10.0.0.0/8', '192.168.1.5', + '[2001:db8::2]:443', '[fe80::1]', 'corp.internal', 'example.com', 'host.test:8080', + ].sort()); + expect(noProxyToChromiumRules('*')).toEqual(['*']); + expect(noProxyToChromiumRules(undefined)).toEqual([]); + expect(withLoopbackBypass([])).toBe('localhost,127.0.0.1,[::1]'); + }); + + test("the CLI's own loopback NO_PROXY additions never change the rules (#494)", () => { + expect(noProxyToChromiumRules(withLoopbackNoProxy({ NO_PROXY: 'corp.internal' }))).toEqual(noProxyToChromiumRules('corp.internal')); + expect(resolveProxy(null, { HTTP_PROXY: 'http://p:1', NO_PROXY: withLoopbackNoProxy({}) }).noProxyRules).toBeNull(); + }); +}); + +describe('config hash', () => { + const base = { HTTP_PROXY: 'http://proxy.example:3128' }; + + test('a NO_PROXY-only change changes the hash of a proxied session', () => { + const a = extractGlobalFlags(['text'], base); + const b = extractGlobalFlags(['text'], { ...base, NO_PROXY: 'corp.internal' }); + expect(a.proxyUrl).toBe(b.proxyUrl!); + expect(a.configHash).not.toBe(b.configHash); + }); + + test('without NO_PROXY entries the hash is the pre-#915 hash, so running daemons stay reusable', () => { + expect(extractGlobalFlags(['--proxy', 'socks5://reuse.example:1080', 'text'], {}).configHash) + .toBe(computeConfigHash({ proxyUrl: 'socks5://reuse.example:1080', headed: false })); + expect(extractGlobalFlags(['text'], { NO_PROXY: 'corp.internal' }).configHash).toBe(computeConfigHash({ proxyUrl: null, headed: false })); + }); + + test('the env-resolved proxy is part of the hash', () => { + expect(extractGlobalFlags(['text'], base).configHash).not.toBe(extractGlobalFlags(['text'], {}).configHash); + expect(extractGlobalFlags(['text'], base).configHash).toBe(extractGlobalFlags(['--proxy', 'http://proxy.example:3128', 'text'], {}).configHash); + }); +}); + +describe('server env carries the proxy source and bypass', () => { + const flags = extractGlobalFlags(['text'], { HTTPS_PROXY: 'http://proxy.example:3128', NO_PROXY: 'corp.internal' }); + const expected = { + BROWSE_PROXY_URL: 'http://proxy.example:3128/', + BROWSE_PROXY_SOURCE: 'HTTPS_PROXY', + BROWSE_PROXY_BYPASS: '*.corp.internal,corp.internal,localhost,127.0.0.1,[::1]', + }; + + test('restart and headed launches reapply all three', () => { + expect(buildRestartEnv(flags, null)).toMatchObject(expected); + expect(buildHeadedServerEnv(flags)).toMatchObject(expected); + expect(buildRestartEnv(extractGlobalFlags(['text'], {}), null).BROWSE_PROXY_URL).toBeUndefined(); + }); +}); + +describe('serverProxyEnv (the daemon side)', () => { + test('direct means no proxy, and a launch without the CLI still keeps loopback and NO_PROXY', () => { + expect(serverProxyEnv({ BROWSE_PROXY_URL: 'direct' }).url).toBeUndefined(); + expect(serverProxyEnv({ BROWSE_PROXY_URL: 'http://p:1', NO_PROXY: 'corp.internal' })).toEqual({ + url: 'http://p:1', source: 'BROWSE_PROXY_URL', bypass: '*.corp.internal,corp.internal,localhost,127.0.0.1,[::1]', + }); + expect(serverProxyEnv({ BROWSE_PROXY_URL: 'http://p:1', BROWSE_PROXY_SOURCE: 'ALL_PROXY', BROWSE_PROXY_BYPASS: 'x,localhost' })).toMatchObject({ source: 'ALL_PROXY', bypass: 'x,localhost' }); + }); +}); + +describe('proxyMismatchLines', () => { + const state = (extra: Record = {}) => ({ pid: 1, port: 1, token: 't', startedAt: '', serverPath: '', mode: 'launched' as const, configHash: 'x', ...extra }); + const proxied = { proxy: 'http://proxy.example:3128/', proxySource: 'HTTPS_PROXY', proxyBypass: 'localhost,127.0.0.1,[::1]' }; + + test('an env proxy meeting a direct daemon names the variable and offers stop or direct', () => { + const lines = proxyMismatchLines(extractGlobalFlags(['text'], { HTTPS_PROXY: 'http://proxy.example:3128' }), state(), {})!; + expect(lines[0]).toContain('(HTTPS_PROXY differs)'); + expect(lines[1]).toBe(' daemon: no proxy'); + expect(lines[2]).toBe(' session: proxy http://proxy.example:3128/ from HTTPS_PROXY, bypassing localhost,127.0.0.1,[::1]'); + expect(lines[3]).toBe("fix: run 'browse stop' to restart the daemon with this session's proxy settings, or set BROWSE_PROXY_URL=direct to use the running daemon without a proxy."); + expect(lines[4]).toBe('see https://github.com/garrytan/gstack/blob/main/BROWSER.md#proxy-from-environment'); + }); + + test('a direct session meeting an env-proxied daemon names the daemon variable', () => { + const lines = proxyMismatchLines(extractGlobalFlags(['text'], {}), state(proxied), {})!; + expect(lines[0]).toContain('(HTTPS_PROXY differs)'); + expect(lines[2]).toBe(' session: no proxy'); + expect(lines[3]).toContain("or run this session with the daemon's HTTPS_PROXY"); + }); + + test('a credentialed proxy is redacted on both sides', () => { + const lines = proxyMismatchLines(extractGlobalFlags(['text'], { HTTPS_PROXY: 'http://USER:PASSWORD@proxy.example:3128' }), state(), {})!; + expect(lines.join('\n')).not.toContain('PASSWORD'); + expect(lines[2]).toContain('http://***:***@proxy.example:3128/'); + }); + + test('--proxy and headed differences keep the existing refusals', () => { + expect(proxyMismatchLines(extractGlobalFlags(['--proxy', 'http://p:1', 'text'], {}), state(), {})).toBeNull(); + expect(proxyMismatchLines(extractGlobalFlags(['text'], { HTTPS_PROXY: 'http://p:1' }), state({ mode: 'headed' }), {})).toBeNull(); + }); +}); + +describe('BROWSER.md documents the proxy messages', () => { + test('the anchor every #915 message cites exists and covers the precedence and both messages', async () => { + const doc = await Bun.file(new URL('../../BROWSER.md', import.meta.url)).text(); + const section = doc.slice(doc.indexOf('\n### Proxy from environment\n')); + expect(section.length).toBeLessThan(doc.length); + for (const needle of ['BROWSE_PROXY_URL=direct', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', 'is not a proxy URL browse can use', 'different proxy settings', 'browse stop']) { + expect(section).toContain(needle); + } + }); +}); diff --git a/browse/test/sidebar-tabs.test.ts b/browse/test/sidebar-tabs.test.ts index 40e4edbd7..a8ece8f68 100644 --- a/browse/test/sidebar-tabs.test.ts +++ b/browse/test/sidebar-tabs.test.ts @@ -233,7 +233,7 @@ describe('cli.ts: sidebar-agent is no longer spawned', () => { 'utf-8', ); expect(CONTROL_SRC).toContain('terminal-agent.ts'); - expect(CONTROL_SRC).toMatch(/\.spawn\(\['bun',\s*'run',\s*\.\.\.BUN_CHILD_FLAGS,\s*script,\s*`--agent-gen=\$\{gen\}`\]/); + expect(CONTROL_SRC).toMatch(/\.spawn\(\['bun',\s*'run',\s*\.\.\.bunChildFlags\(\),\s*script,\s*`--agent-gen=\$\{gen\}`\]/); expect(CONTROL_SRC).toContain('BROWSE_OWNER_PID: String(opts.ownerPid)'); }); }); diff --git a/package.json b/package.json index d88d09008..5ebe68b97 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.85", + "version": "1.91.86", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module",