mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
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:
1 parent
50d020ad9d
commit
47cd4a9f14
22 files changed
+880
-94
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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: /);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user