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.
- openshell/__init__.py +46 -0
- openshell/_proto/__init__.py +15 -0
- openshell/_proto/datamodel_pb2.py +77 -0
- openshell/_proto/datamodel_pb2.pyi +129 -0
- openshell/_proto/datamodel_pb2_grpc.py +24 -0
- openshell/_proto/inference_pb2.py +72 -0
- openshell/_proto/inference_pb2.pyi +149 -0
- openshell/_proto/inference_pb2_grpc.py +236 -0
- openshell/_proto/openshell_pb2.py +698 -0
- openshell/_proto/openshell_pb2.pyi +2377 -0
- openshell/_proto/openshell_pb2_grpc.py +3119 -0
- openshell/_proto/options_pb2.py +37 -0
- openshell/_proto/options_pb2.pyi +22 -0
- openshell/_proto/options_pb2_grpc.py +24 -0
- openshell/_proto/sandbox_pb2.py +123 -0
- openshell/_proto/sandbox_pb2.pyi +390 -0
- openshell/_proto/sandbox_pb2_grpc.py +24 -0
- openshell/openshell_test.py +11 -0
- openshell/py.typed +0 -0
- openshell/release_formula_test.py +170 -0
- openshell/sandbox.py +1701 -0
- openshell/sandbox_test.py +1983 -0
- openshell-0.0.113.dist-info/METADATA +307 -0
- openshell-0.0.113.dist-info/RECORD +27 -0
- openshell-0.0.113.dist-info/WHEEL +5 -0
- openshell-0.0.113.dist-info/licenses/LICENSE +191 -0
- openshell-0.0.113.dist-info/top_level.txt +1 -0
|
@@ -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"
|