From dce05c56870c468c9eab9a868e752fefddc5b8c6 Mon Sep 17 00:00:00 2001 From: Lucain Pouget Date: Mon, 31 Aug 2026 15:17:04 +0200 Subject: [PATCH] [Sandbox] Scope pooled sandbox credentials --- docs/source/en/concepts/sandbox.md | 24 ++++----- docs/source/en/guides/sandbox.md | 2 + src/huggingface_hub/_sandbox.py | 59 +++++++++++++++++----- src/huggingface_hub/_sandbox_cache.py | 5 +- tests/test_sandbox.py | 70 +++++++++++++++++++++++++-- 5 files changed, 131 insertions(+), 29 deletions(-) diff --git a/docs/source/en/concepts/sandbox.md b/docs/source/en/concepts/sandbox.md index 0bc8b717ec..d02ed98914 100644 --- a/docs/source/en/concepts/sandbox.md +++ b/docs/source/en/concepts/sandbox.md @@ -40,29 +40,31 @@ A few decisions worth calling out: > [!TIP] > The server is open source at [github.com/huggingface/sandbox-server](https://github.com/huggingface/sandbox-server). -## Authentication is stateless +## Authentication supports stateless reconnection Two independent layers protect a sandbox: 1. **The proxy gate.** The Jobs proxy only forwards requests carrying an HF token with read access to the job's namespace. A random member of the internet cannot reach the URL. -2. **The application gate.** `sbx-server` additionally checks a per-sandbox `X-Sandbox-Token` on every request. This is defense in depth: a read-only namespace member who can reach the proxy still cannot execute commands. +2. **The application gate.** `sbx-server` additionally checks `X-Sandbox-Token` on every request. This is defense in depth: a read-only namespace member who can reach the proxy still cannot execute commands. -The per-sandbox token is derived, not stored: +For a dedicated sandbox, the token is derived rather than stored locally: ```text nonce = random 128-bit hex # stored in the job label "hf-sandbox-nonce" token = HMAC-SHA256(key=your_hf_token, msg="hf-sandbox:" + nonce) ``` -This means only the HF token used to spin-up the Job can access the sandbox. +This means only a client holding the HF token used to spin up the Job can recompute its token. + +A pool separates host management from sandbox access. The HMAC-derived token is the host-management credential; current clients use it only for pool lifecycle and token-recovery endpoints. When the host creates a shared sandbox, it also generates a random 256-bit capability accepted only by that sandbox's scoped routes (`/v1/sandboxes//*`) and port proxy. The create response returns that capability to the client. On reconnect, the client authenticates to a management endpoint to recover it. For a rolling upgrade, the server temporarily accepts the management credential on non-proxy scoped routes used by older clients, but it is never accepted by a pooled port proxy and therefore is not sent to the application behind one. Every sandbox job also carries two stable labels for discovery — `hf-sandbox=1` (on all of them) and `hf-sandbox-mode=dedicated` or `hf-sandbox-mode=pool` — so you can list or filter them server-side, e.g. `hf jobs ps --label hf-sandbox=1`. The token is delivered to the server via a Job secret. The client re-derives it on demand from the public nonce in the label. This has some nice consequences: -- **Stateless reconnection.** [`Sandbox.connect(id)`] works from any machine that holds the same HF token — read the nonce from the label, recompute the token. No local files, no state to copy. +- **Stateless reconnection.** [`Sandbox.connect(id)`] works from any machine that holds the same HF token — read the nonce from the label, recompute the dedicated or host-management token, and (for a pool) recover the sandbox capability from the host. No local files or state to copy. - **The HF token is not passed to the sandbox as an environment variable or job secret** (unless you opt in with `forward_hf_token=True`). This is not a hard guarantee that your credentials stay out of reach: the process listening on the sandbox port is whatever the image starts first, so an untrusted image may be able to observe the requests the client sends — including their `Authorization` header. Treat credentials reachable from a sandbox as potentially exposed to it. -- **Per-sandbox scope.** Each sandbox has a unique nonce, so a leaked sandbox token compromises that one sandbox only. Other namespace members hold a different HF token and cannot derive it. +- **Per-sandbox scope.** Every pooled sandbox has an independent random capability. A capability exposed to its own proxied application cannot address a sibling or invoke a host-management or unscoped route. Dedicated sandboxes have separate Jobs and separate nonces. ## Dedicated sandboxes (`Sandbox.create`) @@ -122,7 +124,7 @@ A stock Job runs as root inside a user namespace that maps only uids 0..65535, w Combining distinct uids (discretionary access control) with Landlock, and verified live against a hostile sandbox A attacking a victim B, gives: -- ✅ A cannot read any process's `environ` → HF and sandbox tokens never leak between sandboxes. +- ✅ A cannot read another sandbox process's `environ`; independently scoped capabilities also prevent a token exposed to A's own proxied application from authenticating to B. - ✅ A cannot `SIGKILL` / `ptrace` / read the memory of B's processes, `setuid` into B, or read B's `0700` home. - ✅ `/tmp` and `/dev/shm` access is denied — each sandbox is Landlock-confined to its own home (its @@ -138,18 +140,18 @@ Combining distinct uids (discretionary access control) with Landlock, and verifi > - **Resource DoS.** Without cgroup delegation, CPU / total RAM / disk are not partitioned. `RLIMIT_NPROC` and `RLIMIT_AS` bound per-process usage, but an aggressive sandbox can still starve its neighbours or trip the global OOM killer. > - **Process-list metadata.** A sandbox can see other processes via `/proc` (names, cmdlines) — it just cannot read or signal them. Hiding them would need a PID namespace, which `unshare` can't create here. > -> In short: confidentiality and integrity between pooled sandboxes are enforced; only availability (DoS) and process-list metadata are shared. That is the right boundary for one user's own parallel workloads. For mutually-hostile untrusted code — or for GPU — use [`Sandbox.create`], which gives each sandbox its own VM. +> In short: the server combines uid, Landlock, descriptor-confined file operations, and independently scoped API capabilities to protect confidentiality and integrity between pooled sandboxes. Availability (DoS), process-list metadata, the kernel, and the host VM remain shared. This is intended for one user's parallel workloads. For mutually hostile code — or for GPU — use [`Sandbox.create`], which gives each sandbox its own VM. ### The file model in a pool -Because a pooled sandbox's only writable area is its Landlock-confined home (which is also its default working directory), the file API roots every path at that home: `files.write("data/in.txt", ...)` writes to `$HOME/data/in.txt`, a leading `/` is taken relative to the home, and `..` cannot escape it. Files written through the API are `chown`ed to the sandbox's uid so the sandbox's own code can read them. This gives a clean "filesystem rooted at the sandbox" model that matches exactly what code inside the sandbox can touch — and differs from dedicated sandboxes, where paths are absolute on the container filesystem. +Because a pooled sandbox's only writable area is its Landlock-confined home (which is also its default working directory), the file API roots every path at that home: `files.write("data/in.txt", ...)` writes to `$HOME/data/in.txt`, a leading `/` is taken relative to the home, and `..` cannot escape it. The privileged server walks each component relative to an open home-directory descriptor with no-follow semantics, and changes ownership only through descriptors it already opened. Symlinks remain usable by code running inside the sandbox, but the privileged file API does not follow them. Files created through the API are assigned to the sandbox uid. Dedicated sandboxes differ: their file paths are absolute on the container filesystem and the API runs inside that sandbox's VM. ### Pools have no authoritative local state A pool is deliberately not a local config file. A pool is its set of running host Jobs, all sharing an `hf-sandbox-pool=` label. This keeps pools consistent with the rest of the sandbox API (everything is discoverable from labels and reattachable from any machine), and it means a pool simply stops existing once its last host is gone. - A host carries the pool's config (image, flavor, `sandboxes_per_host`, idle timeout) in its job env vars — labels are used only for filtering. When a client must boot a duplicate host, it reads that config back from a running host (`inspect_job`), so all hosts in a pool stay consistent without a central record. -- Env and secrets are per-sandbox, passed at create time — never pool-level. No secret is ever stored on a host or kept on disk locally. +- Env values are per-sandbox and passed at create time, not stored in the host Job metadata or on local disk. The root-owned server retains them in memory until that sandbox is deleted, then installs them only in that sandbox's scrubbed process environment. - Capacity is server-authoritative. A host refuses creates beyond `sandboxes_per_host` (replying `{"rejected": N}`); the client packs the overflow onto another host or boots a duplicate. This keeps packing exact even when several processes create into the same pool concurrently. - Idle eviction is two-level. Each sandbox is evicted after its own `idle_timeout` of inactivity (unless it still has a running process); once a host has had no sandboxes for the host idle timeout, it shuts itself down — a billing backstop even if every client disappears. @@ -207,7 +209,7 @@ All numbers are measured against real HF Jobs on `cpu-basic`, with the client on | Build on Jobs, no new service | inherits billing, hardware, permissions; works in any image | | Static Rust binary, downloaded at startup | no Python/pip; ~6s cold start vs 30–90s for a pip-based bootstrap | | Hand-rolled HTTP/1.1 | minimal frameworks buffer chunked responses and break live streaming (verified) | -| Stateless HMAC auth | reconnect from anywhere; per-sandbox scoped token instead of the HF token | +| HMAC host auth + random pooled capabilities | stateless reconnect without exposing a host-wide token to pooled applications | | `run()` raises on non-zero exit (`check=False` opts out) | best DX for "run code, see the error" loops (E2B-style) | | `idle_timeout` watchdog instead of client-side cleanup | persistent sandboxes are a feature; leaked ones still die | | Pools = uid + Landlock, server-authoritative capacity, no local state | fast same-user fan-out; correct under concurrency; reattachable anywhere | diff --git a/docs/source/en/guides/sandbox.md b/docs/source/en/guides/sandbox.md index a1a32e4c41..f0af0c934b 100644 --- a/docs/source/en/guides/sandbox.md +++ b/docs/source/en/guides/sandbox.md @@ -134,6 +134,8 @@ Start a server in the sandbox (in the background), then reach it from the outsid ... ws_url = sbx.proxy_url_for(8000, "/ws", scheme="wss://") ``` +The proxy headers are credentials. Do not configure the HTTP client to carry them across redirects to another origin; the example above uses `httpx`'s default of not following redirects. A pooled token is scoped to that one sandbox, but should still be handled as a secret. + How the inner server must listen depends on the sandbox kind: - **Dedicated** ([`Sandbox.create`]): bind a normal TCP port on `127.0.0.1:`. diff --git a/src/huggingface_hub/_sandbox.py b/src/huggingface_hub/_sandbox.py index 710464f393..7aaeedba43 100644 --- a/src/huggingface_hub/_sandbox.py +++ b/src/huggingface_hub/_sandbox.py @@ -55,8 +55,9 @@ # host jobs only. Pool config (capacity, idle timeout) lives in the host's env vars, read via # inspect_job — labels are kept for filtering/grouping only. POOL_LABEL = "hf-sandbox-pool" -# Per-job public nonce the sandbox token is derived from (see _derive_sandbox_token), so -# `Sandbox.connect(id)` can recompute the token from any machine with no local state. +# Per-job public nonce the server-management token is derived from (see +# _derive_sandbox_token), so clients can reconnect from any machine with no +# local state. Pool hosts mint a separate capability for each shared sandbox. NONCE_LABEL = "hf-sandbox-nonce" DEFAULT_IMAGE = "python:3.12" @@ -92,13 +93,13 @@ def _derive_sandbox_token(hf_token: str, nonce: str) -> str: - """Derive the per-sandbox auth token from the user's HF token and the sandbox nonce. + """Derive the dedicated or pool-management token from an HF token and nonce. Stateless: any machine holding the same HF token can recompute it from the nonce stored in the job's labels, so `Sandbox.connect(job_id)` needs no local state. Only the derived token is passed to the sandbox server as a job secret; the HF token - itself is not. Note this is not a hardened boundary: an untrusted image can own the - sandbox port, so don't treat it as a guarantee that credentials stay out of the sandbox. + itself is not. On pool hosts this token is restricted to host management, while + each shared sandbox gets a random capability for its own routes. """ return hmac.new(hf_token.encode(), f"hf-sandbox:{nonce}".encode(), hashlib.sha256).hexdigest() @@ -330,6 +331,15 @@ def _raise_for_status(response: httpx.Response) -> None: raise SandboxError(f"Sandbox API error ({response.status_code}): {message}", status_code=response.status_code) +def _scoped_token(data: dict[str, Any]) -> str: + token = data.get("token") + if not isinstance(token, str) or not token: + raise SandboxError( + "The sandbox host does not support scoped sandbox tokens; recycle it with an updated sbx-server." + ) + return token + + class _SandboxServer: """HTTP transport to one `sbx-server` instance — a dedicated job or a shared host. @@ -375,7 +385,10 @@ def __init__( "X-Sandbox-Token": sandbox_token, }, limits=httpx.Limits(max_connections=max_connections, max_keepalive_connections=max_connections), - follow_redirects=True, + # The sandbox API never redirects. Following a response supplied by + # an untrusted in-sandbox app could forward its authentication header + # to another origin. + follow_redirects=False, ) @classmethod @@ -489,6 +502,7 @@ def __init__( id: str, server: _SandboxServer, local_id: str | None, + sandbox_token: str | None = None, owns_sandbox: bool, owns_server: bool, ) -> None: @@ -496,6 +510,13 @@ def __init__( self._server = server # None in dedicated mode; the host-local sandbox id in shared mode. self._local_id = local_id + if local_id is None: + # A dedicated server has one credential for its one sandbox. + self._sandbox_token = server._sandbox_token + elif sandbox_token is None: + raise SandboxError("A shared sandbox requires a sandbox-scoped token.") + else: + self._sandbox_token = sandbox_token # Path prefix for all in-server operations: dedicated routes live under # /v1/*, shared ones under /v1/sandboxes//*. self._base_path = "/v1" if local_id is None else f"/v1/sandboxes/{local_id}" @@ -622,13 +643,19 @@ def connect(cls, sandbox_id: str, *, namespace: str | None = None, token: str | host_job_id, local_id = sandbox_id.split(SHARED_ID_SEP, 1) server = _connect_host(api, host_job_id, namespace=namespace) try: - existing = {item["id"] for item in server.request("GET", "/v1/sandboxes").json()} - if local_id not in existing: - raise SandboxError(f"Sandbox {sandbox_id} no longer exists on host {host_job_id}.") + data = server.request("GET", f"/v1/sandboxes/{local_id}/token").json() + sandbox_token = _scoped_token(data) except Exception: server.close() # don't leak the HTTP client when the host is gone/unreachable raise - return cls(id=sandbox_id, server=server, local_id=local_id, owns_sandbox=False, owns_server=True) + return cls( + id=sandbox_id, + server=server, + local_id=local_id, + sandbox_token=sandbox_token, + owns_sandbox=False, + owns_server=True, + ) job = api.inspect_job(job_id=sandbox_id, namespace=namespace) labels = job.labels or {} @@ -668,7 +695,7 @@ def _kill(self) -> None: if self._local_id is None: self._server.cancel_job() else: - self._server.request("DELETE", f"/v1/sandboxes/{self._local_id}") + self._request("DELETE", "") except Exception as e: # Don't mark as killed: a later kill() call should retry so nothing leaks. logger.warning(f"Failed to kill sandbox {self.id}: {e}") @@ -899,7 +926,7 @@ def proxy_headers(self) -> dict[str, str]: """Auth headers to send with [`proxy_url_for`] requests (HF token + sandbox token).""" return { "Authorization": f"Bearer {self._server._auth_token}", - "X-Sandbox-Token": self._server._sandbox_token, + "X-Sandbox-Token": self._sandbox_token, } def __repr__(self) -> str: @@ -908,10 +935,16 @@ def __repr__(self) -> str: # ------------------------------------------------------------------ internals def _request(self, method: str, resource: str, **kwargs) -> httpx.Response: + headers = dict(kwargs.pop("headers", None) or {}) + headers["X-Sandbox-Token"] = self._sandbox_token + kwargs["headers"] = headers return self._server.request(method, self._base_path + resource, **kwargs) @contextmanager def _stream(self, method: str, resource: str, **kwargs) -> Iterator[httpx.Response]: + headers = dict(kwargs.pop("headers", None) or {}) + headers["X-Sandbox-Token"] = self._sandbox_token + kwargs["headers"] = headers with self._server.stream(method, self._base_path + resource, **kwargs) as response: yield response @@ -1568,10 +1601,12 @@ def _create_one(self, host: "_SandboxServer", env: dict[str, Any], idle_secs: in host.live = host.capacity # the host is full; stop reserving it return None item = sandboxes[0] + sandbox_token = _scoped_token(item) sandbox = Sandbox( id=f"{host.job_id}{SHARED_ID_SEP}{item['id']}", server=host, local_id=item["id"], + sandbox_token=sandbox_token, owns_sandbox=True, owns_server=False, ) diff --git a/src/huggingface_hub/_sandbox_cache.py b/src/huggingface_hub/_sandbox_cache.py index 25ddca75be..c164129356 100644 --- a/src/huggingface_hub/_sandbox_cache.py +++ b/src/huggingface_hub/_sandbox_cache.py @@ -40,8 +40,9 @@ class CachedHost: """A single host Job of a pool, as last seen by some process. `base_url` + `nonce` are everything needed to rebuild the in-job server transport - (`_SandboxServer`) without an `inspect_job` round-trip: the per-sandbox auth token is - re-derived from the user's HF token and `nonce` (see `_derive_sandbox_token`). + (`_SandboxServer`) without an `inspect_job` round-trip: the host-management token is + re-derived from the user's HF token and `nonce` (see `_derive_sandbox_token`). Shared + sandbox capabilities are returned by the host when each sandbox is created or reconnected. """ job_id: str diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index f580ef1add..ccbbd2e448 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -76,6 +76,8 @@ class _FakeServer(BaseHTTPRequestHandler): """Minimal stand-in for sbx-server speaking the same protocol (both modes).""" sandboxes: set = set() + sandbox_tokens: dict[str, str] = {} + received_tokens: list[tuple[str, str | None]] = [] capacity = None # None == unlimited; set per-subclass to test the full handshake seq = 0 # monotonic id source (survives deletes) last_exec: dict | None = None # body of the most recent /exec call (for assertions) @@ -85,6 +87,20 @@ class _FakeServer(BaseHTTPRequestHandler): def log_message(self, *args) -> None: pass + def _assert_auth(self) -> None: + path = self.path.split("?", 1)[0] + segments = path.strip("/").split("/") + token = self.headers.get("X-Sandbox-Token") + type(self).received_tokens.append((path, token)) + if segments[:2] == ["v1", "sandboxes"] and len(segments) >= 3: + if len(segments) == 4 and segments[3] == "token": + expected = "secret" # host-management endpoint + else: + expected = type(self).sandbox_tokens[segments[2]] + else: + expected = "secret" + assert token == expected + def _ndjson(self, events) -> None: self.send_response(200) self.send_header("Content-Type", "application/x-ndjson") @@ -114,7 +130,7 @@ def _exec(self, body) -> None: ) def do_POST(self) -> None: - assert self.headers["X-Sandbox-Token"] == "secret" + self._assert_auth() body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}") cls = type(self) # exec (dedicated /v1/exec or shared /v1/sandboxes//exec) @@ -144,20 +160,24 @@ def do_POST(self) -> None: sid = f"sbx{cls.seq}" cls.seq += 1 cls.sandboxes.add(sid) - created.append({"id": sid}) + token = f"token-{sid}" + cls.sandbox_tokens[sid] = token + created.append({"id": sid, "token": token}) self._json({"sandboxes": created, "rejected": rejected}) def do_DELETE(self) -> None: - assert self.headers["X-Sandbox-Token"] == "secret" + self._assert_auth() last = self.path.rsplit("/", 1)[-1] if "/processes/" in self.path: # kill a background process type(self).processes = [p for p in type(self).processes if str(p["pid"]) != last] self._json({"pid": last, "killed": True}) return type(self).sandboxes.discard(last) + type(self).sandbox_tokens.pop(last, None) self._json({"id": last, "deleted": True}) def do_GET(self) -> None: + self._assert_auth() cls = type(self) if self.path.startswith("/v1/files/stat") or "/files/stat" in self.path: self._json({"name": "x", "path": "/x", "type": "file", "size": 5}) @@ -170,11 +190,16 @@ def do_GET(self) -> None: self._json(cls.processes) elif self.path == "/v1/sandboxes": self._json([{"id": sid} for sid in sorted(cls.sandboxes)]) + elif self.path.startswith("/v1/sandboxes/") and self.path.endswith("/token"): + sid = self.path.split("/")[3] + self._json({"id": sid, "token": cls.sandbox_tokens[sid]}) @pytest.fixture() def fake_server(): _FakeServer.sandboxes = set() + _FakeServer.sandbox_tokens = {} + _FakeServer.received_tokens = [] _FakeServer.seq = 0 _FakeServer.capacity = None _FakeServer.last_exec = None @@ -192,6 +217,8 @@ def _spawn_fake(capacity=None): class _Fake(_FakeServer): sandboxes: set = set() + sandbox_tokens: dict[str, str] = {} + received_tokens: list[tuple[str, str | None]] = [] seq = 0 processes: list = [] proc_seq = 0 @@ -285,6 +312,14 @@ def test_proxy_url_for(self, fake_server: str) -> None: assert sandbox.proxy_url_for(8000, "/ws", scheme="wss://") == f"wss://{host}/v1/proxy/8000/ws" assert sandbox.proxy_headers["X-Sandbox-Token"] == "secret" + def test_sandbox_transport_does_not_follow_redirects(self, fake_server: str) -> None: + sandbox = _make_sandbox(fake_server) + assert sandbox._server._client.follow_redirects is False + + def test_shared_sandbox_requires_scoped_token(self) -> None: + with pytest.raises(SandboxError, match="does not support scoped sandbox tokens"): + sandbox_mod._scoped_token({}) + def test_kill_is_idempotent(self, fake_server: str) -> None: sandbox = _make_sandbox(fake_server) sandbox.kill() @@ -324,7 +359,15 @@ class TestSharedSandbox: def _make_shared(self, base_url: str) -> Sandbox: server = _make_server(base_url, capacity=10) _FakeServer.sandboxes.add("local1") - return Sandbox(id="job123.local1", server=server, local_id="local1", owns_sandbox=True, owns_server=False) + _FakeServer.sandbox_tokens["local1"] = "token-local1" + return Sandbox( + id="job123.local1", + server=server, + local_id="local1", + sandbox_token="token-local1", + owns_sandbox=True, + owns_server=False, + ) def test_base_path_is_scoped(self, fake_server: str) -> None: sandbox = self._make_shared(fake_server) @@ -332,6 +375,8 @@ def test_base_path_is_scoped(self, fake_server: str) -> None: assert sandbox.host_id == "job123" # exec is routed under the per-sandbox prefix and still parsed correctly. assert sandbox.run("echo").stdout == "out1" + assert _FakeServer.received_tokens[-1] == ("/v1/sandboxes/local1/exec", "token-local1") + assert sandbox.proxy_headers["X-Sandbox-Token"] == "token-local1" def test_kill_deletes_sandbox_not_job(self, fake_server: str) -> None: sandbox = self._make_shared(fake_server) @@ -339,6 +384,22 @@ def test_kill_deletes_sandbox_not_job(self, fake_server: str) -> None: sandbox._server._api.cancel_job.assert_not_called() # host keeps running assert "local1" not in _FakeServer.sandboxes + def test_connect_recovers_scoped_token(self, fake_server: str, monkeypatch) -> None: + _FakeServer.sandboxes.add("local1") + _FakeServer.sandbox_tokens["local1"] = "token-local1" + monkeypatch.setattr( + sandbox_mod, + "_connect_host", + lambda api, jid, namespace=None: _make_server(fake_server, job_id=jid), + ) + + sandbox = Sandbox.connect("job123.local1", token="hf_test") + + assert sandbox.proxy_headers["X-Sandbox-Token"] == "token-local1" + assert _FakeServer.received_tokens[-1] == ("/v1/sandboxes/local1/token", "secret") + assert sandbox.run("echo").stdout == "out1" + assert _FakeServer.received_tokens[-1] == ("/v1/sandboxes/local1/exec", "token-local1") + class TestSandboxPool: def _pool(self, fake_server: str, monkeypatch, per_host: int = 4) -> SandboxPool: @@ -357,6 +418,7 @@ def test_packs_into_hosts_and_tracks_slots(self, fake_server: str, monkeypatch) assert pool.num_sandboxes == 6 # Each sandbox id is ".". assert all("." in b.id for b in boxes) + assert len({b.proxy_headers["X-Sandbox-Token"] for b in boxes}) == 6 def test_create_returns_one_sandbox(self, fake_server: str, monkeypatch) -> None: pool = self._pool(fake_server, monkeypatch)