openshell 0.0.113__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1983 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import pickle
9
+ import threading
10
+ import time
11
+ from copy import deepcopy
12
+ from dataclasses import asdict
13
+ from pathlib import Path
14
+ from types import SimpleNamespace
15
+ from typing import Any, cast
16
+
17
+ import pytest
18
+
19
+ import openshell.sandbox as sandbox_module
20
+ from openshell._proto import openshell_pb2
21
+ from openshell.sandbox import (
22
+ _PYTHON_CLOUDPICKLE_BOOTSTRAP,
23
+ _SANDBOX_PYTHON_BIN,
24
+ InferenceRouteClient,
25
+ Sandbox,
26
+ SandboxClient,
27
+ SandboxError,
28
+ SandboxRef,
29
+ SandboxStatusRef,
30
+ TlsConfig,
31
+ _atomic_replace,
32
+ _BearerAuthInterceptor,
33
+ _load_cluster_bearer_token,
34
+ _make_cluster_bearer_provider,
35
+ _normalize_bearer,
36
+ _OidcRefresher,
37
+ _read_oidc_token_bundle,
38
+ _sandbox_ref,
39
+ )
40
+
41
+
42
+ class _FakeStub:
43
+ def __init__(self) -> None:
44
+ self.request: openshell_pb2.ExecSandboxRequest | None = None
45
+
46
+ def ExecSandbox(
47
+ self,
48
+ request: openshell_pb2.ExecSandboxRequest,
49
+ timeout: float | None = None,
50
+ ):
51
+ self.request = request
52
+ _ = timeout
53
+ yield openshell_pb2.ExecSandboxEvent(
54
+ exit=openshell_pb2.ExecSandboxExit(exit_code=0)
55
+ )
56
+
57
+
58
+ class _FakeInferenceStub:
59
+ def __init__(self) -> None:
60
+ self.set_request = None
61
+ self.get_request = None
62
+
63
+ def SetInferenceRoute(self, request: Any, timeout: float | None = None) -> Any:
64
+ self.set_request = request
65
+ _ = timeout
66
+
67
+ class _Response:
68
+ provider_name = request.provider_name
69
+ model_id = request.model_id
70
+ version = 1
71
+
72
+ return _Response()
73
+
74
+ def GetInferenceRoute(self, request: Any, timeout: float | None = None) -> Any:
75
+ self.get_request = request
76
+ _ = timeout
77
+
78
+ class _Response:
79
+ provider_name = "openai-dev"
80
+ model_id = "gpt-4.1"
81
+ version = 2
82
+
83
+ return _Response()
84
+
85
+
86
+ def _client_with_fake_stub(stub: object) -> SandboxClient:
87
+ client = cast("SandboxClient", object.__new__(SandboxClient))
88
+ client._timeout = 30.0
89
+ client._stub = cast("Any", stub)
90
+ return client
91
+
92
+
93
+ def test_exec_sends_stdin_payload() -> None:
94
+ stub = _FakeStub()
95
+ client = _client_with_fake_stub(stub)
96
+
97
+ result = client.exec("sandbox-1", ["python", "-c", "print('ok')"], stdin=b"payload")
98
+
99
+ assert result.exit_code == 0
100
+ assert stub.request is not None
101
+ assert stub.request.stdin == b"payload"
102
+
103
+
104
+ def test_exec_python_serializes_callable_payload() -> None:
105
+ stub = _FakeStub()
106
+ client = _client_with_fake_stub(stub)
107
+
108
+ def add(a: int, b: int) -> int:
109
+ return a + b
110
+
111
+ result = client.exec_python("sandbox-1", add, args=(2, 3))
112
+
113
+ assert result.exit_code == 0
114
+ assert stub.request is not None
115
+ assert stub.request.command == [
116
+ _SANDBOX_PYTHON_BIN,
117
+ "-c",
118
+ _PYTHON_CLOUDPICKLE_BOOTSTRAP,
119
+ ]
120
+ assert stub.request.environment["OPENSHELL_PYFUNC_B64"]
121
+ assert stub.request.stdin == b""
122
+
123
+
124
+ def test_from_active_cluster_reads_gateway_metadata_layout(
125
+ tmp_path: Path,
126
+ monkeypatch: Any,
127
+ ) -> None:
128
+ gateway_name = "test-gateway"
129
+ gateway_dir = tmp_path / "openshell" / "gateways" / gateway_name
130
+ mtls_dir = gateway_dir / "mtls"
131
+ mtls_dir.mkdir(parents=True)
132
+ (tmp_path / "openshell" / "active_gateway").write_text(gateway_name)
133
+ (gateway_dir / "metadata.json").write_text(
134
+ json.dumps({"gateway_endpoint": "https://127.0.0.1:8443"})
135
+ )
136
+ (mtls_dir / "ca.crt").write_text("ca")
137
+ (mtls_dir / "tls.crt").write_text("cert")
138
+ (mtls_dir / "tls.key").write_text("key")
139
+
140
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
141
+ monkeypatch.delenv("OPENSHELL_GATEWAY", raising=False)
142
+
143
+ client = SandboxClient.from_active_cluster()
144
+ try:
145
+ assert client._cluster_name == gateway_name
146
+ finally:
147
+ client.close()
148
+
149
+
150
+ def test_from_active_cluster_prefers_openshell_gateway_env(
151
+ tmp_path: Path,
152
+ monkeypatch: Any,
153
+ ) -> None:
154
+ gateway_name = "env-gateway"
155
+ gateway_dir = tmp_path / "openshell" / "gateways" / gateway_name
156
+ mtls_dir = gateway_dir / "mtls"
157
+ mtls_dir.mkdir(parents=True)
158
+ (gateway_dir / "metadata.json").write_text(
159
+ json.dumps({"gateway_endpoint": "https://127.0.0.1:8443"})
160
+ )
161
+ (mtls_dir / "ca.crt").write_text("ca")
162
+ (mtls_dir / "tls.crt").write_text("cert")
163
+ (mtls_dir / "tls.key").write_text("key")
164
+
165
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
166
+ monkeypatch.setenv("OPENSHELL_GATEWAY", gateway_name)
167
+
168
+ client = SandboxClient.from_active_cluster()
169
+ try:
170
+ assert client._cluster_name == gateway_name
171
+ finally:
172
+ client.close()
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # OIDC bearer auth
177
+ # ---------------------------------------------------------------------------
178
+
179
+
180
+ class _FakeClientCallDetails:
181
+ """grpc.ClientCallDetails is a NamedTuple in real gRPC; for unit tests we
182
+ just need an object with the same field set and a ._replace shim."""
183
+
184
+ __slots__ = ("credentials", "metadata", "method", "timeout", "wait_for_ready")
185
+
186
+ def __init__(
187
+ self,
188
+ method: str = "/Test/Method",
189
+ timeout: float | None = None,
190
+ metadata: Any = None,
191
+ credentials: Any = None,
192
+ wait_for_ready: Any = None,
193
+ ) -> None:
194
+ self.method = method
195
+ self.timeout = timeout
196
+ self.metadata = metadata
197
+ self.credentials = credentials
198
+ self.wait_for_ready = wait_for_ready
199
+
200
+ def _replace(self, **kwargs: Any) -> _FakeClientCallDetails:
201
+ return _FakeClientCallDetails(
202
+ method=kwargs.get("method", self.method),
203
+ timeout=kwargs.get("timeout", self.timeout),
204
+ metadata=kwargs.get("metadata", self.metadata),
205
+ credentials=kwargs.get("credentials", self.credentials),
206
+ wait_for_ready=kwargs.get("wait_for_ready", self.wait_for_ready),
207
+ )
208
+
209
+
210
+ def test_normalize_bearer_accepts_str_or_callable() -> None:
211
+ assert _normalize_bearer(None) is None
212
+
213
+ static = _normalize_bearer("abc")
214
+ assert static is not None
215
+ assert static() == "abc"
216
+
217
+ counter = [0]
218
+
219
+ def provider() -> str:
220
+ counter[0] += 1
221
+ return f"token-{counter[0]}"
222
+
223
+ dynamic = _normalize_bearer(provider)
224
+ assert dynamic is not None
225
+ assert dynamic() == "token-1"
226
+ assert dynamic() == "token-2"
227
+
228
+
229
+ def test_bearer_interceptor_attaches_authorization_header() -> None:
230
+ interceptor = _BearerAuthInterceptor(lambda: "secret-token")
231
+ captured: dict[str, Any] = {}
232
+
233
+ def continuation(details: Any, request: Any) -> str:
234
+ captured["details"] = details
235
+ captured["request"] = request
236
+ return "result"
237
+
238
+ details = _FakeClientCallDetails(metadata=[("x-existing", "yes")])
239
+ result = interceptor.intercept_unary_unary(continuation, details, "payload")
240
+
241
+ assert result == "result"
242
+ md = list(captured["details"].metadata)
243
+ # Pre-existing metadata preserved, authorization appended last.
244
+ assert ("x-existing", "yes") in md
245
+ assert ("authorization", "Bearer secret-token") in md
246
+ assert captured["request"] == "payload"
247
+
248
+
249
+ def test_bearer_interceptor_handles_empty_metadata() -> None:
250
+ interceptor = _BearerAuthInterceptor(lambda: "t")
251
+ captured: dict[str, Any] = {}
252
+
253
+ def continuation(details: Any, _request: Any) -> None:
254
+ captured["metadata"] = list(details.metadata)
255
+
256
+ details = _FakeClientCallDetails(metadata=None)
257
+ interceptor.intercept_unary_unary(continuation, details, request="x")
258
+
259
+ assert captured["metadata"] == [("authorization", "Bearer t")]
260
+
261
+
262
+ def test_bearer_interceptor_calls_token_provider_per_request() -> None:
263
+ tokens = iter(["t1", "t2", "t3"])
264
+ interceptor = _BearerAuthInterceptor(lambda: next(tokens))
265
+ seen: list[str] = []
266
+
267
+ def continuation(details: Any, _request: Any) -> None:
268
+ for key, value in details.metadata:
269
+ if key == "authorization":
270
+ seen.append(value)
271
+
272
+ for _ in range(3):
273
+ interceptor.intercept_unary_unary(
274
+ continuation, _FakeClientCallDetails(), request="x"
275
+ )
276
+
277
+ assert seen == ["Bearer t1", "Bearer t2", "Bearer t3"]
278
+
279
+
280
+ def test_load_cluster_bearer_token_reads_oidc_token_json(tmp_path: Path) -> None:
281
+ gateway_dir = tmp_path / "gw"
282
+ gateway_dir.mkdir()
283
+ (gateway_dir / "oidc_token.json").write_text(
284
+ json.dumps(
285
+ {
286
+ "access_token": "jwt-blob",
287
+ "refresh_token": "rt",
288
+ "expires_at": 9999999999,
289
+ "issuer": "https://idp.example/realms/openshell",
290
+ "client_id": "openshell-cli",
291
+ }
292
+ )
293
+ )
294
+ assert _load_cluster_bearer_token(gateway_dir) == "jwt-blob"
295
+
296
+
297
+ def test_load_cluster_bearer_token_returns_none_when_missing(
298
+ tmp_path: Path,
299
+ ) -> None:
300
+ assert _load_cluster_bearer_token(tmp_path / "absent") is None
301
+
302
+
303
+ def test_load_cluster_bearer_token_tolerates_unreadable_file(
304
+ tmp_path: Path,
305
+ ) -> None:
306
+ gateway_dir = tmp_path / "gw"
307
+ gateway_dir.mkdir()
308
+ (gateway_dir / "oidc_token.json").write_text("not json")
309
+ assert _load_cluster_bearer_token(gateway_dir) is None
310
+
311
+
312
+ def test_load_cluster_bearer_token_rejects_missing_access_token(
313
+ tmp_path: Path,
314
+ ) -> None:
315
+ gateway_dir = tmp_path / "gw"
316
+ gateway_dir.mkdir()
317
+ (gateway_dir / "oidc_token.json").write_text(json.dumps({"refresh_token": "rt"}))
318
+ assert _load_cluster_bearer_token(gateway_dir) is None
319
+
320
+
321
+ def _setup_gateway_dir(
322
+ tmp_path: Path,
323
+ monkeypatch: Any,
324
+ *,
325
+ name: str = "g",
326
+ endpoint: str = "http://127.0.0.1:8080",
327
+ auth_mode: str | None = None,
328
+ mtls_files: dict[str, str] | None = None,
329
+ oidc_bundle: dict | None = None,
330
+ ) -> Path:
331
+ gateway_dir = tmp_path / "openshell" / "gateways" / name
332
+ gateway_dir.mkdir(parents=True)
333
+ (tmp_path / "openshell" / "active_gateway").write_text(name)
334
+ meta: dict[str, Any] = {"gateway_endpoint": endpoint}
335
+ if auth_mode is not None:
336
+ meta["auth_mode"] = auth_mode
337
+ (gateway_dir / "metadata.json").write_text(json.dumps(meta))
338
+ if mtls_files:
339
+ mtls_dir = gateway_dir / "mtls"
340
+ mtls_dir.mkdir()
341
+ for fname, body in mtls_files.items():
342
+ (mtls_dir / fname).write_text(body)
343
+ if oidc_bundle is not None:
344
+ (gateway_dir / "oidc_token.json").write_text(json.dumps(oidc_bundle))
345
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
346
+ monkeypatch.delenv("OPENSHELL_GATEWAY", raising=False)
347
+ return gateway_dir
348
+
349
+
350
+ def _channel_is_intercepted(channel: Any) -> bool:
351
+ """grpc.intercept_channel returns a _Channel whose module name ends in
352
+ `interceptor`. We don't depend on the class name (it varies across
353
+ gRPC versions); module is stable."""
354
+ return type(channel).__module__.endswith("interceptor")
355
+
356
+
357
+ def test_from_active_cluster_loads_bearer_when_auth_mode_is_oidc(
358
+ tmp_path: Path,
359
+ monkeypatch: Any,
360
+ ) -> None:
361
+ """Finding 3: bearer is attached iff metadata.auth_mode == "oidc"."""
362
+ _setup_gateway_dir(
363
+ tmp_path,
364
+ monkeypatch,
365
+ auth_mode="oidc",
366
+ oidc_bundle={"access_token": "from-disk"},
367
+ )
368
+ client = SandboxClient.from_active_cluster()
369
+ try:
370
+ assert _channel_is_intercepted(client._channel)
371
+ finally:
372
+ client.close()
373
+
374
+
375
+ def test_from_active_cluster_ignores_stale_token_when_auth_mode_not_oidc(
376
+ tmp_path: Path,
377
+ monkeypatch: Any,
378
+ ) -> None:
379
+ """Finding 3: a stale oidc_token.json alongside a non-OIDC gateway must
380
+ NOT cause bearer auth to be attached."""
381
+ _setup_gateway_dir(
382
+ tmp_path,
383
+ monkeypatch,
384
+ # auth_mode omitted (or "mtls", "plaintext") — anything but "oidc".
385
+ oidc_bundle={"access_token": "stale-from-disk"},
386
+ )
387
+ client = SandboxClient.from_active_cluster()
388
+ try:
389
+ # Plain channel, no interceptor wrapper.
390
+ assert not _channel_is_intercepted(client._channel)
391
+ finally:
392
+ client.close()
393
+
394
+
395
+ def test_from_active_cluster_https_oidc_without_mtls_uses_tls_with_system_roots(
396
+ tmp_path: Path,
397
+ monkeypatch: Any,
398
+ ) -> None:
399
+ """Finding 1: https OIDC gateways without mTLS material must still use a
400
+ TLS channel (system roots) — NOT fall back to insecure_channel."""
401
+ _setup_gateway_dir(
402
+ tmp_path,
403
+ monkeypatch,
404
+ endpoint="https://gateway.example:443",
405
+ auth_mode="oidc",
406
+ oidc_bundle={"access_token": "t"},
407
+ )
408
+ client = SandboxClient.from_active_cluster()
409
+ try:
410
+ # The bearer interceptor wraps the channel, so inspect the
411
+ # wrapped channel's class to confirm it's a secure (TLS) channel.
412
+ inner = getattr(client._channel, "_channel", client._channel)
413
+ # gRPC's `grpc.secure_channel` returns a `_Channel` from
414
+ # `grpc._channel`; we can't trivially introspect "secure" vs
415
+ # "insecure" on the wrapper itself. Probe by attempting to
416
+ # extract the connectivity state — both kinds expose it — and
417
+ # rely on a behavioral assertion: an insecure channel against
418
+ # a hostname-only endpoint would have already attached TCP-only
419
+ # subchannels. Easier: verify TlsConfig() was used by checking
420
+ # the SandboxClient endpoint normalized correctly.
421
+ # The most direct assertion is on the client config:
422
+ assert client._endpoint == "gateway.example:443"
423
+ # And the channel must not be insecure.
424
+ assert "InsecureChannelCredentials" not in repr(inner)
425
+ finally:
426
+ client.close()
427
+
428
+
429
+ def test_from_active_cluster_https_ca_only_layout(
430
+ tmp_path: Path,
431
+ monkeypatch: Any,
432
+ ) -> None:
433
+ """Finding 1: a CA-only mtls directory (ca.crt but no tls.crt/tls.key)
434
+ must produce a CA-only TLS channel, not a FileNotFoundError."""
435
+ _setup_gateway_dir(
436
+ tmp_path,
437
+ monkeypatch,
438
+ endpoint="https://gateway.example:443",
439
+ auth_mode="oidc",
440
+ mtls_files={
441
+ "ca.crt": "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n"
442
+ },
443
+ oidc_bundle={"access_token": "t"},
444
+ )
445
+ # Should not raise.
446
+ client = SandboxClient.from_active_cluster()
447
+ try:
448
+ assert client._endpoint == "gateway.example:443"
449
+ finally:
450
+ client.close()
451
+
452
+
453
+ def test_tls_config_rejects_partial_client_identity() -> None:
454
+ """Cert without key (or vice versa) is a misconfiguration."""
455
+ import pytest as _pytest
456
+
457
+ with _pytest.raises(ValueError, match="cert_path and key_path"):
458
+ TlsConfig(cert_path=Path("/x.crt"))
459
+
460
+
461
+ def test_tls_config_allows_empty_for_system_roots() -> None:
462
+ """`TlsConfig()` is the system-roots-trust flavor."""
463
+ cfg = TlsConfig()
464
+ assert cfg.ca_path is None and cfg.cert_path is None and cfg.key_path is None
465
+
466
+
467
+ # ---------------------------------------------------------------------------
468
+ # Provider semantics: per-RPC reload + expiry
469
+ # ---------------------------------------------------------------------------
470
+
471
+
472
+ def test_cluster_bearer_provider_reloads_on_every_call(tmp_path: Path) -> None:
473
+ """The fail-closed (no-refresh) provider re-reads oidc_token.json each
474
+ invocation, so a long-lived SandboxClient picks up CLI rotations
475
+ without reconstruction."""
476
+ gateway_dir = tmp_path
477
+ token_file = gateway_dir / "oidc_token.json"
478
+ token_file.write_text(json.dumps({"access_token": "first"}))
479
+ provider, _ = _make_cluster_bearer_provider(gateway_dir, "g", auto_refresh=False)
480
+
481
+ assert provider() == "first"
482
+ # Simulate `openshell gateway login` writing a new token.
483
+ token_file.write_text(json.dumps({"access_token": "second"}))
484
+ assert provider() == "second"
485
+
486
+
487
+ def test_cluster_bearer_provider_raises_on_expired_token(tmp_path: Path) -> None:
488
+ """Fail-closed provider raises on expiry with a clear re-login hint."""
489
+ gateway_dir = tmp_path
490
+ (gateway_dir / "oidc_token.json").write_text(
491
+ json.dumps({"access_token": "expired", "expires_at": 1})
492
+ )
493
+ provider, _ = _make_cluster_bearer_provider(
494
+ gateway_dir, "stale-gateway", auto_refresh=False
495
+ )
496
+
497
+ import pytest as _pytest
498
+
499
+ with _pytest.raises(SandboxError, match="expired"):
500
+ provider()
501
+
502
+
503
+ def test_cluster_bearer_provider_raises_when_file_missing(tmp_path: Path) -> None:
504
+ provider, _ = _make_cluster_bearer_provider(
505
+ tmp_path / "absent", "g", auto_refresh=False
506
+ )
507
+ import pytest as _pytest
508
+
509
+ with _pytest.raises(SandboxError, match="missing or unreadable"):
510
+ provider()
511
+
512
+
513
+ def test_cluster_bearer_provider_raises_on_missing_access_token(
514
+ tmp_path: Path,
515
+ ) -> None:
516
+ (tmp_path / "oidc_token.json").write_text(json.dumps({"refresh_token": "r"}))
517
+ provider, _ = _make_cluster_bearer_provider(tmp_path, "g", auto_refresh=False)
518
+ import pytest as _pytest
519
+
520
+ with _pytest.raises(SandboxError, match="no access token"):
521
+ provider()
522
+
523
+
524
+ # ---------------------------------------------------------------------------
525
+ # OAuth2 native refresh (_OidcRefresher) — opt-in via auto_refresh=True.
526
+ # ---------------------------------------------------------------------------
527
+
528
+
529
+ def _write_bundle(
530
+ gateway_dir: Path,
531
+ *,
532
+ access_token: str = "fresh",
533
+ refresh_token: str = "r-orig",
534
+ expires_at: int | None = None,
535
+ issuer: str = "https://idp.example/realms/openshell",
536
+ client_id: str = "openshell-cli",
537
+ ) -> None:
538
+ bundle: dict[str, Any] = {
539
+ "access_token": access_token,
540
+ "refresh_token": refresh_token,
541
+ "issuer": issuer,
542
+ "client_id": client_id,
543
+ }
544
+ if expires_at is not None:
545
+ bundle["expires_at"] = expires_at
546
+ (gateway_dir / "oidc_token.json").write_text(json.dumps(bundle))
547
+
548
+
549
+ DEFAULT_ISSUER = "https://idp.example/realms/openshell"
550
+ DEFAULT_TOKEN_ENDPOINT = (
551
+ "https://idp.example/realms/openshell/protocol/openid-connect/token"
552
+ )
553
+
554
+
555
+ def _make_mock_transport(
556
+ *,
557
+ discovery: dict | None = None,
558
+ refresh_responses: list[dict] | None = None,
559
+ discovery_status: int = 200,
560
+ refresh_status: int = 200,
561
+ seen_refresh: list[Any] | None = None,
562
+ seen_discovery: list[Any] | None = None,
563
+ ):
564
+ """Build an httpx.MockTransport that serves OIDC discovery + token
565
+ refresh from an in-memory script.
566
+
567
+ `refresh_responses` is consumed in order across successive POSTs to
568
+ the token endpoint (which lets tests assert refresh-token rotation
569
+ semantics across multiple refreshes).
570
+ """
571
+ import httpx as _httpx
572
+
573
+ refresh_iter = iter(
574
+ refresh_responses or [{"access_token": "refreshed-jwt", "expires_in": 3600}]
575
+ )
576
+
577
+ def handler(request: _httpx.Request) -> _httpx.Response:
578
+ if request.url.path.endswith("/.well-known/openid-configuration"):
579
+ if seen_discovery is not None:
580
+ seen_discovery.append(str(request.url))
581
+ body = discovery or {
582
+ "issuer": DEFAULT_ISSUER,
583
+ "token_endpoint": DEFAULT_TOKEN_ENDPOINT,
584
+ }
585
+ return _httpx.Response(discovery_status, json=body)
586
+ # Anything else is a refresh exchange.
587
+ if seen_refresh is not None:
588
+ seen_refresh.append((str(request.url), bytes(request.content)))
589
+ try:
590
+ body = next(refresh_iter)
591
+ except StopIteration:
592
+ return _httpx.Response(500, json={"error": "test_script_exhausted"})
593
+ return _httpx.Response(refresh_status, json=body)
594
+
595
+ return _httpx.MockTransport(handler)
596
+
597
+
598
+ def _install_mock_transport(refresher: Any, transport: Any) -> None:
599
+ """Swap the refresher's httpx.Client for one bound to a mock transport.
600
+
601
+ We rebuild with `follow_redirects=False` so the redirect-rejection
602
+ test still exercises the real policy.
603
+ """
604
+ import httpx as _httpx
605
+
606
+ refresher._http.close()
607
+ refresher._http = _httpx.Client(transport=transport, follow_redirects=False)
608
+
609
+
610
+ def test_refresher_returns_cached_token_when_fresh(tmp_path: Path) -> None:
611
+ """No refresh round-trip when the cached bundle is still fresh."""
612
+ _write_bundle(tmp_path, expires_at=int(time.time()) + 3600)
613
+ seen: list[Any] = []
614
+ transport = _make_mock_transport(
615
+ seen_discovery=seen,
616
+ seen_refresh=seen,
617
+ )
618
+ r = _OidcRefresher(tmp_path, "g")
619
+ _install_mock_transport(r, transport)
620
+ assert r.current_access_token() == "fresh"
621
+ assert seen == [] # no discovery, no refresh
622
+
623
+
624
+ def test_refresher_picks_up_disk_rotation_before_refreshing(
625
+ tmp_path: Path,
626
+ ) -> None:
627
+ """If the in-memory bundle is stale but the CLI just wrote a fresh one,
628
+ re-read disk instead of hitting the IdP."""
629
+ _write_bundle(tmp_path, access_token="old", expires_at=1)
630
+ seen_refresh: list[Any] = []
631
+ transport = _make_mock_transport(seen_refresh=seen_refresh)
632
+ r = _OidcRefresher(tmp_path, "g", write_back=False)
633
+ _install_mock_transport(r, transport)
634
+ # First call: refresh against IdP — exercise that path first.
635
+ assert r.current_access_token() == "refreshed-jwt"
636
+ assert len(seen_refresh) == 1
637
+
638
+ # Now simulate the CLI writing a fresh bundle. Force the in-memory
639
+ # state to look stale so the disk re-read path triggers.
640
+ _write_bundle(
641
+ tmp_path, access_token="cli-rotated", expires_at=int(time.time()) + 3600
642
+ )
643
+ r._bundle = {
644
+ "access_token": "stale-in-memory",
645
+ "expires_at": 1,
646
+ "refresh_token": "r",
647
+ }
648
+ # Replace the transport with one that asserts on any request.
649
+ import httpx as _httpx
650
+
651
+ def assert_no_calls(_req: _httpx.Request) -> _httpx.Response:
652
+ raise AssertionError("should not refresh — disk was fresh")
653
+
654
+ _install_mock_transport(r, _httpx.MockTransport(assert_no_calls))
655
+ assert r.current_access_token() == "cli-rotated"
656
+
657
+
658
+ def test_refresher_adopts_stale_disk_refresh_token_before_refreshing(
659
+ tmp_path: Path,
660
+ ) -> None:
661
+ """Regression: when both the in-memory and on-disk access tokens are
662
+ stale but another process rotated the on-disk refresh_token, refresh
663
+ with the disk refresh_token (r2), not the invalidated in-memory one (r1).
664
+
665
+ Without this, a rotating IdP (Keycloak with rotation, Entra strict) would
666
+ invalid_grant because process A still holds the pre-rotation r1.
667
+ """
668
+ # Disk holds a rotated-but-stale bundle (r2) written by another process.
669
+ # Its access token was minted more recently than ours (later expiry,
670
+ # though still inside the grace window), so disk carries the newer
671
+ # refresh_token even though both are due for refresh.
672
+ disk_exp = int(time.time()) + 5
673
+ _write_bundle(
674
+ tmp_path, access_token="disk-old", expires_at=disk_exp, refresh_token="r2"
675
+ )
676
+ seen: list[Any] = []
677
+ transport = _make_mock_transport(
678
+ refresh_responses=[
679
+ {"access_token": "a-new", "refresh_token": "r3", "expires_in": 3600},
680
+ ],
681
+ seen_refresh=seen,
682
+ )
683
+ r = _OidcRefresher(tmp_path, "g", write_back=False)
684
+ _install_mock_transport(r, transport)
685
+ # Seed older stale in-memory state holding the pre-rotation token r1.
686
+ r._bundle = {
687
+ "access_token": "mem-old",
688
+ "expires_at": 1,
689
+ "refresh_token": "r1",
690
+ "issuer": DEFAULT_ISSUER,
691
+ }
692
+
693
+ assert r.current_access_token() == "a-new"
694
+ # The refresh POST must carry the disk's r2, never the stale r1.
695
+ _, body = seen[-1]
696
+ assert b"refresh_token=r2" in body
697
+ assert b"refresh_token=r1" not in body
698
+
699
+
700
+ def test_refresher_resets_token_endpoint_when_disk_issuer_changes(
701
+ tmp_path: Path,
702
+ ) -> None:
703
+ """When the adopted disk bundle has a different issuer than the cached
704
+ one, the previously discovered token endpoint must be re-discovered
705
+ against the new issuer rather than reused."""
706
+ new_issuer = "https://other-idp.example/realms/openshell"
707
+ # Disk is newer than the in-memory bundle (later expiry) so it is
708
+ # adopted, but still stale so a refresh — and thus re-discovery — runs.
709
+ _write_bundle(
710
+ tmp_path,
711
+ access_token="disk-old",
712
+ expires_at=int(time.time()) + 5,
713
+ refresh_token="r2",
714
+ issuer=new_issuer,
715
+ )
716
+ seen_discovery: list[Any] = []
717
+ transport = _make_mock_transport(
718
+ discovery={
719
+ "issuer": new_issuer,
720
+ "token_endpoint": f"{new_issuer}/protocol/openid-connect/token",
721
+ },
722
+ seen_discovery=seen_discovery,
723
+ )
724
+ r = _OidcRefresher(tmp_path, "g", write_back=False)
725
+ _install_mock_transport(r, transport)
726
+ # Pretend we already discovered an endpoint for the OLD issuer.
727
+ r._token_endpoint = f"{DEFAULT_ISSUER}/protocol/openid-connect/token"
728
+ r._bundle = {
729
+ "access_token": "mem-old",
730
+ "expires_at": 1,
731
+ "refresh_token": "r1",
732
+ "issuer": DEFAULT_ISSUER,
733
+ }
734
+
735
+ r.current_access_token()
736
+ # Re-discovery happened against the new issuer.
737
+ assert len(seen_discovery) == 1
738
+ assert new_issuer in seen_discovery[0]
739
+
740
+
741
+ def test_refresher_recovers_from_invalid_grant_after_peer_rotation(
742
+ tmp_path: Path,
743
+ ) -> None:
744
+ """If our refresh POST loses a rotation race (peer already rotated r1→r2
745
+ and the IdP rejects our r1 with invalid_grant), re-read disk, pick up the
746
+ peer's r2, and retry — succeeding without forcing a re-authenticate."""
747
+ import httpx as _httpx
748
+
749
+ _write_bundle(tmp_path, access_token="old", expires_at=1, refresh_token="r1")
750
+ posts: list[bytes] = []
751
+
752
+ def handler(request: _httpx.Request) -> _httpx.Response:
753
+ if request.url.path.endswith("/.well-known/openid-configuration"):
754
+ return _httpx.Response(
755
+ 200,
756
+ json={
757
+ "issuer": DEFAULT_ISSUER,
758
+ "token_endpoint": DEFAULT_TOKEN_ENDPOINT,
759
+ },
760
+ )
761
+ body = bytes(request.content)
762
+ posts.append(body)
763
+ if b"refresh_token=r1" in body:
764
+ # Simulate the peer: it already rotated r1→r2 and wrote r2 to
765
+ # disk, so the IdP rejects our now-stale r1.
766
+ _write_bundle(
767
+ tmp_path,
768
+ access_token="peer",
769
+ expires_at=int(time.time()) + 5,
770
+ refresh_token="r2",
771
+ )
772
+ return _httpx.Response(400, json={"error": "invalid_grant"})
773
+ # The retry carries the peer's r2 and succeeds.
774
+ return _httpx.Response(
775
+ 200,
776
+ json={"access_token": "a-final", "refresh_token": "r3", "expires_in": 3600},
777
+ )
778
+
779
+ r = _OidcRefresher(tmp_path, "g", write_back=False)
780
+ _install_mock_transport(r, _httpx.MockTransport(handler))
781
+
782
+ assert r.current_access_token() == "a-final"
783
+ # Exactly two refresh POSTs: the failed r1 then the recovered r2.
784
+ assert any(b"refresh_token=r1" in p for p in posts)
785
+ assert any(b"refresh_token=r2" in p for p in posts)
786
+ assert len(posts) == 2
787
+
788
+
789
+ def test_refresher_reraises_invalid_grant_without_peer_rotation(
790
+ tmp_path: Path,
791
+ ) -> None:
792
+ """invalid_grant with no peer rotation (disk still holds our refresh_token)
793
+ is a genuine dead token — surface the re-authenticate hint and do NOT loop
794
+ on the retry path."""
795
+ import httpx as _httpx
796
+ import pytest as _pytest
797
+
798
+ _write_bundle(tmp_path, access_token="old", expires_at=1, refresh_token="r1")
799
+ posts: list[bytes] = []
800
+
801
+ def handler(request: _httpx.Request) -> _httpx.Response:
802
+ if request.url.path.endswith("/.well-known/openid-configuration"):
803
+ return _httpx.Response(
804
+ 200,
805
+ json={
806
+ "issuer": DEFAULT_ISSUER,
807
+ "token_endpoint": DEFAULT_TOKEN_ENDPOINT,
808
+ },
809
+ )
810
+ posts.append(bytes(request.content))
811
+ return _httpx.Response(400, json={"error": "invalid_grant"})
812
+
813
+ r = _OidcRefresher(tmp_path, "g", write_back=False)
814
+ _install_mock_transport(r, _httpx.MockTransport(handler))
815
+
816
+ with _pytest.raises(SandboxError, match="Re-authenticate"):
817
+ r.current_access_token()
818
+ # Only one POST — disk offered no new refresh_token, so no retry.
819
+ assert len(posts) == 1
820
+
821
+
822
+ def test_refresher_exchanges_refresh_token_when_stale(tmp_path: Path) -> None:
823
+ """When both memory and disk are stale, do the OAuth2 refresh exchange."""
824
+ _write_bundle(tmp_path, access_token="old", expires_at=1)
825
+ seen_refresh: list[Any] = []
826
+ transport = _make_mock_transport(seen_refresh=seen_refresh)
827
+ r = _OidcRefresher(tmp_path, "g", write_back=False)
828
+ _install_mock_transport(r, transport)
829
+
830
+ assert r.current_access_token() == "refreshed-jwt"
831
+ # The refresh request should be a POST to the discovered token endpoint
832
+ # with grant_type=refresh_token in the body.
833
+ url, body = seen_refresh[-1]
834
+ assert url.endswith("/protocol/openid-connect/token")
835
+ assert b"grant_type=refresh_token" in body
836
+ assert b"refresh_token=r-orig" in body
837
+
838
+
839
+ def test_refresher_writes_back_when_enabled(tmp_path: Path) -> None:
840
+ """write_back=True persists rotated bundle to disk atomically with 0600."""
841
+ _write_bundle(tmp_path, access_token="old", expires_at=1)
842
+ transport = _make_mock_transport(
843
+ refresh_responses=[
844
+ {
845
+ "access_token": "rotated",
846
+ "refresh_token": "r-new",
847
+ "expires_in": 3600,
848
+ }
849
+ ],
850
+ )
851
+ r = _OidcRefresher(tmp_path, "g", write_back=True)
852
+ _install_mock_transport(r, transport)
853
+
854
+ assert r.current_access_token() == "rotated"
855
+ on_disk = json.loads((tmp_path / "oidc_token.json").read_text())
856
+ assert on_disk["access_token"] == "rotated"
857
+ assert on_disk["refresh_token"] == "r-new"
858
+ # Mode should be 0600 on POSIX.
859
+ if os.name == "posix":
860
+ mode = (tmp_path / "oidc_token.json").stat().st_mode & 0o777
861
+ assert mode == 0o600, f"got {oct(mode)}"
862
+
863
+
864
+ def test_refresher_write_back_is_default(tmp_path: Path) -> None:
865
+ """Default IS write_back=True so refresh-token rotation propagates to
866
+ disk for other processes (Rust CLI, TUI, second Python client)."""
867
+ _write_bundle(tmp_path, access_token="old", expires_at=1)
868
+ transport = _make_mock_transport(
869
+ refresh_responses=[
870
+ {
871
+ "access_token": "rotated",
872
+ "refresh_token": "r-new",
873
+ "expires_in": 3600,
874
+ }
875
+ ],
876
+ )
877
+ r = _OidcRefresher(tmp_path, "g") # default write_back=True
878
+ _install_mock_transport(r, transport)
879
+
880
+ r.current_access_token()
881
+ on_disk = json.loads((tmp_path / "oidc_token.json").read_text())
882
+ assert on_disk["access_token"] == "rotated"
883
+ assert on_disk["refresh_token"] == "r-new"
884
+
885
+
886
+ def test_refresher_honors_refresh_token_rotation(tmp_path: Path) -> None:
887
+ """When the IdP returns a new refresh_token, use it for subsequent refreshes
888
+ instead of the original. Some IdPs (Keycloak with rotation enabled, Entra
889
+ in strict mode) reissue and invalidate the old refresh_token."""
890
+ _write_bundle(tmp_path, access_token="old", expires_at=1, refresh_token="r1")
891
+ seen: list[Any] = []
892
+ transport = _make_mock_transport(
893
+ refresh_responses=[
894
+ {"access_token": "a2", "refresh_token": "r2", "expires_in": 1},
895
+ {"access_token": "a3", "refresh_token": "r3", "expires_in": 3600},
896
+ ],
897
+ seen_refresh=seen,
898
+ )
899
+ r = _OidcRefresher(tmp_path, "g", write_back=False)
900
+ _install_mock_transport(r, transport)
901
+
902
+ assert r.current_access_token() == "a2"
903
+ # Second call: a2 is also expired (expires_in=1), so we refresh again,
904
+ # this time the request body should carry the rotated r2 (not r1).
905
+ assert r.current_access_token() == "a3"
906
+ assert b"refresh_token=r1" in seen[0][1]
907
+ assert b"refresh_token=r2" in seen[1][1]
908
+
909
+
910
+ def test_refresher_second_process_can_refresh_after_rotation(
911
+ tmp_path: Path,
912
+ ) -> None:
913
+ """Two-process simulation (Finding #2): process A refreshes r1→r2 with
914
+ write_back=True (default). Process B starts from disk and successfully
915
+ uses r2 — proving the rotation reached the shared cache."""
916
+ _write_bundle(tmp_path, access_token="old", expires_at=1, refresh_token="r1")
917
+ transport_a = _make_mock_transport(
918
+ refresh_responses=[
919
+ {"access_token": "a2", "refresh_token": "r2", "expires_in": 1},
920
+ ],
921
+ )
922
+ process_a = _OidcRefresher(tmp_path, "g") # write_back=True (default)
923
+ _install_mock_transport(process_a, transport_a)
924
+ assert process_a.current_access_token() == "a2"
925
+
926
+ # Process B picks up the cache fresh. The IdP now expects r2; if the
927
+ # disk still held r1, this would fail at the IdP. With write_back the
928
+ # disk has r2, and B refreshes successfully.
929
+ seen_b: list[Any] = []
930
+ transport_b = _make_mock_transport(
931
+ refresh_responses=[
932
+ {"access_token": "a3", "refresh_token": "r3", "expires_in": 3600},
933
+ ],
934
+ seen_refresh=seen_b,
935
+ )
936
+ process_b = _OidcRefresher(tmp_path, "g")
937
+ _install_mock_transport(process_b, transport_b)
938
+ assert process_b.current_access_token() == "a3"
939
+ # Process B should have presented r2, not r1.
940
+ assert b"refresh_token=r2" in seen_b[0][1]
941
+
942
+
943
+ def test_refresher_concurrent_calls_share_one_refresh(tmp_path: Path) -> None:
944
+ """N threads racing on a stale token should produce exactly one
945
+ refresh exchange (not N). Mirrors google-auth's RefreshThreadManager
946
+ coordination."""
947
+ _write_bundle(tmp_path, access_token="old", expires_at=1)
948
+ refresh_count = [0]
949
+ barrier = threading.Barrier(8)
950
+
951
+ import httpx as _httpx
952
+
953
+ def handler(request: _httpx.Request) -> _httpx.Response:
954
+ if request.url.path.endswith("/.well-known/openid-configuration"):
955
+ return _httpx.Response(
956
+ 200,
957
+ json={
958
+ "issuer": DEFAULT_ISSUER,
959
+ "token_endpoint": DEFAULT_TOKEN_ENDPOINT,
960
+ },
961
+ )
962
+ refresh_count[0] += 1
963
+ return _httpx.Response(
964
+ 200,
965
+ json={
966
+ "access_token": "refreshed",
967
+ "expires_in": 3600,
968
+ },
969
+ )
970
+
971
+ r = _OidcRefresher(tmp_path, "g", write_back=False)
972
+ _install_mock_transport(r, _httpx.MockTransport(handler))
973
+
974
+ results: list[str] = []
975
+ errors: list[BaseException] = []
976
+
977
+ def worker() -> None:
978
+ try:
979
+ barrier.wait()
980
+ results.append(r.current_access_token())
981
+ except BaseException as e:
982
+ errors.append(e)
983
+
984
+ threads = [threading.Thread(target=worker) for _ in range(8)]
985
+ for t in threads:
986
+ t.start()
987
+ for t in threads:
988
+ t.join()
989
+
990
+ assert not errors, errors
991
+ assert results == ["refreshed"] * 8
992
+ # One refresh exchange, regardless of thread count.
993
+ assert refresh_count[0] == 1, f"expected one refresh, got {refresh_count[0]}"
994
+
995
+
996
+ def test_refresher_surfaces_idp_failure_as_sandbox_error(
997
+ tmp_path: Path,
998
+ ) -> None:
999
+ """A non-2xx from the token endpoint becomes a SandboxError."""
1000
+ _write_bundle(tmp_path, access_token="old", expires_at=1)
1001
+ transport = _make_mock_transport(
1002
+ refresh_status=400,
1003
+ refresh_responses=[
1004
+ {
1005
+ "error": "invalid_grant",
1006
+ "error_description": "Token is not active",
1007
+ }
1008
+ ],
1009
+ )
1010
+ r = _OidcRefresher(tmp_path, "g", write_back=False)
1011
+ _install_mock_transport(r, transport)
1012
+
1013
+ import pytest as _pytest
1014
+
1015
+ with _pytest.raises(SandboxError, match="refresh failed"):
1016
+ r.current_access_token()
1017
+
1018
+
1019
+ def test_refresher_rejects_issuer_mismatch_in_discovery(tmp_path: Path) -> None:
1020
+ """Finding #1 (Critical): a discovery doc claiming a different issuer
1021
+ must be rejected. Without this, a malicious or misdirected discovery
1022
+ response could steer the refresh_token POST to an attacker-
1023
+ controlled endpoint."""
1024
+ _write_bundle(tmp_path, access_token="old", expires_at=1)
1025
+ transport = _make_mock_transport(
1026
+ discovery={
1027
+ "issuer": "https://evil.example/realms/openshell",
1028
+ "token_endpoint": "https://evil.example/token",
1029
+ },
1030
+ )
1031
+ r = _OidcRefresher(tmp_path, "g", write_back=False)
1032
+ _install_mock_transport(r, transport)
1033
+
1034
+ import pytest as _pytest
1035
+
1036
+ with _pytest.raises(SandboxError, match="issuer mismatch"):
1037
+ r.current_access_token()
1038
+
1039
+
1040
+ def test_refresher_rejects_redirect_during_discovery(tmp_path: Path) -> None:
1041
+ """Finding #1 (Critical): a 3xx during OIDC discovery must NOT be
1042
+ auto-followed — that would let a network attacker steer the SDK to
1043
+ an arbitrary token_endpoint URL. The Rust CLI sets
1044
+ `Policy::none()`; we set httpx's `follow_redirects=False`."""
1045
+ _write_bundle(tmp_path, access_token="old", expires_at=1)
1046
+ transport = _make_mock_transport(
1047
+ discovery_status=302,
1048
+ discovery={"location": "https://evil.example/...."},
1049
+ )
1050
+ r = _OidcRefresher(tmp_path, "g", write_back=False)
1051
+ _install_mock_transport(r, transport)
1052
+
1053
+ import pytest as _pytest
1054
+
1055
+ with _pytest.raises(SandboxError, match=r"discovery failed.*HTTP 302"):
1056
+ r.current_access_token()
1057
+
1058
+
1059
+ def test_refresher_insecure_disables_tls_verification() -> None:
1060
+ """Finding #3: insecure=True propagates to httpx as verify=False so
1061
+ self-signed OIDC issuers work the same way they do in the Rust CLI's
1062
+ `--insecure` plumbing."""
1063
+ import pathlib
1064
+
1065
+ r = _OidcRefresher(
1066
+ pathlib.Path("/tmp/does-not-exist"),
1067
+ "g",
1068
+ insecure=True,
1069
+ )
1070
+ try:
1071
+ # httpx exposes the configured verify policy on the client; we
1072
+ # don't depend on its precise type, just on it being a falsy
1073
+ # value (the default is True / an SSLContext).
1074
+ # In recent httpx versions this lives on the underlying transport.
1075
+ # The simplest stable check is: an insecure client allows
1076
+ # connect to self-signed hosts; the rest of the contract is
1077
+ # httpx's responsibility.
1078
+ # Verify the client's verify attribute (whether top-level or via
1079
+ # transport) is False.
1080
+ assert _client_verify_is_disabled(r._http)
1081
+ finally:
1082
+ r.close()
1083
+
1084
+
1085
+ def _client_verify_is_disabled(client: Any) -> bool:
1086
+ """Inspect an httpx.Client for verify=False. httpx surfaces verify
1087
+ either on the client directly (older) or via the default transport
1088
+ (newer)."""
1089
+ if getattr(client, "verify", None) is False:
1090
+ return True
1091
+ transport = getattr(client, "_transport", None)
1092
+ if transport is None:
1093
+ return False
1094
+ # httpx's default HTTPTransport wraps an SSL context or a bool.
1095
+ pool = getattr(transport, "_pool", None)
1096
+ if pool is not None:
1097
+ ssl_context = getattr(pool, "_ssl_context", None)
1098
+ # When verify=False, httpx builds a context without verification.
1099
+ if ssl_context is not None:
1100
+ import ssl
1101
+
1102
+ return ssl_context.verify_mode == ssl.CERT_NONE
1103
+ # Fallback: check for any internal `_verify` attribute set to False.
1104
+ return getattr(transport, "_verify", None) is False
1105
+
1106
+
1107
+ def test_refresher_raises_when_bundle_has_no_refresh_token(
1108
+ tmp_path: Path,
1109
+ ) -> None:
1110
+ """Without a refresh_token (e.g. client_credentials grant — different
1111
+ code path entirely), refresh has nothing to exchange and surfaces a
1112
+ clear error."""
1113
+ (tmp_path / "oidc_token.json").write_text(
1114
+ json.dumps({"access_token": "old", "expires_at": 1, "issuer": "x"})
1115
+ )
1116
+ r = _OidcRefresher(tmp_path, "g", write_back=False)
1117
+ import pytest as _pytest
1118
+
1119
+ with _pytest.raises(SandboxError, match="no refresh token"):
1120
+ r.current_access_token()
1121
+
1122
+
1123
+ # ---------------------------------------------------------------------------
1124
+ # auth_mode gate: only metadata.json["auth_mode"] == "oidc" wires the bearer
1125
+ # interceptor. A stray oidc_token.json next to a non-OIDC gateway must not
1126
+ # trigger it.
1127
+ # ---------------------------------------------------------------------------
1128
+
1129
+
1130
+ def test_mtls_only_from_active_cluster_skips_bearer_interceptor(
1131
+ tmp_path: Path,
1132
+ monkeypatch: Any,
1133
+ ) -> None:
1134
+ """from_active_cluster against an mTLS-only gateway (no auth_mode set)
1135
+ does not wrap the channel with a bearer interceptor, even if a stale
1136
+ oidc_token.json is present in the gateway directory."""
1137
+ gateway_name = "mtls-only"
1138
+ gateway_dir = tmp_path / "openshell" / "gateways" / gateway_name
1139
+ mtls_dir = gateway_dir / "mtls"
1140
+ mtls_dir.mkdir(parents=True)
1141
+ (tmp_path / "openshell" / "active_gateway").write_text(gateway_name)
1142
+ # No auth_mode field — the chart-default path.
1143
+ (gateway_dir / "metadata.json").write_text(
1144
+ json.dumps({"gateway_endpoint": "https://127.0.0.1:8443"})
1145
+ )
1146
+ for f in ("ca.crt", "tls.crt", "tls.key"):
1147
+ (mtls_dir / f).write_text(f"-----BEGIN {f}-----\n-----END {f}-----\n")
1148
+ # Stray oidc_token.json — proving the auth_mode gate (and not the
1149
+ # file's presence) is what would trigger the refresher.
1150
+ (gateway_dir / "oidc_token.json").write_text(json.dumps({"access_token": "stale"}))
1151
+
1152
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
1153
+ monkeypatch.delenv("OPENSHELL_GATEWAY", raising=False)
1154
+
1155
+ client = SandboxClient.from_active_cluster()
1156
+ try:
1157
+ # No bearer interceptor wraps the channel.
1158
+ assert not type(client._channel).__module__.endswith("interceptor")
1159
+ finally:
1160
+ client.close()
1161
+
1162
+
1163
+ # ---------------------------------------------------------------------------
1164
+ # Lifecycle plumbing: close() releases refresher resources, concurrent
1165
+ # write-back doesn't trample.
1166
+ # ---------------------------------------------------------------------------
1167
+
1168
+
1169
+ def test_sandbox_client_close_invokes_bearer_close() -> None:
1170
+ """`SandboxClient.close()` must invoke the `_bearer_close` callable
1171
+ wired by `from_active_cluster`. Otherwise the refresher's
1172
+ httpx.Client leaks sockets/FDs until GC runs."""
1173
+ closed = [0]
1174
+
1175
+ def bearer_close() -> None:
1176
+ closed[0] += 1
1177
+
1178
+ client = SandboxClient(
1179
+ "localhost:8080",
1180
+ bearer_token="tok",
1181
+ _bearer_close=bearer_close,
1182
+ )
1183
+ client.close()
1184
+ assert closed[0] == 1
1185
+ # close() is idempotent — re-invoking does not double-call.
1186
+ client.close()
1187
+ assert closed[0] == 1
1188
+
1189
+
1190
+ def test_sandbox_client_close_releases_refresher_http_client(
1191
+ tmp_path: Path,
1192
+ monkeypatch: Any,
1193
+ ) -> None:
1194
+ """End-to-end check: an OIDC-backed client built by
1195
+ from_active_cluster() must close the refresher's httpx.Client when
1196
+ the SandboxClient is closed."""
1197
+ gateway_name = "oidc-gw"
1198
+ gateway_dir = tmp_path / "openshell" / "gateways" / gateway_name
1199
+ mtls_dir = gateway_dir / "mtls"
1200
+ mtls_dir.mkdir(parents=True)
1201
+ (tmp_path / "openshell" / "active_gateway").write_text(gateway_name)
1202
+ (gateway_dir / "metadata.json").write_text(
1203
+ json.dumps(
1204
+ {
1205
+ "gateway_endpoint": "https://127.0.0.1:8443",
1206
+ "auth_mode": "oidc",
1207
+ }
1208
+ )
1209
+ )
1210
+ for f in ("ca.crt", "tls.crt", "tls.key"):
1211
+ (mtls_dir / f).write_text(f"-----BEGIN {f}-----\n-----END {f}-----\n")
1212
+ _write_bundle(gateway_dir, expires_at=int(time.time()) + 3600)
1213
+
1214
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
1215
+ monkeypatch.delenv("OPENSHELL_GATEWAY", raising=False)
1216
+
1217
+ # Capture the httpx.Client instance created inside the refresher by
1218
+ # monkey-patching _OidcRefresher to record it on construction.
1219
+ created: list[Any] = []
1220
+ real_init = _OidcRefresher.__init__
1221
+
1222
+ def recording_init(self: Any, *args: Any, **kwargs: Any) -> None:
1223
+ real_init(self, *args, **kwargs)
1224
+ created.append(self._http)
1225
+
1226
+ monkeypatch.setattr(_OidcRefresher, "__init__", recording_init)
1227
+
1228
+ client = SandboxClient.from_active_cluster()
1229
+ assert len(created) == 1
1230
+ http_client = created[0]
1231
+ assert not http_client.is_closed
1232
+ client.close()
1233
+ assert http_client.is_closed
1234
+
1235
+
1236
+ def test_refresher_concurrent_write_back_does_not_trample(tmp_path: Path) -> None:
1237
+ """Two writers calling `_write_to_disk` concurrently must each use
1238
+ their own tempfile (PID+random) and not corrupt each other's content.
1239
+ The final file must be valid JSON from exactly one of the writers,
1240
+ and no orphaned `.oidc_token.<rand>.tmp` files should remain."""
1241
+ _write_bundle(tmp_path, expires_at=int(time.time()) + 3600)
1242
+ r = _OidcRefresher(tmp_path, "g", write_back=False)
1243
+ try:
1244
+ N = 16
1245
+ barrier = threading.Barrier(N)
1246
+ errors: list[BaseException] = []
1247
+
1248
+ def writer(idx: int) -> None:
1249
+ try:
1250
+ barrier.wait()
1251
+ r._write_to_disk(
1252
+ {
1253
+ "access_token": f"a-{idx}",
1254
+ "refresh_token": f"r-{idx}",
1255
+ "expires_at": 1_700_000_000 + idx,
1256
+ "issuer": DEFAULT_ISSUER,
1257
+ "client_id": "openshell-cli",
1258
+ }
1259
+ )
1260
+ except BaseException as e:
1261
+ errors.append(e)
1262
+
1263
+ threads = [threading.Thread(target=writer, args=(i,)) for i in range(N)]
1264
+ for t in threads:
1265
+ t.start()
1266
+ for t in threads:
1267
+ t.join()
1268
+
1269
+ assert not errors, errors
1270
+
1271
+ # Final file is valid JSON from one of the writers (race winner).
1272
+ final = json.loads((tmp_path / "oidc_token.json").read_text())
1273
+ assert final["access_token"].startswith("a-")
1274
+ assert final["refresh_token"].startswith("r-")
1275
+
1276
+ # No orphan tmp files left behind. mkstemp uses a random suffix
1277
+ # so each writer's tmp is distinct; the cleanup path on the
1278
+ # success branch is `.replace()`, which atomically moves the
1279
+ # tmp to the final path — no straggler tmp should remain.
1280
+ leftovers = sorted(tmp_path.glob(".oidc_token.*.tmp"))
1281
+ assert leftovers == [], f"orphan tmp files: {leftovers}"
1282
+ finally:
1283
+ r.close()
1284
+
1285
+
1286
+ class _WindowsPermissionError(PermissionError):
1287
+ winerror: int
1288
+
1289
+
1290
+ def test_atomic_replace_retries_windows_sharing_violations(
1291
+ tmp_path: Path, monkeypatch: Any
1292
+ ) -> None:
1293
+ source = tmp_path / "source"
1294
+ destination = tmp_path / "destination"
1295
+ source.write_text("new")
1296
+ destination.write_text("old")
1297
+ attempts = 0
1298
+ delays: list[float] = []
1299
+ real_replace = Path.replace
1300
+
1301
+ def replace(path: Path, target: Path) -> Path:
1302
+ nonlocal attempts
1303
+ attempts += 1
1304
+ if attempts < 3:
1305
+ error = _WindowsPermissionError("destination is busy")
1306
+ error.winerror = 32
1307
+ raise error
1308
+ return real_replace(path, target)
1309
+
1310
+ monkeypatch.setattr(sandbox_module, "_IS_WINDOWS", True)
1311
+ monkeypatch.setattr(Path, "replace", replace)
1312
+ monkeypatch.setattr(time, "sleep", delays.append)
1313
+
1314
+ _atomic_replace(source, destination)
1315
+
1316
+ assert attempts == 3
1317
+ assert delays == [0.005, 0.01]
1318
+ assert destination.read_text() == "new"
1319
+
1320
+
1321
+ def test_atomic_replace_does_not_retry_permanent_windows_errors(
1322
+ tmp_path: Path, monkeypatch: Any
1323
+ ) -> None:
1324
+ source = tmp_path / "source"
1325
+ destination = tmp_path / "destination"
1326
+ source.write_text("new")
1327
+ attempts = 0
1328
+
1329
+ def replace(_path: Path, _target: Path) -> Path:
1330
+ nonlocal attempts
1331
+ attempts += 1
1332
+ error = _WindowsPermissionError("access denied")
1333
+ error.winerror = 13
1334
+ raise error
1335
+
1336
+ monkeypatch.setattr(sandbox_module, "_IS_WINDOWS", True)
1337
+ monkeypatch.setattr(Path, "replace", replace)
1338
+
1339
+ with pytest.raises(PermissionError, match="access denied"):
1340
+ _atomic_replace(source, destination)
1341
+
1342
+ assert attempts == 1
1343
+
1344
+
1345
+ def test_sandbox_wrapper_forwards_auth_kwargs_to_from_active_cluster(
1346
+ monkeypatch: Any,
1347
+ ) -> None:
1348
+ """The high-level `Sandbox` context manager must pass auto_refresh,
1349
+ write_back, and insecure through to SandboxClient.from_active_cluster
1350
+ so callers using the wrapper get parity with SandboxClient for
1351
+ OIDC-protected gateways."""
1352
+ captured: dict[str, Any] = {}
1353
+
1354
+ class _Sentinel(Exception):
1355
+ pass
1356
+
1357
+ def fake_from_active_cluster(**kwargs: Any) -> Any:
1358
+ captured.update(kwargs)
1359
+ # Short-circuit the rest of __enter__ (which would try to create
1360
+ # a session against a real gateway). The kwargs we care about
1361
+ # have already been recorded.
1362
+ raise _Sentinel
1363
+
1364
+ monkeypatch.setattr(
1365
+ SandboxClient, "from_active_cluster", staticmethod(fake_from_active_cluster)
1366
+ )
1367
+
1368
+ sandbox = Sandbox(
1369
+ workspace="default",
1370
+ cluster="my-gw",
1371
+ timeout=42.0,
1372
+ auto_refresh=False,
1373
+ write_back=False,
1374
+ insecure=True,
1375
+ )
1376
+ import pytest as _pytest
1377
+
1378
+ with _pytest.raises(_Sentinel):
1379
+ sandbox.__enter__()
1380
+
1381
+ assert captured["cluster"] == "my-gw"
1382
+ assert captured["timeout"] == 42.0
1383
+ assert captured["auto_refresh"] is False
1384
+ assert captured["write_back"] is False
1385
+ assert captured["insecure"] is True
1386
+
1387
+
1388
+ def test_sandbox_wrapper_defaults_match_from_active_cluster(
1389
+ monkeypatch: Any,
1390
+ ) -> None:
1391
+ """Sandbox(...) with no auth kwargs forwards the same defaults
1392
+ (auto_refresh=True, write_back=True, insecure=False) that
1393
+ SandboxClient.from_active_cluster uses, so the wrapper doesn't
1394
+ silently weaken the security posture."""
1395
+ captured: dict[str, Any] = {}
1396
+
1397
+ class _Sentinel(Exception):
1398
+ pass
1399
+
1400
+ def fake_from_active_cluster(**kwargs: Any) -> Any:
1401
+ captured.update(kwargs)
1402
+ raise _Sentinel
1403
+
1404
+ monkeypatch.setattr(
1405
+ SandboxClient, "from_active_cluster", staticmethod(fake_from_active_cluster)
1406
+ )
1407
+
1408
+ import pytest as _pytest
1409
+
1410
+ with _pytest.raises(_Sentinel):
1411
+ Sandbox(workspace="default").__enter__()
1412
+
1413
+ assert captured["auto_refresh"] is True
1414
+ assert captured["write_back"] is True
1415
+ assert captured["insecure"] is False
1416
+
1417
+
1418
+ def test_inference_set_route_forwards_workspace_and_no_verify() -> None:
1419
+ stub = _FakeInferenceStub()
1420
+ client = cast("InferenceRouteClient", object.__new__(InferenceRouteClient))
1421
+ client._timeout = 30.0
1422
+ client._stub = cast("Any", stub)
1423
+
1424
+ client.set_route(
1425
+ workspace="production",
1426
+ provider_name="openai-dev",
1427
+ model_id="gpt-4.1",
1428
+ no_verify=True,
1429
+ )
1430
+
1431
+ assert stub.set_request is not None
1432
+ assert stub.set_request.no_verify is True
1433
+ assert stub.set_request.workspace == "production"
1434
+
1435
+
1436
+ def test_inference_get_route_forwards_workspace() -> None:
1437
+ stub = _FakeInferenceStub()
1438
+ client = cast("InferenceRouteClient", object.__new__(InferenceRouteClient))
1439
+ client._timeout = 30.0
1440
+ client._stub = cast("Any", stub)
1441
+
1442
+ config = client.get_route(workspace="staging")
1443
+
1444
+ assert stub.get_request is not None
1445
+ assert stub.get_request.workspace == "staging"
1446
+ assert config.provider_name == "openai-dev"
1447
+ assert config.model_id == "gpt-4.1"
1448
+ assert config.version == 2
1449
+
1450
+
1451
+ # ---------------------------------------------------------------------------
1452
+ # Encoding regression tests (utf-8 explicit on all config file reads/writes)
1453
+ # ---------------------------------------------------------------------------
1454
+
1455
+
1456
+ def test_read_oidc_token_bundle_parses_non_ascii_utf8(tmp_path: Path) -> None:
1457
+ gateway_dir = tmp_path / "gw"
1458
+ gateway_dir.mkdir()
1459
+ payload = {"refresh_token": "tok", "issuer": "https://example.com/é"}
1460
+ (gateway_dir / "oidc_token.json").write_bytes(
1461
+ json.dumps(payload, ensure_ascii=False).encode("utf-8")
1462
+ )
1463
+ result = _read_oidc_token_bundle(gateway_dir)
1464
+ assert result == payload
1465
+
1466
+
1467
+ def test_read_oidc_token_bundle_returns_none_on_corrupt_bytes(tmp_path: Path) -> None:
1468
+ gateway_dir = tmp_path / "gw"
1469
+ gateway_dir.mkdir()
1470
+ (gateway_dir / "oidc_token.json").write_bytes(b"\xff\xfe not utf-8")
1471
+ assert _read_oidc_token_bundle(gateway_dir) is None
1472
+
1473
+
1474
+ def test_load_cluster_bearer_token_handles_non_ascii_utf8_oidc(tmp_path: Path) -> None:
1475
+ gateway_dir = tmp_path / "gw"
1476
+ gateway_dir.mkdir()
1477
+ bundle = {
1478
+ "access_token": "accéss",
1479
+ "refresh_token": "ref",
1480
+ "expiry": "2099-01-01T00:00:00Z",
1481
+ "issuer": "https://example.com",
1482
+ "client_id": "c",
1483
+ "client_secret": "s",
1484
+ }
1485
+ (gateway_dir / "oidc_token.json").write_bytes(
1486
+ json.dumps(bundle, ensure_ascii=False).encode("utf-8")
1487
+ )
1488
+ token = _load_cluster_bearer_token(gateway_dir)
1489
+ assert token == "accéss"
1490
+
1491
+
1492
+ def test_from_active_cluster_reads_utf8_bytes_from_active_gateway_and_metadata(
1493
+ tmp_path: Path,
1494
+ monkeypatch: Any,
1495
+ ) -> None:
1496
+ gateway_name = "gw-é"
1497
+ gateway_dir = tmp_path / "openshell" / "gateways" / gateway_name
1498
+ gateway_dir.mkdir(parents=True)
1499
+ (tmp_path / "openshell" / "active_gateway").write_bytes(
1500
+ gateway_name.encode("utf-8")
1501
+ )
1502
+ meta = {"gateway_endpoint": "http://tést.example:8080"}
1503
+ (gateway_dir / "metadata.json").write_bytes(
1504
+ json.dumps(meta, ensure_ascii=False).encode("utf-8")
1505
+ )
1506
+
1507
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
1508
+ monkeypatch.delenv("OPENSHELL_GATEWAY", raising=False)
1509
+
1510
+ client = SandboxClient.from_active_cluster()
1511
+ try:
1512
+ assert client._cluster_name == gateway_name
1513
+ assert client._endpoint == "tést.example:8080"
1514
+ finally:
1515
+ client.close()
1516
+
1517
+
1518
+ # ---- Sandbox label / selector API tests ----
1519
+
1520
+
1521
+ def _make_sandbox_proto(
1522
+ id_: str,
1523
+ name: str,
1524
+ labels: dict[str, str] | None = None,
1525
+ phase: openshell_pb2.SandboxPhase = openshell_pb2.SANDBOX_PHASE_READY,
1526
+ version: int = 0,
1527
+ workspace: str = "default",
1528
+ ) -> openshell_pb2.Sandbox:
1529
+ sandbox = openshell_pb2.Sandbox()
1530
+ sandbox.metadata.id = id_
1531
+ sandbox.metadata.name = name
1532
+ sandbox.metadata.workspace = workspace
1533
+ for key, value in (labels or {}).items():
1534
+ sandbox.metadata.labels[key] = value
1535
+ sandbox.status.phase = phase
1536
+ sandbox.status.current_policy_version = version
1537
+ return sandbox
1538
+
1539
+
1540
+ class _FakeSandboxStub:
1541
+ def __init__(self, listed: list[openshell_pb2.Sandbox] | None = None) -> None:
1542
+ self.create_request: openshell_pb2.CreateSandboxRequest | None = None
1543
+ self.list_request: openshell_pb2.ListSandboxesRequest | None = None
1544
+ self.get_request: openshell_pb2.GetSandboxRequest | None = None
1545
+ self.delete_request: openshell_pb2.DeleteSandboxRequest | None = None
1546
+ self.stop_request: openshell_pb2.StopSandboxRequest | None = None
1547
+ self.start_request: openshell_pb2.StartSandboxRequest | None = None
1548
+ self._listed = listed or []
1549
+
1550
+ def GetSandbox(
1551
+ self,
1552
+ request: openshell_pb2.GetSandboxRequest,
1553
+ timeout: float | None = None,
1554
+ ) -> Any:
1555
+ self.get_request = request
1556
+ _ = timeout
1557
+ return SimpleNamespace(
1558
+ sandbox=_make_sandbox_proto(
1559
+ "sandbox-1", request.name, workspace=request.workspace or "default"
1560
+ )
1561
+ )
1562
+
1563
+ def DeleteSandbox(
1564
+ self,
1565
+ request: openshell_pb2.DeleteSandboxRequest,
1566
+ timeout: float | None = None,
1567
+ ) -> Any:
1568
+ self.delete_request = request
1569
+ _ = timeout
1570
+ return SimpleNamespace(deleted=True)
1571
+
1572
+ def StopSandbox(
1573
+ self,
1574
+ request: openshell_pb2.StopSandboxRequest,
1575
+ timeout: float | None = None,
1576
+ ) -> Any:
1577
+ self.stop_request = request
1578
+ _ = timeout
1579
+ return SimpleNamespace(
1580
+ sandbox=_make_sandbox_proto(
1581
+ "sandbox-1",
1582
+ request.name,
1583
+ phase=openshell_pb2.SANDBOX_PHASE_STOPPED,
1584
+ workspace=request.workspace,
1585
+ )
1586
+ )
1587
+
1588
+ def StartSandbox(
1589
+ self,
1590
+ request: openshell_pb2.StartSandboxRequest,
1591
+ timeout: float | None = None,
1592
+ ) -> Any:
1593
+ self.start_request = request
1594
+ _ = timeout
1595
+ return SimpleNamespace(
1596
+ sandbox=_make_sandbox_proto(
1597
+ "sandbox-1",
1598
+ request.name,
1599
+ phase=openshell_pb2.SANDBOX_PHASE_STARTING,
1600
+ workspace=request.workspace,
1601
+ )
1602
+ )
1603
+
1604
+ def CreateSandbox(
1605
+ self,
1606
+ request: openshell_pb2.CreateSandboxRequest,
1607
+ timeout: float | None = None,
1608
+ ) -> Any:
1609
+ self.create_request = request
1610
+ _ = timeout
1611
+ return SimpleNamespace(
1612
+ sandbox=_make_sandbox_proto(
1613
+ "sandbox-1",
1614
+ request.name or "generated",
1615
+ dict(request.labels),
1616
+ workspace=request.workspace or "default",
1617
+ )
1618
+ )
1619
+
1620
+ def ListSandboxes(
1621
+ self,
1622
+ request: openshell_pb2.ListSandboxesRequest,
1623
+ timeout: float | None = None,
1624
+ ) -> Any:
1625
+ self.list_request = request
1626
+ _ = timeout
1627
+ return SimpleNamespace(sandboxes=list(self._listed))
1628
+
1629
+
1630
+ class _RecordingHighLevelClient:
1631
+ """A stand-in for SandboxClient used to observe high-level forwarding."""
1632
+
1633
+ def __init__(self) -> None:
1634
+ self.create_kwargs: dict[str, Any] | None = None
1635
+
1636
+ def create_session(
1637
+ self,
1638
+ *,
1639
+ workspace: str,
1640
+ spec: Any = None,
1641
+ name: str | None = None,
1642
+ labels: Any = None,
1643
+ ) -> Any:
1644
+ self.create_kwargs = {
1645
+ "workspace": workspace,
1646
+ "spec": spec,
1647
+ "name": name,
1648
+ "labels": labels,
1649
+ }
1650
+ return SimpleNamespace(sandbox=SimpleNamespace(name=name or "generated"))
1651
+
1652
+ def wait_ready(
1653
+ self, name: str, *, workspace: str, timeout_seconds: float = 300.0
1654
+ ) -> SandboxRef:
1655
+ _ = timeout_seconds
1656
+ return SandboxRef(
1657
+ id="sandbox-1",
1658
+ name=name,
1659
+ workspace=workspace,
1660
+ status=SandboxStatusRef(phase=2, current_policy_version=0),
1661
+ )
1662
+
1663
+
1664
+ def test_create_forwards_name_and_labels() -> None:
1665
+ stub = _FakeSandboxStub()
1666
+ client = _client_with_fake_stub(stub)
1667
+
1668
+ ref = client.create(
1669
+ workspace="default", name="job-1", labels={"aiq": "deep-research"}
1670
+ )
1671
+
1672
+ assert stub.create_request is not None
1673
+ assert stub.create_request.name == "job-1"
1674
+ assert dict(stub.create_request.labels) == {"aiq": "deep-research"}
1675
+ assert dict(ref.labels) == {"aiq": "deep-research"}
1676
+
1677
+
1678
+ def test_stop_and_start_forward_workspace_and_return_phase() -> None:
1679
+ stub = _FakeSandboxStub()
1680
+ client = _client_with_fake_stub(stub)
1681
+
1682
+ stopped = client.stop("job-1", workspace="team-a")
1683
+ assert stub.stop_request is not None
1684
+ assert stub.stop_request.name == "job-1"
1685
+ assert stub.stop_request.workspace == "team-a"
1686
+ assert stopped.phase == openshell_pb2.SANDBOX_PHASE_STOPPED
1687
+
1688
+ starting = client.start("job-1", workspace="team-a")
1689
+ assert stub.start_request is not None
1690
+ assert stub.start_request.name == "job-1"
1691
+ assert stub.start_request.workspace == "team-a"
1692
+ assert starting.phase == openshell_pb2.SANDBOX_PHASE_STARTING
1693
+
1694
+
1695
+ def test_create_without_args_sends_empty_metadata() -> None:
1696
+ stub = _FakeSandboxStub()
1697
+ client = _client_with_fake_stub(stub)
1698
+
1699
+ client.create(workspace="default")
1700
+
1701
+ assert stub.create_request is not None
1702
+ assert stub.create_request.name == ""
1703
+ assert dict(stub.create_request.labels) == {}
1704
+ assert stub.create_request.workspace == "default"
1705
+
1706
+
1707
+ def test_create_copies_caller_labels() -> None:
1708
+ stub = _FakeSandboxStub()
1709
+ client = _client_with_fake_stub(stub)
1710
+
1711
+ caller_labels = {"aiq": "deep-research"}
1712
+ client.create(workspace="default", labels=caller_labels)
1713
+ caller_labels["aiq"] = "mutated"
1714
+
1715
+ assert stub.create_request is not None
1716
+ assert dict(stub.create_request.labels) == {"aiq": "deep-research"}
1717
+
1718
+
1719
+ def test_create_session_forwards_name_and_labels() -> None:
1720
+ stub = _FakeSandboxStub()
1721
+ client = _client_with_fake_stub(stub)
1722
+
1723
+ session = client.create_session(
1724
+ workspace="default", name="job-2", labels={"team": "aiq"}
1725
+ )
1726
+
1727
+ assert stub.create_request is not None
1728
+ assert stub.create_request.name == "job-2"
1729
+ assert dict(stub.create_request.labels) == {"team": "aiq"}
1730
+ assert session.sandbox.name == "job-2"
1731
+
1732
+
1733
+ def test_list_forwards_label_selector() -> None:
1734
+ stub = _FakeSandboxStub()
1735
+ client = _client_with_fake_stub(stub)
1736
+
1737
+ client.list(workspace="default", label_selector="aiq=deep-research")
1738
+
1739
+ assert stub.list_request is not None
1740
+ assert stub.list_request.label_selector == "aiq=deep-research"
1741
+ assert stub.list_request.workspace == "default"
1742
+
1743
+
1744
+ def test_list_without_selector_sends_empty_string() -> None:
1745
+ stub = _FakeSandboxStub()
1746
+ client = _client_with_fake_stub(stub)
1747
+
1748
+ client.list(workspace="default")
1749
+
1750
+ assert stub.list_request is not None
1751
+ assert stub.list_request.label_selector == ""
1752
+
1753
+
1754
+ def test_list_ids_forwards_label_selector() -> None:
1755
+ stub = _FakeSandboxStub(listed=[_make_sandbox_proto("sandbox-1", "job-1")])
1756
+ client = _client_with_fake_stub(stub)
1757
+
1758
+ ids = client.list_ids(workspace="default", label_selector="aiq=deep-research")
1759
+
1760
+ assert stub.list_request is not None
1761
+ assert stub.list_request.label_selector == "aiq=deep-research"
1762
+ assert ids == ["sandbox-1"]
1763
+
1764
+
1765
+ def test_sandbox_ref_retains_gateway_labels() -> None:
1766
+ proto = _make_sandbox_proto(
1767
+ "sandbox-1", "job-1", {"aiq": "deep-research", "env": "dev"}
1768
+ )
1769
+
1770
+ ref = _sandbox_ref(proto)
1771
+
1772
+ assert dict(ref.labels) == {"aiq": "deep-research", "env": "dev"}
1773
+
1774
+
1775
+ def test_sandbox_ref_includes_main_process_result() -> None:
1776
+ proto = _make_sandbox_proto("sandbox-1", "job-1")
1777
+ proto.status.exit_code = 0
1778
+
1779
+ status = _sandbox_ref(proto).status
1780
+
1781
+ assert status.exit_code == 0
1782
+
1783
+
1784
+ def test_returned_labels_are_immutable() -> None:
1785
+ proto = _make_sandbox_proto("sandbox-1", "job-1", {"aiq": "deep-research"})
1786
+ ref = _sandbox_ref(proto)
1787
+
1788
+ with pytest.raises(TypeError):
1789
+ ref.labels["mutated"] = "nope" # type: ignore[index]
1790
+
1791
+
1792
+ def test_direct_sandbox_ref_construction_defaults_labels() -> None:
1793
+ ref = SandboxRef(
1794
+ id="sandbox-1",
1795
+ name="job-1",
1796
+ workspace="default",
1797
+ status=SandboxStatusRef(phase=2, current_policy_version=0),
1798
+ )
1799
+
1800
+ assert dict(ref.labels) == {}
1801
+
1802
+
1803
+ def test_sandbox_ref_stays_hashable_with_labels_excluded_from_identity() -> None:
1804
+ ref_a = _sandbox_ref(_make_sandbox_proto("sandbox-1", "job-1", {"aiq": "a"}))
1805
+ ref_b = _sandbox_ref(_make_sandbox_proto("sandbox-1", "job-1", {"aiq": "b"}))
1806
+
1807
+ # Frozen dataclass must remain hashable despite the immutable labels field.
1808
+ assert hash(ref_a) == hash(ref_b)
1809
+ # Labels are excluded from identity: same (id, name, status) compares equal.
1810
+ assert ref_a == ref_b
1811
+ assert {ref_a, ref_b} == {ref_a}
1812
+
1813
+
1814
+ def test_sandbox_ref_labels_support_standard_serialization() -> None:
1815
+ ref = _sandbox_ref(
1816
+ _make_sandbox_proto("sandbox-1", "job-1", {"aiq": "deep-research"})
1817
+ )
1818
+
1819
+ assert asdict(ref)["labels"] == {"aiq": "deep-research"}
1820
+ assert dict(deepcopy(ref).labels) == {"aiq": "deep-research"}
1821
+ assert dict(pickle.loads(pickle.dumps(ref)).labels) == {"aiq": "deep-research"}
1822
+
1823
+
1824
+ def test_default_sandbox_ref_labels_support_standard_serialization() -> None:
1825
+ ref = SandboxRef(
1826
+ id="sandbox-1",
1827
+ name="job-1",
1828
+ workspace="default",
1829
+ status=SandboxStatusRef(phase=2, current_policy_version=0),
1830
+ )
1831
+
1832
+ assert asdict(ref)["labels"] == {}
1833
+ assert dict(deepcopy(ref).labels) == {}
1834
+ assert dict(pickle.loads(pickle.dumps(ref)).labels) == {}
1835
+
1836
+
1837
+ def test_direct_sandbox_ref_copies_and_freezes_labels() -> None:
1838
+ labels = {"aiq": "deep-research"}
1839
+ ref = SandboxRef(
1840
+ id="sandbox-1",
1841
+ name="job-1",
1842
+ workspace="default",
1843
+ status=SandboxStatusRef(phase=2, current_policy_version=0),
1844
+ labels=labels,
1845
+ )
1846
+ labels["aiq"] = "mutated"
1847
+
1848
+ assert dict(ref.labels) == {"aiq": "deep-research"}
1849
+ with pytest.raises(TypeError):
1850
+ ref.labels["mutated"] = "nope" # type: ignore[index]
1851
+
1852
+
1853
+ def test_high_level_creation_forwards_name_and_labels(
1854
+ monkeypatch: pytest.MonkeyPatch,
1855
+ ) -> None:
1856
+ recording = _RecordingHighLevelClient()
1857
+ monkeypatch.setattr(
1858
+ SandboxClient,
1859
+ "from_active_cluster",
1860
+ classmethod(lambda _cls, **_kwargs: recording),
1861
+ )
1862
+
1863
+ sandbox = Sandbox(
1864
+ workspace="staging",
1865
+ name="job-1",
1866
+ labels={"aiq": "deep-research"},
1867
+ delete_on_exit=False,
1868
+ )
1869
+ sandbox.__enter__()
1870
+
1871
+ assert recording.create_kwargs == {
1872
+ "workspace": "staging",
1873
+ "spec": None,
1874
+ "name": "job-1",
1875
+ "labels": {"aiq": "deep-research"},
1876
+ }
1877
+
1878
+
1879
+ def test_high_level_attach_rejects_name() -> None:
1880
+ sandbox = Sandbox(workspace="default", sandbox="existing-sandbox", name="job-1")
1881
+
1882
+ with pytest.raises(SandboxError):
1883
+ sandbox.__enter__()
1884
+
1885
+
1886
+ def test_high_level_attach_rejects_labels() -> None:
1887
+ ref = SandboxRef(
1888
+ id="sandbox-1",
1889
+ name="existing",
1890
+ workspace="default",
1891
+ status=SandboxStatusRef(phase=2, current_policy_version=0),
1892
+ )
1893
+ sandbox = Sandbox(workspace="default", sandbox=ref, labels={"aiq": "deep-research"})
1894
+
1895
+ with pytest.raises(SandboxError):
1896
+ sandbox.__enter__()
1897
+
1898
+
1899
+ # ---------------------------------------------------------------------------
1900
+ # Workspace support
1901
+ # ---------------------------------------------------------------------------
1902
+
1903
+
1904
+ def test_create_passes_workspace_to_proto() -> None:
1905
+ stub = _FakeSandboxStub()
1906
+ client = _client_with_fake_stub(stub)
1907
+
1908
+ ref = client.create(workspace="staging", name="job-1")
1909
+
1910
+ assert stub.create_request is not None
1911
+ assert stub.create_request.workspace == "staging"
1912
+ assert ref.workspace == "staging"
1913
+
1914
+
1915
+ def test_get_passes_workspace_to_proto() -> None:
1916
+ stub = _FakeSandboxStub()
1917
+ client = _client_with_fake_stub(stub)
1918
+
1919
+ ref = client.get("job-1", workspace="production")
1920
+
1921
+ assert stub.get_request is not None
1922
+ assert stub.get_request.workspace == "production"
1923
+ assert ref.workspace == "production"
1924
+
1925
+
1926
+ def test_delete_passes_workspace_to_proto() -> None:
1927
+ stub = _FakeSandboxStub()
1928
+ client = _client_with_fake_stub(stub)
1929
+
1930
+ result = client.delete("job-1", workspace="staging")
1931
+
1932
+ assert result is True
1933
+ assert stub.delete_request is not None
1934
+ assert stub.delete_request.workspace == "staging"
1935
+
1936
+
1937
+ def test_list_for_all_workspaces_sets_flag() -> None:
1938
+ stub = _FakeSandboxStub()
1939
+ client = _client_with_fake_stub(stub)
1940
+
1941
+ client.list_for_all_workspaces()
1942
+
1943
+ assert stub.list_request is not None
1944
+ assert stub.list_request.all_workspaces is True
1945
+ assert stub.list_request.workspace == ""
1946
+
1947
+
1948
+ def test_list_with_workspace_passes_workspace() -> None:
1949
+ stub = _FakeSandboxStub()
1950
+ client = _client_with_fake_stub(stub)
1951
+
1952
+ client.list(workspace="staging")
1953
+
1954
+ assert stub.list_request is not None
1955
+ assert stub.list_request.workspace == "staging"
1956
+ assert stub.list_request.all_workspaces is False
1957
+
1958
+
1959
+ def test_sandbox_ref_includes_workspace_from_proto() -> None:
1960
+ proto = _make_sandbox_proto("sandbox-1", "job-1", workspace="production")
1961
+
1962
+ ref = _sandbox_ref(proto)
1963
+
1964
+ assert ref.workspace == "production"
1965
+
1966
+
1967
+ def test_sandbox_session_delete_passes_workspace() -> None:
1968
+ from openshell.sandbox import SandboxSession
1969
+
1970
+ stub = _FakeSandboxStub()
1971
+ client = _client_with_fake_stub(stub)
1972
+ ref = SandboxRef(
1973
+ id="sandbox-1",
1974
+ name="job-1",
1975
+ workspace="staging",
1976
+ status=SandboxStatusRef(phase=2, current_policy_version=0),
1977
+ )
1978
+ session = SandboxSession(client, ref)
1979
+
1980
+ session.delete()
1981
+
1982
+ assert stub.delete_request is not None
1983
+ assert stub.delete_request.workspace == "staging"