v1.91.86.0 fix(browse): proxy from environment, Windows 8.3 temp paths, no NUL bunfig (Wave 9 Lane E) (#3135)

browse resolves its proxy from --proxy, BROWSE_PROXY_URL, then HTTPS_PROXY,
HTTP_PROXY and ALL_PROXY (BROWSE_PROXY_URL=direct opts out), translates
NO_PROXY to Chromium bypass rules with loopback always kept, and hashes the
proxy and NO_PROXY rules into the daemon config so a differing environment is
refused with the variable named. Unparseable standard variables warn and go
direct; browse stop always stops (#915, supersedes #916).

Safe directories and targets resolve through one realpath
(fs.realpathSync.native), fixing Windows 8.3 temp paths (#3104). Bun children
get an empty private bunfig instead of --config=NUL, which panicked Bun
1.3.10 on Windows (#3085).

Co-authored-by: ningfeemic-dev <239259264+ningfeemic-dev@users.noreply.github.com>
This commit is contained in:
Garry Tanandningfeemic-dev authored and GitHub committed 2026-10-11 15:56:22 +00:00
1 parent 50d020ad9d
commit 47cd4a9f14
22 files changed
+880 -94

No files matched your search

+1
View File
@@ -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/
+36
View File
@@ -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 <url>`
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 (<VAR> 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.
+26
View File
@@ -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).**
+1 -1
View File
@@ -1 +1 @@
1.91.85.0
1.91.86.0
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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:<bridgePort>); 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<number, Page> = new Map();
private tabSessions: Map<number, TabSession> = 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;
}
+35
View File
@@ -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;
}
+82 -32
View File
@@ -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<Pick<GlobalFlags, 'proxyUrl' | 'proxySource' | 'proxyBypass'>> | null | undefined): Record<string, string> {
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<string, string> {
const env: Record<string, string> = {};
if (globalFlags?.proxyUrl) env.BROWSE_PROXY_URL = globalFlags.proxyUrl;
const env: Record<string, string> = 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, 'proxyUrl' | 'configHash'>,
globalFlags: Pick<GlobalFlags, 'proxyUrl' | 'configHash'> & Partial<Pick<GlobalFlags, 'proxySource' | 'proxyBypass'>>,
): Record<string, string> {
return {
BROWSE_HEADED: '1',
@@ -503,7 +547,7 @@ export function buildHeadedServerEnv(
// Apply --proxy from this invocation if present. Without this,
// `browse --proxy <url> 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<string, string>): Promise<ServerSta
// (PPID=1, STAT=Ss) and survives the spawning shell's exit. Mirrors
// the Windows path's rationale — same root cause, different OS API.
const daemonLogFd = openDaemonLogSink();
const child = nodeSpawn('bun', ['run', ...BUN_CHILD_FLAGS, server.script], {
const child = nodeSpawn('bun', ['run', ...bunChildFlags(), server.script], {
detached: true,
windowsHide: true,
stdio: ['ignore', daemonLogFd, daemonLogFd],
@@ -844,8 +888,7 @@ export function acquireServerLock(
async function ensureServer(flags?: GlobalFlags): Promise<ServerState> {
const state = readState();
const desiredHash = flags?.configHash;
const extraEnv: Record<string, string> = {};
if (flags?.proxyUrl) extraEnv.BROWSE_PROXY_URL = flags.proxyUrl;
const extraEnv: Record<string, string> = 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<ServerState> {
// 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<ServerState> {
if (state && state.pid) {
await killServer(state.pid);
}
if (flags?.proxyWarning) console.error(`[browse] warning: ${flags.proxyWarning}`);
if (flags?.redactedProxyUrl && flags.redactedProxyUrl !== '<no proxy>') {
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) ──────────────
@@ -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',
+2 -1
View File
@@ -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,
+2 -1
View File
@@ -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,
+39 -24
View File
@@ -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');
+132 -4
View File
@@ -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 '<unparseable>';
}
}
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<string>();
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,
};
}
+10 -11
View File
@@ -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.
+6 -2
View File
@@ -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,
+4 -13
View File
@@ -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 <json-file>');
// 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[];
+83
View File
@@ -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);
});
});
@@ -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<string, string> = {};
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: /);
});
});
+137
View File
@@ -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<typeof Bun.serve>;
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(`<!doctype html><html><body><p>via proxy for ${host}</p></body></html>`);
});
proxy.on('connect', (_req, socket) => socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n'));
await new Promise<void>(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('<!doctype html><html><body><p>direct loopback page</p></body></html>', { 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<void>(resolve => proxy.close(() => resolve()));
fs.rmSync(scratch, { recursive: true, force: true });
});
async function browse(args: string[], extraEnv: Record<string, string>) {
const env: Record<string, string> = {};
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);
});
+189
View File
@@ -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<string, string> = { ...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<string, unknown> = {}) => ({ 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);
}
});
});
+1 -1
View File
@@ -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)');
});
});
+1 -1
View File
@@ -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",