From f1c4c280ba653b0e906ab35e4ea545e85776955d Mon Sep 17 00:00:00 2001 From: Randy Date: Thu, 8 Oct 2026 19:01:55 +0800 Subject: [PATCH] fix(cdp): remote --attach takes its proxy from the caller, once, at session open `session new --attach=` (and markdown/search --attach) now resolve the proxy for the attach URL from the CLI's own environment (env vars only, NO_PROXY with CIDR/suffix support, loopback always direct) and pin it on the session. CDP discovery, the session's CDP websocket and the facade bridge all use exactly that value; the daemon's own proxy env is never read. A SOCKS all_proxy in the daemon's env used to crash discovery even with the host in NO_PROXY. - httpx[socks] + python-socks become default dependencies - *.localhost and 0.0.0.0 count as loopback - proxy credentials are redacted in session list / logs - connect failures through a proxy name it and the NO_PROXY bypass - ADR-0016 records the decision (supersedes #20's "never proxy") Closes #136 Co-Authored-By: Claude --- ...0016-attach-proxy-comes-from-the-caller.md | 66 +++++ pyproject.toml | 8 +- src/browserwright/daemon/_net.py | 128 ++++++++- src/browserwright/daemon/backends/cdp.py | 37 ++- src/browserwright/daemon/config.py | 4 + src/browserwright/daemon/server/facade.py | 42 +-- src/browserwright/daemon/server/upstream.py | 110 +++----- .../daemon/server/upstream_context.py | 26 +- src/browserwright/session_create.py | 11 + src/browserwright/session_registry.py | 15 +- src/browserwright/skill_runtime.md | 2 +- .../daemon/e2e/test_issue136_attach_proxy.py | 262 ++++++++++++++++++ tests/daemon/test_cdp_backend_resolve.py | 22 +- tests/daemon/test_coverage_listener_dense.py | 15 - tests/daemon/test_facade_proxy_bypass.py | 30 +- tests/daemon/test_net_helpers.py | 2 +- tests/skill/test_session_attach.py | 9 + uv.lock | 31 ++- 18 files changed, 655 insertions(+), 165 deletions(-) create mode 100644 docs/adr/0016-attach-proxy-comes-from-the-caller.md create mode 100644 tests/daemon/e2e/test_issue136_attach_proxy.py diff --git a/docs/adr/0016-attach-proxy-comes-from-the-caller.md b/docs/adr/0016-attach-proxy-comes-from-the-caller.md new file mode 100644 index 0000000..ac8299d --- /dev/null +++ b/docs/adr/0016-attach-proxy-comes-from-the-caller.md @@ -0,0 +1,66 @@ +# A remote `--attach` takes its proxy from the caller's environment, once, at session open + +Status: accepted and implemented (2026-10-08) by #136. Supersedes the +"never proxy the CDP control channel" half of #20. + +## Context + +Reaching a remote CDP browser (`--attach=`) takes three connections: +`/json/version` discovery over HTTP, the session's own CDP websocket, and one +websocket per Playwright client through the facade bridge. Before #136 they +disagreed about proxies, and all of them asked the wrong process: + +- discovery used httpx with `trust_env=True` for any non-loopback endpoint; +- both websockets were hard-coded `proxy=None` (#20); +- every decision read the **daemon's** environment. + +The daemon is long-lived and is started either by a LaunchAgent (no proxy +vars) or from a shell (whatever that shell exported). So one +`session new --attach=…` command worked against one daemon and failed against +another. Under a SOCKS `all_proxy` without `socksio`, httpx raised while +*constructing* the client, before it looked at `NO_PROXY` at all. + +Sometimes the proxy is needed: a cloud browser behind a corporate egress, for +example. "Never proxy", which is what #20 chose, is too strong. + +## Decision + +1. **The CLI that opens the session decides.** `session new --attach=` and + the throwaway sessions behind `markdown` / `search --attach` resolve the + proxy for the attach URL from their **own** environment: + `http_proxy` / `https_proxy` / `all_proxy` / `no_proxy`, in either case. They + read only those variables, never the OS proxy settings that + `urllib.request.getproxies()` falls back to on macOS. `NO_PROXY` accepts + `*`, domains (which match subdomains), IPs, and CIDR ranges. Loopback + targets, including `*.localhost` and `0.0.0.0`, are always direct. +2. **The result is pinned on the session.** It is stored as + `workspace["proxy"]` in the ledger and is fixed for the session's lifetime. + Later calls (`-e`, tasks) do not carry or change it. Every reconnect the + daemon performs on its own (ADR-0013) reuses it. To change the proxy, end + the session and open a new one. +3. **The daemon never reads proxy env vars of its own.** Discovery uses httpx + with `trust_env=False` and the pinned proxy. Both websockets get the pinned + proxy explicitly, or `None`. Proxy variables in the daemon process have no + effect on any of these connections. +4. **The opt-out is `NO_PROXY` at the call site.** For example, + `NO_PROXY=100.64.0.0/10 browserwright session new --attach=…`. There is no + `--proxy` flag. +5. **SOCKS works out of the box.** `httpx[socks]` and `python-socks` are + default dependencies, because macOS proxy apps export + `all_proxy=socks5://…` by default. +6. **A dead proxy is reported as a dead proxy.** A connect failure through a + proxy names the proxy (redacted) and the `NO_PROXY=` bypass, so it + cannot be mistaken for a dead remote browser. + +## Consequences + +- #20's scenario (LAN, Tailscale, CloakBrowser) still works when the user's + proxy app routes those addresses DIRECT, which Surge and Clash do in rule + mode. Otherwise the user lists them in `NO_PROXY`; the daemon no longer + guesses. +- Behaviour no longer depends on which daemon is running. +- A proxy URL can carry `user:pass@`. It is redacted wherever a record is + printed (`session list --json`, daemon logs, errors). +- Verified end to end by `tests/daemon/e2e/test_issue136_attach_proxy.py`, + which uses recording HTTP and SOCKS5 proxies and a daemon whose own env + points at a dead proxy. diff --git a/pyproject.toml b/pyproject.toml index d6620ce..1305ceb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,13 @@ license = "AGPL-3.0-only" dependencies = [ "websockets==15.0.1", "pillow==12.2.0", - "httpx>=0.27", + # SOCKS support is default, not an extra (#136): an `--attach` session's + # proxy comes from the caller's environment, and macOS proxy apps (Surge, + # Clash) routinely export `all_proxy=socks5://...`. `socks` gives httpx + # socksio for CDP discovery; python-socks is what websockets 15 needs for + # the CDP websocket itself. + "httpx[socks]>=0.27", + "python-socks>=2.7", # Phase C: the skill heredoc injects a real Playwright `page`/`context` # bound to the daemon facade, so playwright is a runtime dependency now (it # was previously dev-only). `playwright install chromium` is still needed diff --git a/src/browserwright/daemon/_net.py b/src/browserwright/daemon/_net.py index 6e34491..ff69b3e 100644 --- a/src/browserwright/daemon/_net.py +++ b/src/browserwright/daemon/_net.py @@ -11,6 +11,9 @@ question decides both whether the user's `ALL_PROXY` should apply and whether Chrome's `DevToolsActivePort` file is worth reading. Neither is correct for a remote endpoint. +- **`proxy_for`** — "which proxy reaches this attach target?" Answered once, + by the CLI from *its* environment when a session is opened, and stored on + the session (#136). The daemon never reads proxy env vars of its own. - **`redact_url`** — a CDP endpoint routinely carries a reusable token, in the userinfo or the query string. Anything that prints an endpoint (daemon logs, `daemon ps --json`, `session list --json`, error messages) must go through @@ -19,6 +22,7 @@ from __future__ import annotations import ipaddress +from collections.abc import Mapping from urllib.parse import urlsplit, urlunsplit #: Hostnames that mean "this machine" without being parseable as an address. @@ -53,12 +57,132 @@ def is_loopback_host(host_or_url: str) -> bool: host = host.strip().strip("[]").lower() if not host: return False - if host in _LOOPBACK_NAMES: + # RFC 6761: every `*.localhost` name resolves to loopback. + if host in _LOOPBACK_NAMES or host.rstrip(".").endswith(".localhost"): return True try: - return ipaddress.ip_address(host).is_loopback + addr = ipaddress.ip_address(host) except ValueError: return False + # `0.0.0.0` / `::` as a *destination* is this machine on every OS we run on. + return addr.is_loopback or addr.is_unspecified + + +#: Which env var serves which target scheme. `ws` rides the same proxy as +#: `http` and `wss` the same as `https`, matching curl and `websockets`. +_PROXY_VAR_FOR_SCHEME = {"http": "http", "ws": "http", + "https": "https", "wss": "https"} + + +def _env_value(env: Mapping[str, str], name: str) -> str: + """`name_proxy` from `env`, lowercase winning over uppercase as in curl.""" + for key in (name.lower(), name.upper()): + value = env.get(key) + if value and value.strip(): + return value.strip() + return "" + + +def _no_proxy_matches(host: str, port: int | None, no_proxy: str) -> bool: + """Does a `NO_PROXY` list exempt `host`? + + Entries are comma- or space-separated: `*` (everything), an IP or CIDR + (`100.64.0.0/10`), or a domain, which also covers its subdomains whether + written `example.com` or `.example.com`. An entry may carry a `:port`, + which then has to match too. `urllib`'s own bypass check does not + understand CIDR, which is the form a tailnet or LAN is usually written in. + """ + try: + addr = ipaddress.ip_address(host) + except ValueError: + addr = None + for raw in no_proxy.replace(" ", ",").split(","): + entry = raw.strip().lower() + if not entry: + continue + if entry == "*": + return True + entry_port: int | None = None + if entry.startswith("[") and "]" in entry: + body, _, rest = entry[1:].partition("]") + if rest.startswith(":") and rest[1:].isdigit(): + entry_port = int(rest[1:]) + entry = body + elif entry.count(":") == 1: + body, _, maybe_port = entry.partition(":") + if maybe_port.isdigit(): + entry, entry_port = body, int(maybe_port) + if entry_port is not None and entry_port != port: + continue + if addr is not None: + try: + if addr in ipaddress.ip_network(entry, strict=False): + return True + except ValueError: + pass + continue + domain = entry.lstrip(".").rstrip(".") + if domain and (host == domain or host.endswith("." + domain)): + return True + return False + + +def proxy_for(url: str, env: Mapping[str, str]) -> str | None: + """The proxy `env` says reaches `url`, or None for a direct connection. + + Reads exactly the `http_proxy` / `https_proxy` / `all_proxy` / `no_proxy` + variables (either case) in `env` — never the OS proxy settings, which + `urllib.request.getproxies()` falls back to on macOS. Loopback targets are + always direct: proxying to your own machine is never what anyone wants. + + The caller passes `env` explicitly because *whose* environment it is is + the whole decision (#136): the CLI that opens a session passes its own, + and the daemon never calls this with its own. + """ + if is_loopback_host(url): + return None + try: + parts = urlsplit(url) + host = (parts.hostname or "").lower() + port = parts.port + except ValueError: + return None + var = _PROXY_VAR_FOR_SCHEME.get(parts.scheme.lower()) + if not host or var is None: + return None + if _no_proxy_matches(host, port, _env_value(env, "no_proxy")): + return None + proxy = _env_value(env, f"{var}_proxy") or _env_value(env, "all_proxy") + if not proxy: + return None + # `host:port` without a scheme is a common way to write an HTTP proxy. + return proxy if "://" in proxy else f"http://{proxy}" + + +def proxy_toward(url: str, proxy: str | None) -> str | None: + """`proxy`, unless `url` is on this machine. + + A session's proxy was resolved for its attach URL, but discovery can hand + back a websocket URL on a different host. A loopback one is still direct. + """ + return None if proxy is None or is_loopback_host(url) else proxy + + +def proxy_hint(proxy: str | None, url: str) -> str: + """The suffix that turns a connect failure through `proxy` into an + actionable one, or "" when the connection was direct. + + Without it a dead proxy reads as a dead remote browser. + """ + if proxy is None: + return "" + try: + host = urlsplit(url).hostname or url + except ValueError: + host = url + return (f" (via proxy {redact_url(proxy)}, taken from the environment " + f"that opened this session; if that proxy is not reachable, end " + f"the session and open it again with NO_PROXY={host} set)") def redact_url(url: object) -> object: diff --git a/src/browserwright/daemon/backends/cdp.py b/src/browserwright/daemon/backends/cdp.py index 21afdea..6244189 100644 --- a/src/browserwright/daemon/backends/cdp.py +++ b/src/browserwright/daemon/backends/cdp.py @@ -36,7 +36,7 @@ import httpx -from .._net import is_loopback_host, redact_url +from .._net import is_loopback_host, proxy_hint, redact_url from ..config import Config from ..errors import Unavailable from ..platforms import profile_paths @@ -62,12 +62,10 @@ class RealCdpBackend: kind = "UPSTREAM_WS" recommended_mode: str = "A" ux_cost = "none" - # cdp overrides to False: the user's HTTP(S)_PROXY / ALL_PROXY must not be - # applied to localhost probes — proxying to your own loopback is never what - # anyone wants and triggers httpx[socks] import errors when - # ALL_PROXY=socks5://... env keeps the default: its target may be a remote - # host where the proxy is intentional. - _trust_env: bool = True + # The proxy discovery goes through, or None for direct. Never read from + # the daemon's environment (#136): `CdpBackend` takes it from the + # session's Config, where the CLI that opened the session pinned it. + _proxy: str | None = None def __init__(self, cfg: Config): self._cfg = cfg @@ -97,7 +95,12 @@ async def _resolve_source(self, timeout: float) -> tuple[str, dict]: # ---- shared plumbing ---------------------------------------------------- async def _get(self, url: str, timeout: float) -> httpx.Response: - async with httpx.AsyncClient(timeout=timeout, trust_env=self._trust_env) as client: + # `trust_env=False`: httpx would otherwise build a transport for every + # proxy env var at construction — before `NO_PROXY` is consulted — so + # a stray daemon-side `all_proxy` could crash a request it never + # applied to (#136). + async with httpx.AsyncClient(timeout=timeout, trust_env=False, + proxy=self._proxy) as client: return await client.get(url) async def _discover_ws_url(self, base_url: str, timeout: float) -> str: @@ -118,7 +121,8 @@ async def _discover_ws_url(self, base_url: str, timeout: float) -> str: resp = await self._get(url, timeout) except (httpx.HTTPError, OSError) as e: raise Unavailable( - f"{self.name}: cannot reach {shown}: {e}", + f"{self.name}: cannot reach {shown}: {e}" + f"{proxy_hint(self._proxy, base_url)}", attempts={self.name: f"GET {shown} -> {type(e).__name__}: {e}"}, ) from e if resp.status_code == 404: @@ -166,7 +170,7 @@ class CdpBackend(RealCdpBackend): | | old | new | |---|---|---| - | apply the user's `ALL_PROXY` | `cdp` no, `env` yes | `not _loopback` | + | apply a proxy | `cdp` no, `env` yes | `not _loopback` | | DevToolsActivePort 404 fallback | `cdp` yes, `env` never | `_loopback` | That is strictly more correct than the names were: `--attach=http://127.0.0.1:9222` @@ -179,6 +183,7 @@ def __init__(self, cfg: Config): super().__init__(cfg) self.port = cfg.backends.cdp.port self.endpoint = cfg.backends.cdp.endpoint + self.proxy = cfg.backends.cdp.proxy # ---- the one predicate the two old backends disagreed about ------------- @@ -190,15 +195,9 @@ def _loopback(self) -> bool: return is_loopback_host(self.endpoint) @property - def _trust_env(self) -> bool: # overrides RealCdpBackend's class attribute - """Apply the user's HTTP(S)_PROXY / ALL_PROXY? - - Never for a local browser: proxying to your own loopback is never what - anyone wants, and it trips httpx[socks] import errors under - `ALL_PROXY=socks5://...`. Always for a remote endpoint, where reaching - it through the proxy is usually the whole point. - """ - return not self._loopback + def _proxy(self) -> str | None: # overrides RealCdpBackend's class attribute + """The proxy the session pinned (#136); never one for a local browser.""" + return None if self._loopback else self.proxy # ---- URL sources -------------------------------------------------------- diff --git a/src/browserwright/daemon/config.py b/src/browserwright/daemon/config.py index 0e4cce5..3bf5ceb 100644 --- a/src/browserwright/daemon/config.py +++ b/src/browserwright/daemon/config.py @@ -197,6 +197,10 @@ class CdpConfig: """ port: int = 9222 endpoint: str | None = None + #: The proxy that reaches `endpoint`, or None for direct. Per-session only, + #: like `endpoint`: resolved by the CLI from the environment of whoever + #: opened the session (#136). The daemon never reads its own proxy env. + proxy: str | None = None @dataclass diff --git a/src/browserwright/daemon/server/facade.py b/src/browserwright/daemon/server/facade.py index 8fbce1c..c6031bc 100644 --- a/src/browserwright/daemon/server/facade.py +++ b/src/browserwright/daemon/server/facade.py @@ -68,6 +68,7 @@ from ..config import (DEFAULT_FACADE_PORT, LOOPBACK_HOST, Config, needs_loopback_cobind) from .. import __version__ +from .._net import proxy_toward from ..errors import Unavailable from ..resolver import resolve as resolve_upstream from ..._executor.protocol import _MAX_FRAME @@ -75,7 +76,6 @@ from .exec_relay import serve_exec_relay from .facade_extension import ExtensionFacadeBridge from .relay import RelayServer -from .upstream import _localhost_bypass_proxy logger = logging.getLogger(__name__) @@ -636,7 +636,7 @@ async def _handle_cdp_client( return try: - await self._bridge(conn, ws_url) + await self._bridge(conn, ws_url, proxy=self._cdp_proxy(ctx)) except websockets.exceptions.ConnectionClosed: pass except Exception as e: # noqa: BLE001 @@ -656,29 +656,33 @@ async def _resolve_cdp_ws(self, ctx: UpstreamContext | None = None) -> str: rr = await resolve_upstream(cfg) return rr.ws_url - async def _bridge(self, client: ServerConnection, upstream_url: str) -> None: + def _cdp_proxy(self, ctx: UpstreamContext | None = None) -> str | None: + """The proxy the session pinned for its endpoint — the same value its + own upstream connection uses, so the two always agree on the route + (#136). The daemon-wide Config never carries one.""" + return getattr(ctx.upstream, "session_proxy", None) if ctx is not None else None + + async def _bridge(self, client: ServerConnection, upstream_url: str, + proxy: str | None = None) -> None: """Open a raw ws to the upstream Chrome and shuttle frames both ways. Transparent: no id/sessionId rewriting (unlike the agent Router) — a Playwright client owns the whole browser-level CDP namespace on its own dedicated upstream connection, so Target.*/Browser.* responses and events flow back unmodified.""" - with _localhost_bypass_proxy(upstream_url): - upstream = await websockets.connect( - upstream_url, - max_size=100 * 1024 * 1024, - compression=None, - ping_interval=20, - ping_timeout=20, - # The daemon→browser CDP control channel must never traverse the - # user's ambient web proxy (http_proxy/all_proxy). `websockets` - # 15.x honors those env vars by default, which breaks any - # non-loopback upstream (LAN / Tailscale / CloakBrowser) — the - # loopback-only NO_PROXY augmentation above can't cover it. - # proxy=None disables proxying entirely; per-page proxying is - # applied downstream by Chrome/CloakBrowser itself. (issue #20) - proxy=None, - ) + upstream = await websockets.connect( + upstream_url, + max_size=100 * 1024 * 1024, + compression=None, + ping_interval=20, + ping_timeout=20, + # Exactly the session's proxy, or None for direct — never + # websockets' default of reading the daemon's http_proxy/all_proxy, + # which broke LAN / Tailscale / CloakBrowser upstreams (#20). The + # CLI that opened the session resolved it from its own environment + # (#136); per-page proxying is still Chrome's own business. + proxy=proxy_toward(upstream_url, proxy), + ) c2u = asyncio.create_task(self._pump(client, upstream, "c->u")) u2c = asyncio.create_task(self._pump(upstream, client, "u->c")) try: diff --git a/src/browserwright/daemon/server/upstream.py b/src/browserwright/daemon/server/upstream.py index 82b3a58..d27d9ec 100644 --- a/src/browserwright/daemon/server/upstream.py +++ b/src/browserwright/daemon/server/upstream.py @@ -16,7 +16,6 @@ from __future__ import annotations import asyncio -import contextlib import json import logging import os @@ -26,7 +25,7 @@ import websockets from websockets.exceptions import ConnectionClosed -from .._net import is_loopback_host +from .._net import proxy_hint, proxy_toward, redact_url logger = logging.getLogger(__name__) @@ -250,6 +249,17 @@ def cfg(self) -> Any | None: channel.""" return self._cfg + @property + def session_proxy(self) -> str | None: + """The proxy this session's endpoint is reached through, or None. + + Pinned from the ledger by the context factory and read from the same + Config as the endpoint, so discovery, this adapter's websocket, and + the facade bridge can never disagree about it (#136).""" + cdp = getattr(getattr(self._cfg, "backends", None), "cdp", None) + proxy = getattr(cdp, "proxy", None) + return proxy if isinstance(proxy, str) else None + @property def browser_pid(self) -> int | None: """Pid of the Chrome this adapter launched, or None (attach-owned, or @@ -334,37 +344,40 @@ async def open(self, ws_url: str | None = None, *, if timeout is None: timeout = 30.0 assert ws_url is not None - with _localhost_bypass_proxy(ws_url): - connect_kwargs: dict[str, Any] = { - # Big max_size: CDP `Page.captureScreenshot` returns base64 - # blobs that comfortably exceed the websockets default 1MiB. - "max_size": 100 * 1024 * 1024, - # Disable per-message-deflate — Chrome's browser-level CDP - # doesn't speak it, and websockets v15 sometimes negotiates - # extensions that break the handshake. - "compression": None, - # Never route the daemon→browser CDP control channel through the - # user's ambient web proxy. websockets v15 honors - # http_proxy/all_proxy by default, which breaks any non-loopback - # upstream (LAN / Tailscale / an env-backed CloakBrowser profile) - # that the loopback-only NO_PROXY bypass above can't cover. Same - # fix as the Playwright facade bridge. (issue #20) - "proxy": None, - # Keep the upstream alive with ws-level pings; CDP-level - # Browser.getVersion heartbeat is layered on top for protocol - # liveness. - "ping_interval": 20, - "ping_timeout": 20, - } - try: - self._ws = await asyncio.wait_for( - websockets.connect(ws_url, **connect_kwargs), - timeout=timeout, - ) - except Exception as e: - if lifecycle: - logger.warning("upstream open failed: %r", e) - raise + proxy = proxy_toward(ws_url, self.session_proxy) + connect_kwargs: dict[str, Any] = { + # Big max_size: CDP `Page.captureScreenshot` returns base64 + # blobs that comfortably exceed the websockets default 1MiB. + "max_size": 100 * 1024 * 1024, + # Disable per-message-deflate — Chrome's browser-level CDP + # doesn't speak it, and websockets v15 sometimes negotiates + # extensions that break the handshake. + "compression": None, + # Exactly the session's proxy, or None for direct — never + # websockets' default of reading the daemon's http_proxy/all_proxy. + # The CLI that opened the session resolved it from its own + # environment; the facade bridge uses the same value. (#20, #136) + "proxy": proxy, + # Keep the upstream alive with ws-level pings; CDP-level + # Browser.getVersion heartbeat is layered on top for protocol + # liveness. + "ping_interval": 20, + "ping_timeout": 20, + } + try: + self._ws = await asyncio.wait_for( + websockets.connect(ws_url, **connect_kwargs), + timeout=timeout, + ) + except Exception as e: + if lifecycle: + logger.warning("upstream open failed: %r%s", e, + proxy_hint(proxy, ws_url)) + if proxy is not None: + raise ConnectionError( + f"cannot open {redact_url(ws_url)}: {e!r}" + f"{proxy_hint(proxy, ws_url)}") from e + raise self._ws_url = ws_url self._reader_task = asyncio.create_task(self._reader_loop()) self._heartbeat_task = asyncio.create_task(self._heartbeat_loop()) @@ -1066,37 +1079,6 @@ async def _heartbeat_loop(self) -> None: return -# ---- localhost proxy bypass (same trick as active_tab) -------------------- - - -@contextlib.contextmanager -def _localhost_bypass_proxy(ws_url: str): - """When the upstream URL is loopback, ensure NO_PROXY covers it. - - Spec doesn't mention this — but a browser we launched runs on the user's - machine, and the user often has HTTPS_PROXY / ALL_PROXY set. An external - endpoint is the opposite case: there the proxy is usually intentional, so - we leave it alone. `is_loopback_host` is what decides which one this is, - and it is the same predicate the cdp backend uses to pick `trust_env`. - """ - if not is_loopback_host(ws_url): - yield - return - prev = os.environ.get("NO_PROXY", "") - augmented = prev - for h in ("127.0.0.1", "localhost", "::1"): - if h not in augmented: - augmented = f"{augmented},{h}" if augmented else h - os.environ["NO_PROXY"] = augmented - try: - yield - finally: - if prev: - os.environ["NO_PROXY"] = prev - else: - os.environ.pop("NO_PROXY", None) - - # Compatibility name for callers/tests that still import the old transport- # shaped class. The concrete implementation is now the raw-CDP Upstream # adapter, covering both cdp and env. diff --git a/src/browserwright/daemon/server/upstream_context.py b/src/browserwright/daemon/server/upstream_context.py index a17c56b..7fe0f18 100644 --- a/src/browserwright/daemon/server/upstream_context.py +++ b/src/browserwright/daemon/server/upstream_context.py @@ -361,6 +361,23 @@ def endpoint_from_workspace(workspace: object) -> tuple[int | None, str | None]: return None, None +def proxy_from_workspace(workspace: object) -> str | None: + """The proxy a session's `workspace` pinned for its endpoint, or None. + + Only a `{"url": ...}` workspace can carry one: the CLI resolved it from + its own environment when the session was opened (#136). A port endpoint + is on this machine and always direct, so a `proxy` beside a `port` is + ignored, as is anything that is not a URL. + """ + if not isinstance(workspace, dict): + return None + _, url = endpoint_from_workspace(workspace) + proxy = workspace.get("proxy") + if url is None or not isinstance(proxy, str) or "://" not in proxy: + return None + return proxy + + def cdp_cfg_for(record: dict, base: Config) -> Config: """Derive a per-session cdp Config from the ledger record. @@ -375,7 +392,8 @@ def cdp_cfg_for(record: dict, base: Config) -> Config: # `replace` shares the nested BackendsConfig instance; copy the cdp # sub-config so per-session pinning never mutates the shared cfg (or # another session's context). - fields: dict = {"endpoint": endpoint} + fields: dict = {"endpoint": endpoint, + "proxy": proxy_from_workspace(record.get("workspace"))} if port is not None: fields["port"] = port cfg.backends = dataclasses.replace( @@ -406,9 +424,11 @@ def context_for_record(session_id: str, record: dict, cfg = cdp_cfg_for(record, base) # Redacted: a per-session endpoint can carry a bearer token, and daemon # logs get pasted into bug reports. - logger.info("cdp context for session %s: port=%s endpoint=%s owner=%s", + logger.info("cdp context for session %s: port=%s endpoint=%s " + "proxy=%s owner=%s", session_id, cfg.backends.cdp.port, - redact_url(cfg.backends.cdp.endpoint), record.get("owner")) + redact_url(cfg.backends.cdp.endpoint), + redact_url(cfg.backends.cdp.proxy), record.get("owner")) return build_context( backend="cdp", cfg=cfg, session_id=session_id, owns_browser=record.get("owner") == "create") diff --git a/src/browserwright/session_create.py b/src/browserwright/session_create.py index 208ae48..ced7b97 100644 --- a/src/browserwright/session_create.py +++ b/src/browserwright/session_create.py @@ -24,6 +24,7 @@ from __future__ import annotations import json +import os import socket import time from dataclasses import dataclass @@ -345,6 +346,16 @@ def new(*, backend: str, create: bool = False, attach: Optional[object] = None, else: port, endpoint = _checked_attach(attach) workspace = {"port": port} if port is not None else {"url": endpoint} + if endpoint is not None: + # #136: the proxy is decided here, once, from the environment + # of whoever opens the session, and travels with the session. + # The daemon never reads proxy env vars of its own, so the + # same command behaves the same against any daemon. + from .daemon._net import proxy_for + + proxy = proxy_for(endpoint, os.environ) + if proxy is not None: + workspace["proxy"] = proxy sid = reg.allocate(backend="cdp", owner=owner, name=name, workspace=workspace) return NewSession(sid, False, lifecycle.ensure("session new")) diff --git a/src/browserwright/session_registry.py b/src/browserwright/session_registry.py index c75d015..c5be7cb 100644 --- a/src/browserwright/session_registry.py +++ b/src/browserwright/session_registry.py @@ -163,9 +163,10 @@ def remove(session_id: str) -> Optional[dict]: def redacted(record: Optional[dict]) -> Optional[dict]: """A record safe to print, with any endpoint credential stripped. - `workspace["url"]` is the one field here that can carry a secret: a cloud - or anti-detect browser's CDP URL routinely embeds a reusable token in its - userinfo or query string. Before #38 that value lived in a daemon's + `workspace["url"]` and `workspace["proxy"]` are the fields here that can + carry a secret: a cloud or anti-detect browser's CDP URL routinely embeds a + reusable token in its userinfo or query string, and a proxy URL may carry + `user:pass@`. Before #38 that value lived in a daemon's environment and never in this file, so dumping a record wholesale was safe; it isn't any more. @@ -176,12 +177,14 @@ def redacted(record: Optional[dict]) -> Optional[dict]: if not isinstance(record, dict): return record workspace = record.get("workspace") - if not isinstance(workspace, dict) or "url" not in workspace: + secret_keys = [k for k in ("url", "proxy") + if isinstance(workspace, dict) and k in workspace] + if not secret_keys: return record from .daemon._net import redact_url - return {**record, - "workspace": {**workspace, "url": redact_url(workspace["url"])}} + return {**record, "workspace": { + **workspace, **{k: redact_url(workspace[k]) for k in secret_keys}}} def _numeric_sort_key(key: object) -> int: diff --git a/src/browserwright/skill_runtime.md b/src/browserwright/skill_runtime.md index 883e02e..408544a 100644 --- a/src/browserwright/skill_runtime.md +++ b/src/browserwright/skill_runtime.md @@ -34,7 +34,7 @@ print(page.title()) browserwright session end --session=$sid ``` -Use `--backend=extension` for the user's daily Chrome. Use `--backend=cdp --create` for an isolated Chrome that the daemon owns. Use `--backend=cdp --attach=` to bind to a browser someone else owns — a local port, or a `ws://`/`wss://`/`http://` endpoint for an anti-detect, fingerprint or cloud profile; ending the session never closes it. Each attached session carries its own endpoint, so one daemon can drive many external browsers at once. +Use `--backend=extension` for the user's daily Chrome. Use `--backend=cdp --create` for an isolated Chrome that the daemon owns. Use `--backend=cdp --attach=` to bind to a browser someone else owns — a local port, or a `ws://`/`wss://`/`http://` endpoint for an anti-detect, fingerprint or cloud profile; ending the session never closes it. Each attached session carries its own endpoint, so one daemon can drive many external browsers at once. A remote endpoint is reached through the proxy in *your* environment when you run `session new` (`http_proxy`/`https_proxy`/`all_proxy`); it is fixed for the session's life. To go direct, open the session with `NO_PROXY=` set (CIDR such as `100.64.0.0/10` works). ## When A Call Fails diff --git a/tests/daemon/e2e/test_issue136_attach_proxy.py b/tests/daemon/e2e/test_issue136_attach_proxy.py new file mode 100644 index 0000000..da7f8a2 --- /dev/null +++ b/tests/daemon/e2e/test_issue136_attach_proxy.py @@ -0,0 +1,262 @@ +"""#136: a remote `--attach` takes its proxy from the environment of the CLI +that opens the session — never from the daemon's — for discovery, the +session's CDP websocket, and the Playwright facade bridge alike. + +The rig, all on this machine: + +- a real Chrome on the test CDP port, reached through a TCP forwarder bound to + a **non-loopback** address of this host, so it counts as remote (loopback + targets are always direct); +- a recording HTTP proxy (absolute-form GET + CONNECT) and a recording SOCKS5 + proxy, each logging every target it tunnels to; +- the test daemon started with `all_proxy` / `http_proxy` pointing at a dead + port. Before #136 that alone crashed every attach (`socksio` missing) or + sent it nowhere. + +Each scenario runs the real CLI: `session new --attach=`, one `-e` that +drives the page through Playwright, `session end`. The artifact is +`issue136-proxy-routes.json` in the e2e artifacts dir: which proxy saw which +connections, per scenario. +""" +from __future__ import annotations + +import asyncio +import json +import os +import socket +import struct +import subprocess +import sys +import threading +from pathlib import Path + +import pytest + +from .conftest import TEST_CDP_PORT, published_endpoint, scrubbed_env + +_BS_HOME = Path(__file__).resolve().parent / "_bs_home" / "cdp" +_PROXY_VARS = ("http_proxy", "https_proxy", "all_proxy", "no_proxy", + "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY") +_DEAD = "127.0.0.1:9" # discard port: nothing listens + + +def _non_loopback_ip() -> str | None: + """An address of this host that is not loopback, or None.""" + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(("192.0.2.1", 9)) # TEST-NET-1; no packet is sent + ip = s.getsockname()[0] + except OSError: + return None + finally: + s.close() + return None if ip.startswith("127.") or ip == "0.0.0.0" else ip + + +class _Rig: + """Forwarder + two recording proxies on one background event loop.""" + + def __init__(self, lan_ip: str): + self.lan_ip = lan_ip + self.loop = asyncio.new_event_loop() + self.seen: dict[str, list[str]] = {"http": [], "socks": []} + self._servers: list[asyncio.base_events.Server] = [] + self._thread = threading.Thread(target=self.loop.run_forever, daemon=True) + self._thread.start() + self.fwd_port = self._start(self._forward, lan_ip) + self.http_port = self._start(self._http_proxy, "127.0.0.1") + self.socks_port = self._start(self._socks_proxy, "127.0.0.1") + + def _start(self, handler, host: str) -> int: + async def _serve(): + srv = await asyncio.start_server(handler, host, 0) + self._servers.append(srv) + return srv.sockets[0].getsockname()[1] + return asyncio.run_coroutine_threadsafe(_serve(), self.loop).result(5) + + def close(self) -> None: + for srv in self._servers: + self.loop.call_soon_threadsafe(srv.close) + self.loop.call_soon_threadsafe(self.loop.stop) + self._thread.join(5) + + @staticmethod + async def _pipe(reader, writer): + try: + while data := await reader.read(65536): + writer.write(data) + await writer.drain() + except (ConnectionError, OSError): + pass + finally: + writer.close() + + async def _splice(self, r1, w1, r2, w2): + await asyncio.gather(self._pipe(r1, w2), self._pipe(r2, w1)) + + async def _forward(self, reader, writer): + """LAN address -> the Chrome on loopback.""" + ur, uw = await asyncio.open_connection("127.0.0.1", TEST_CDP_PORT) + await self._splice(reader, writer, ur, uw) + + async def _http_proxy(self, reader, writer): + head = await reader.readuntil(b"\r\n\r\n") + method, target, _ = head.split(b"\r\n", 1)[0].decode().split(" ", 2) + if method == "CONNECT": + host, port = target.rsplit(":", 1) + self.seen["http"].append(f"CONNECT {target}") + ur, uw = await asyncio.open_connection(host, int(port)) + writer.write(b"HTTP/1.1 200 Connection established\r\n\r\n") + await writer.drain() + else: # absolute-form: GET http://host:port/path HTTP/1.1 + hostport = target.split("://", 1)[1].split("/", 1)[0] + host, port = hostport.rsplit(":", 1) + self.seen["http"].append(f"{method} {target}") + ur, uw = await asyncio.open_connection(host, int(port)) + path = "/" + target.split("://", 1)[1].split("/", 1)[1] + rest = head.split(b"\r\n", 1)[1] + uw.write(f"{method} {path} HTTP/1.1\r\n".encode() + rest) + await self._splice(reader, writer, ur, uw) + + async def _socks_proxy(self, reader, writer): + ver, nmethods = await reader.readexactly(2) + await reader.readexactly(nmethods) + writer.write(b"\x05\x00") # no auth + _, cmd, _, atyp = await reader.readexactly(4) + if atyp == 1: + host = socket.inet_ntoa(await reader.readexactly(4)) + elif atyp == 3: + host = (await reader.readexactly((await reader.readexactly(1))[0])).decode() + else: + host = socket.inet_ntop(socket.AF_INET6, await reader.readexactly(16)) + port = struct.unpack("!H", await reader.readexactly(2))[0] + self.seen["socks"].append(f"{host}:{port}") + ur, uw = await asyncio.open_connection(host, port) + writer.write(b"\x05\x00\x00\x01" + socket.inet_aton("0.0.0.0") + b"\x00\x00") + await self._splice(reader, writer, ur, uw) + + def take(self) -> dict[str, list[str]]: + out = {k: list(v) for k, v in self.seen.items()} + for v in self.seen.values(): + v.clear() + return out + + +@pytest.fixture +def daemon_env_has_dead_proxy(monkeypatch): + """Inherited by the daemon `e2e_cdp_daemon` spawns (it copies os.environ). + Must be requested before that fixture.""" + for var in _PROXY_VARS: + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("all_proxy", f"socks5://{_DEAD}") + monkeypatch.setenv("http_proxy", f"http://{_DEAD}") + monkeypatch.setenv("https_proxy", f"http://{_DEAD}") + + +@pytest.fixture +def rig(): + ip = _non_loopback_ip() + if ip is None: + pytest.skip("this host has no non-loopback IPv4 address to act as remote") + r = _Rig(ip) + try: + yield r + finally: + r.close() + + +def _cli(args: list[str], runtime_dir: str, proxy_env: dict[str, str], + stdin: str | None = None, timeout: float = 120.0): + env = {k: v for k, v in scrubbed_env().items() if k not in _PROXY_VARS} + env.update(proxy_env) + env["PATH"] = str(Path(sys.executable).parent) + os.pathsep + env.get("PATH", "") + env["XDG_RUNTIME_DIR"] = runtime_dir + env["TMPDIR"] = runtime_dir + env["BW_DAEMON_URL"] = published_endpoint(runtime_dir) or "" + env["BS_HOME"] = str(_BS_HOME) + assert env["BW_DAEMON_URL"], "test cdp daemon did not publish its endpoint" + binary = Path(sys.executable).with_name("browserwright") + return subprocess.run([str(binary), *args], input=stdin, capture_output=True, + text=True, env=env, timeout=timeout) + + +_SCRIPT = 'page.goto("data:text/html,via-136")\nprint(page.title())\n' + + +def _drive(rig: _Rig, runtime_dir: str, name: str, proxy_env: dict[str, str]): + """session new --attach= → one -e through Playwright → session end. + + Returns (new, exec, list_json) CompletedProcesses.""" + attach = f"http://{rig.lan_ip}:{rig.fwd_port}" + new = _cli(["session", "new", "--backend=cdp", f"--attach={attach}", + f"--name={name}"], runtime_dir, proxy_env) + assert new.returncode == 0, new.stderr + sid = new.stdout.strip().splitlines()[-1] + try: + listed = _cli(["session", "list", "--json"], runtime_dir, proxy_env) + # A different env on the call must not matter: the proxy was pinned + # when the session was opened. + ran = _cli(["-s", sid, "--code-stdin"], runtime_dir, {}, stdin=_SCRIPT) + finally: + _cli(["session", "end", sid], runtime_dir, proxy_env) + return new, ran, listed + + +def _remote(rig: _Rig) -> str: + return f"{rig.lan_ip}:{rig.fwd_port}" + + +def test_attach_proxy_follows_the_callers_env( + daemon_env_has_dead_proxy, e2e_cdp_daemon, rig, e2e_artifacts_dir): + report: dict[str, object] = {"remote": _remote(rig)} + + # 1. HTTP proxy with credentials: discovery is an absolute-form GET, both + # websockets (session upstream + facade bridge) are CONNECT tunnels. + proxy = f"http://user:s3cret@127.0.0.1:{rig.http_port}" + new, ran, listed = _drive(rig, e2e_cdp_daemon, "p136-http", {"http_proxy": proxy}) + seen = rig.take() + report["http_proxy"] = seen + assert ran.returncode == 0, ran.stderr + assert "via-136" in ran.stdout + assert any(s.startswith("GET http://" + _remote(rig)) for s in seen["http"]), seen + assert seen["http"].count(f"CONNECT {_remote(rig)}") >= 2, seen # upstream + bridge + assert seen["socks"] == [] + assert "s3cret" not in listed.stdout, "proxy credentials leaked into session list" + + # 2. SOCKS5 via all_proxy — the #136 shape. Every connection goes through it. + new, ran, _ = _drive(rig, e2e_cdp_daemon, "p136-socks", + {"all_proxy": f"socks5://127.0.0.1:{rig.socks_port}"}) + seen = rig.take() + report["socks5_all_proxy"] = seen + assert ran.returncode == 0, ran.stderr + assert seen["socks"].count(_remote(rig)) >= 3, seen # discovery + upstream + bridge + assert seen["http"] == [] + + # 3. Same SOCKS env, but NO_PROXY exempts the host by CIDR: all direct. + cidr = ".".join(rig.lan_ip.split(".")[:3]) + ".0/24" + new, ran, _ = _drive(rig, e2e_cdp_daemon, "p136-noproxy", + {"all_proxy": f"socks5://127.0.0.1:{rig.socks_port}", + "NO_PROXY": cidr}) + seen = rig.take() + report["no_proxy_cidr"] = {"NO_PROXY": cidr, **seen} + assert ran.returncode == 0, ran.stderr + assert seen == {"http": [], "socks": []}, seen + + # 4. No proxy in the caller's env: direct, though the daemon's env has a + # dead SOCKS proxy (which used to crash discovery outright). + new, ran, _ = _drive(rig, e2e_cdp_daemon, "p136-direct", {}) + seen = rig.take() + report["caller_has_no_proxy"] = seen + assert ran.returncode == 0, ran.stderr + assert seen == {"http": [], "socks": []}, seen + + # 5. A dead proxy in the caller's env fails loudly and says how to bypass. + new, ran, _ = _drive(rig, e2e_cdp_daemon, "p136-dead", + {"http_proxy": f"http://{_DEAD}"}) + report["dead_proxy_error"] = ran.stderr[-1500:] + assert ran.returncode != 0 + assert "via proxy http://127.0.0.1:9" in ran.stderr, ran.stderr + assert f"NO_PROXY={rig.lan_ip}" in ran.stderr, ran.stderr + + (e2e_artifacts_dir / "issue136-proxy-routes.json").write_text( + json.dumps(report, indent=2) + "\n", encoding="utf-8") diff --git a/tests/daemon/test_cdp_backend_resolve.py b/tests/daemon/test_cdp_backend_resolve.py index 9145902..b26e6dd 100644 --- a/tests/daemon/test_cdp_backend_resolve.py +++ b/tests/daemon/test_cdp_backend_resolve.py @@ -2,7 +2,7 @@ The env half of this had **no unit test at all** before the merge: neither the verbatim ws pass-through nor the http `/json/version` discovery was covered -anywhere. That is also why the `trust_env` and 404-fallback differences between +anywhere. That is also why the proxy and 404-fallback differences between the two old backends could sit encoded in their *names* unchallenged. """ from __future__ import annotations @@ -15,9 +15,10 @@ from browserwright.daemon.errors import Unavailable -def _cfg(*, port: int = 9222, endpoint: str | None = None) -> Config: +def _cfg(*, port: int = 9222, endpoint: str | None = None, + proxy: str | None = None) -> Config: return Config(backend="cdp", backends=BackendsConfig( - cdp=CdpConfig(port=port, endpoint=endpoint))) + cdp=CdpConfig(port=port, endpoint=endpoint, proxy=proxy))) def _no_http(monkeypatch): @@ -172,10 +173,10 @@ async def fake_get(self, url, timeout): assert seen == [9444] -# ---- trust_env is derived from the endpoint, not from a name --------------- +# ---- the proxy is the session's, never the daemon's env (#136) ------------ -@pytest.mark.parametrize(("endpoint", "trusts_proxy"), [ +@pytest.mark.parametrize(("endpoint", "uses_proxy"), [ (None, False), # port mode: always local ("ws://127.0.0.1:9222/devtools/browser/x", False), ("ws://[::1]:9222/devtools/browser/x", False), @@ -184,14 +185,17 @@ async def fake_get(self, url, timeout): ("wss://cloud.example.com/cdp", True), ("http://192.168.1.10:9222", True), ]) -def test_proxy_trust_follows_the_endpoint(endpoint, trusts_proxy): +def test_session_proxy_applies_only_to_a_remote_endpoint(endpoint, uses_proxy): """One predicate replaced two name checks. `rdp` hard-coded `trust_env=False` and `env` hard-coded `True`. Both were - answering the same question — is this browser on my machine? — so the merged - backend asks it directly. + answering the same question — is this browser on my machine? — so the + merged backend asks it directly, and now applies the session's own pinned + proxy instead of the daemon's environment. """ - assert CdpBackend(_cfg(endpoint=endpoint))._trust_env is trusts_proxy + proxy = "socks5://127.0.0.1:6153" + backend = CdpBackend(_cfg(endpoint=endpoint, proxy=proxy)) + assert backend._proxy == (proxy if uses_proxy else None) # ---- the deleted env vars are inert ---------------------------------------- diff --git a/tests/daemon/test_coverage_listener_dense.py b/tests/daemon/test_coverage_listener_dense.py index 2172d69..258a755 100644 --- a/tests/daemon/test_coverage_listener_dense.py +++ b/tests/daemon/test_coverage_listener_dense.py @@ -289,21 +289,6 @@ async def close(self, *, code=1000, reason=""): assert conn._heartbeat_task.cancelled() -def test_localhost_bypass_proxy_augments_and_restores(monkeypatch): - monkeypatch.setenv("NO_PROXY", "example.com") - with upstream_mod._localhost_bypass_proxy("ws://localhost:9222/devtools/browser/x"): - no_proxy = upstream_mod.os.environ["NO_PROXY"] - assert "example.com" in no_proxy - assert "127.0.0.1" in no_proxy - assert "localhost" in no_proxy - assert "::1" in no_proxy - assert upstream_mod.os.environ["NO_PROXY"] == "example.com" - - monkeypatch.delenv("NO_PROXY", raising=False) - with upstream_mod._localhost_bypass_proxy("wss://remote.example/ws"): - assert "NO_PROXY" not in upstream_mod.os.environ - - def test_relay_process_request_status_and_origin_filter(): relay = RelayServer() relay._extensions["ready"] = SimpleNamespace( diff --git a/tests/daemon/test_facade_proxy_bypass.py b/tests/daemon/test_facade_proxy_bypass.py index 0a6c988..b51cb4a 100644 --- a/tests/daemon/test_facade_proxy_bypass.py +++ b/tests/daemon/test_facade_proxy_bypass.py @@ -1,25 +1,19 @@ """Regression: the facade bridge must not route the upstream CDP connection -through the user's ambient web proxy (issue #20). +through the *daemon's* ambient web proxy (issues #20, #136). `websockets` 15.x honors ``http_proxy`` / ``https_proxy`` / ``all_proxy`` by -default. But the daemon→browser CDP control channel is a direct connection to a -browser the user controls (loopback, LAN, or a Tailscale host), and must bypass -that proxy — otherwise any non-loopback upstream (e.g. a CloakBrowser profile -reached over Tailscale) fails the ws handshake with ``InvalidProxyMessage``. +default. The bridge's only proxy is the one the session pinned (resolved by +the CLI that opened it); the daemon's own environment must never apply — +otherwise a non-loopback upstream (e.g. a CloakBrowser profile reached over +Tailscale) fails the ws handshake with ``InvalidProxyMessage``. -The loopback-only ``_localhost_bypass_proxy`` shortcut can't cover a -non-loopback upstream, so ``_bridge`` passes ``proxy=None`` to disable proxying -outright (per-page proxying is applied downstream by Chrome itself). - -This test simulates a non-loopback upstream by no-op'ing the loopback shortcut, -points a bogus proxy at a dead port, and asserts a client driven through the -facade still reaches the (mock) upstream — which only holds if the facade -disabled the proxy on its bridge connection. +This test points every daemon-side proxy var at a dead port and asserts a +client driven through the facade still reaches the (mock) upstream — which +only holds if the bridge connection ignored that environment. """ from __future__ import annotations import asyncio -import contextlib import json from types import SimpleNamespace @@ -59,14 +53,6 @@ def bogus_proxy(monkeypatch): async def test_bridge_bypasses_ambient_proxy(monkeypatch, bogus_proxy): srv, upstream_url = await _mock_browser_cdp() - # Simulate a non-loopback upstream: the loopback bypass shortcut can't help, - # so only the facade's proxy=None keeps the bridge connection direct. - @contextlib.contextmanager - def _no_bypass(_ws_url): - yield - monkeypatch.setattr( - "browserwright.daemon.server.facade._localhost_bypass_proxy", _no_bypass) - async def _fake_resolve(_cfg): return SimpleNamespace(ws_url=upstream_url) monkeypatch.setattr( diff --git a/tests/daemon/test_net_helpers.py b/tests/daemon/test_net_helpers.py index c67fe5f..9f12995 100644 --- a/tests/daemon/test_net_helpers.py +++ b/tests/daemon/test_net_helpers.py @@ -1,7 +1,7 @@ """`daemon/_net.py` — the loopback predicate and the URL redactor. Both rules used to live inline at their single call site. They moved here -because each is now asked from several places (proxy bypass, `trust_env`, the +because each is now asked from several places (the proxy decision, the DevToolsActivePort fallback; daemon logs, `daemon ps --json`, `session list --json`, resolver errors), and a rule answered twice is a rule that drifts. """ diff --git a/tests/skill/test_session_attach.py b/tests/skill/test_session_attach.py index f25e35d..009dae2 100644 --- a/tests/skill/test_session_attach.py +++ b/tests/skill/test_session_attach.py @@ -17,6 +17,15 @@ from browserwright import session_registry as reg +@pytest.fixture(autouse=True) +def _no_caller_proxy(monkeypatch): + """`session new` pins the caller's proxy into the workspace (#136); keep + the developer's shell proxy out of the ledger shapes asserted here.""" + for var in ("http_proxy", "https_proxy", "all_proxy", "no_proxy"): + monkeypatch.delenv(var, raising=False) + monkeypatch.delenv(var.upper(), raising=False) + + def _new(*args) -> int: return cli._cmd_session(["new", "--backend=cdp", "--name=t", *args]) diff --git a/uv.lock b/uv.lock index 4affb9e..ad8bf4a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11" [[package]] @@ -21,9 +21,10 @@ version = "0.0.0" source = { editable = "." } dependencies = [ { name = "html-to-markdown" }, - { name = "httpx" }, + { name = "httpx", extra = ["socks"] }, { name = "pillow" }, { name = "playwright" }, + { name = "python-socks" }, { name = "pyyaml" }, { name = "websockets" }, ] @@ -43,9 +44,10 @@ dev = [ [package.metadata] requires-dist = [ { name = "html-to-markdown", specifier = ">=3.10.6,<4" }, - { name = "httpx", specifier = ">=0.27" }, + { name = "httpx", extras = ["socks"], specifier = ">=0.27" }, { name = "pillow", specifier = "==12.2.0" }, { name = "playwright", specifier = ">=1.60.0" }, + { name = "python-socks", specifier = ">=2.7" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "rich", marker = "extra == 'ux'", specifier = ">=13" }, { name = "websockets", specifier = "==15.0.1" }, @@ -303,6 +305,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[package.optional-dependencies] +socks = [ + { name = "socksio" }, +] + [[package]] name = "idna" version = "3.16" @@ -516,6 +523,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "python-socks" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/ad/484ffb79532517b11a90af38647c38652224650b31a7ae1cedd5a418d8ab/python_socks-3.1.1.tar.gz", hash = "sha256:8d3e817cdbe858dc0bb8c8fdc8e79b6ce37acce110d33374c6f57a675cc9029e", size = 232781, upload-time = "2026-09-08T13:03:31.058Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/23/2c2cef1b4313c55d1713201acd4ee2043fb2cf22e546ca9d36bf4317faea/python_socks-3.1.1-py3-none-any.whl", hash = "sha256:327e0d6378702c73a7790bf732e9f01392f17b48c7348a50b5bd1f710c2df1be", size = 49566, upload-time = "2026-09-08T13:03:29.604Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -584,6 +600,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] +[[package]] +name = "socksio" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"