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/sandbox.py ADDED
@@ -0,0 +1,1701 @@
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 base64
7
+ import contextlib
8
+ import errno
9
+ import json
10
+ import os
11
+ import pathlib
12
+ import sys
13
+ import tempfile
14
+ import threading
15
+ import time
16
+ from collections import namedtuple
17
+ from dataclasses import dataclass, field
18
+ from typing import TYPE_CHECKING, Any, Never, SupportsIndex, cast
19
+ from urllib.parse import urlparse
20
+
21
+ import grpc
22
+ import httpx
23
+
24
+ from ._proto import (
25
+ datamodel_pb2,
26
+ inference_pb2,
27
+ inference_pb2_grpc,
28
+ openshell_pb2,
29
+ openshell_pb2_grpc,
30
+ )
31
+
32
+ _ClientCallDetailsBase = namedtuple(
33
+ "_ClientCallDetailsBase",
34
+ ("method", "timeout", "metadata", "credentials", "wait_for_ready", "compression"),
35
+ )
36
+
37
+
38
+ class _ClientCallDetails(_ClientCallDetailsBase, grpc.ClientCallDetails):
39
+ pass
40
+
41
+
42
+ if TYPE_CHECKING:
43
+ import builtins
44
+ from collections.abc import Callable, Iterator, Mapping, Sequence
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class TlsConfig:
49
+ """Channel TLS material.
50
+
51
+ All three fields are optional so callers can pick the trust profile:
52
+
53
+ - Full mTLS: pass all three (server trusts client identity).
54
+ - CA-only: pass `ca_path` (custom CA, no client identity).
55
+ - System roots: pass no fields (`TlsConfig()`) — uses the OS trust
56
+ store. Useful for OIDC gateways behind a public CA.
57
+
58
+ `cert_path` and `key_path` must be set together or not at all.
59
+ """
60
+
61
+ ca_path: pathlib.Path | None = None
62
+ cert_path: pathlib.Path | None = None
63
+ key_path: pathlib.Path | None = None
64
+
65
+ def __post_init__(self) -> None:
66
+ if (self.cert_path is None) != (self.key_path is None):
67
+ raise ValueError("TlsConfig: cert_path and key_path must be set together")
68
+
69
+
70
+ class _BearerAuthInterceptor(
71
+ grpc.UnaryUnaryClientInterceptor,
72
+ grpc.UnaryStreamClientInterceptor,
73
+ grpc.StreamUnaryClientInterceptor,
74
+ grpc.StreamStreamClientInterceptor,
75
+ ):
76
+ """Add `authorization: Bearer <token>` to every outgoing RPC.
77
+
78
+ Implemented as an interceptor (not call credentials) so it works on
79
+ both plaintext and TLS channels without needing
80
+ `grpc.composite_channel_credentials`. The token provider is invoked
81
+ per call, so callers can swap tokens at runtime by mutating shared
82
+ state or returning a fresh value from the callable.
83
+ """
84
+
85
+ def __init__(self, token_provider: Callable[[], str]) -> None:
86
+ self._token_provider = token_provider
87
+
88
+ def _attach(self, details: grpc.ClientCallDetails) -> grpc.ClientCallDetails:
89
+ original_metadata = getattr(details, "metadata", None)
90
+ metadata = list(original_metadata) if original_metadata else []
91
+ metadata.append(("authorization", f"Bearer {self._token_provider()}"))
92
+ return _ClientCallDetails(
93
+ getattr(details, "method", None),
94
+ getattr(details, "timeout", None),
95
+ metadata,
96
+ getattr(details, "credentials", None),
97
+ getattr(details, "wait_for_ready", None),
98
+ getattr(details, "compression", None),
99
+ )
100
+
101
+ def intercept_unary_unary(self, continuation, client_call_details, request):
102
+ return continuation(self._attach(client_call_details), request)
103
+
104
+ def intercept_unary_stream(self, continuation, client_call_details, request):
105
+ return continuation(self._attach(client_call_details), request)
106
+
107
+ def intercept_stream_unary(
108
+ self, continuation, client_call_details, request_iterator
109
+ ):
110
+ return continuation(self._attach(client_call_details), request_iterator)
111
+
112
+ def intercept_stream_stream(
113
+ self, continuation, client_call_details, request_iterator
114
+ ):
115
+ return continuation(self._attach(client_call_details), request_iterator)
116
+
117
+
118
+ def _normalize_bearer(
119
+ bearer: str | Callable[[], str] | None,
120
+ ) -> Callable[[], str] | None:
121
+ if bearer is None:
122
+ return None
123
+ if callable(bearer):
124
+ return cast("Callable[[], str]", bearer)
125
+ token = bearer
126
+ return lambda: token
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class SandboxStatusRef:
131
+ phase: int
132
+ current_policy_version: int
133
+ exit_code: int | None = None
134
+
135
+
136
+ class _ImmutableLabels(dict[str, str]):
137
+ """A read-only, copy- and pickle-safe label mapping."""
138
+
139
+ def _deny_mutation(self, *args: object, **kwargs: object) -> Never:
140
+ del args, kwargs
141
+ raise TypeError("sandbox labels are immutable")
142
+
143
+ __setitem__ = _deny_mutation
144
+ __delitem__ = _deny_mutation
145
+ clear = _deny_mutation
146
+ pop = _deny_mutation
147
+ popitem = _deny_mutation
148
+ setdefault = _deny_mutation
149
+ update = _deny_mutation
150
+ __ior__ = _deny_mutation
151
+
152
+ def __deepcopy__(self, memo: dict[int, object]) -> _ImmutableLabels:
153
+ del memo
154
+ return type(self)(self)
155
+
156
+ def __reduce_ex__(self, protocol: SupportsIndex, /) -> str | tuple[Any, ...]:
157
+ del protocol
158
+ return type(self), (dict(self),)
159
+
160
+
161
+ @dataclass(frozen=True)
162
+ class SandboxRef:
163
+ id: str
164
+ name: str
165
+ workspace: str
166
+ status: SandboxStatusRef
167
+ # Excluded from equality/hash to preserve the original identity while the
168
+ # immutable mapping remains safe for deepcopy, pickle, and asdict.
169
+ labels: Mapping[str, str] = field(default_factory=_ImmutableLabels, compare=False)
170
+
171
+ def __post_init__(self) -> None:
172
+ object.__setattr__(self, "labels", _ImmutableLabels(self.labels))
173
+
174
+ @property
175
+ def phase(self) -> int:
176
+ return self.status.phase
177
+
178
+ @property
179
+ def current_policy_version(self) -> int:
180
+ return self.status.current_policy_version
181
+
182
+
183
+ @dataclass(frozen=True)
184
+ class ExecChunk:
185
+ stream: str
186
+ data: bytes
187
+
188
+
189
+ @dataclass(frozen=True)
190
+ class ExecResult:
191
+ exit_code: int
192
+ stdout: str
193
+ stderr: str
194
+
195
+
196
+ class SandboxError(RuntimeError):
197
+ pass
198
+
199
+
200
+ class SandboxSession:
201
+ def __init__(self, client: SandboxClient, sandbox: SandboxRef) -> None:
202
+ self._client = client
203
+ self.sandbox = sandbox
204
+ self._workspace = sandbox.workspace
205
+
206
+ @property
207
+ def id(self) -> str:
208
+ return self.sandbox.id
209
+
210
+ def exec(
211
+ self,
212
+ command: Sequence[str],
213
+ *,
214
+ stream_output: bool = False,
215
+ workdir: str | None = None,
216
+ env: Mapping[str, str] | None = None,
217
+ stdin: bytes | None = None,
218
+ timeout_seconds: int | None = None,
219
+ ) -> ExecResult:
220
+ return self._client.exec(
221
+ self.sandbox.id,
222
+ command,
223
+ stream_output=stream_output,
224
+ workdir=workdir,
225
+ env=env,
226
+ stdin=stdin,
227
+ timeout_seconds=timeout_seconds,
228
+ )
229
+
230
+ def exec_python(
231
+ self,
232
+ function: Callable[..., object],
233
+ *,
234
+ args: Sequence[object] = (),
235
+ kwargs: Mapping[str, object] | None = None,
236
+ stream_output: bool = False,
237
+ workdir: str | None = None,
238
+ env: Mapping[str, str] | None = None,
239
+ timeout_seconds: int | None = None,
240
+ ) -> ExecResult:
241
+ return self._client.exec_python(
242
+ self.sandbox.id,
243
+ function,
244
+ args=args,
245
+ kwargs=kwargs,
246
+ stream_output=stream_output,
247
+ workdir=workdir,
248
+ env=env,
249
+ timeout_seconds=timeout_seconds,
250
+ )
251
+
252
+ def delete(self) -> bool:
253
+ return self._client.delete(self.sandbox.name, workspace=self._workspace)
254
+
255
+ def stop(self) -> SandboxRef:
256
+ self.sandbox = self._client.stop(self.sandbox.name, workspace=self._workspace)
257
+ return self.sandbox
258
+
259
+ def start(self) -> SandboxRef:
260
+ self.sandbox = self._client.start(self.sandbox.name, workspace=self._workspace)
261
+ return self.sandbox
262
+
263
+
264
+ class SandboxClient:
265
+ """gRPC client for sandbox CRUD and command execution."""
266
+
267
+ def __init__(
268
+ self,
269
+ endpoint: str,
270
+ *,
271
+ tls: TlsConfig | None = None,
272
+ bearer_token: str | Callable[[], str] | None = None,
273
+ timeout: float = 30.0,
274
+ cluster_name: str | None = None,
275
+ _bearer_close: Callable[[], None] | None = None,
276
+ ) -> None:
277
+ """Create a SandboxClient.
278
+
279
+ Args:
280
+ endpoint: host:port for the gateway gRPC service.
281
+ tls: mTLS material. None for a plaintext channel.
282
+ bearer_token: OIDC access token, or a zero-arg callable
283
+ returning the current token (called per RPC; supports
284
+ runtime refresh). Combines with `tls` — pass both when
285
+ the gateway uses mTLS for transport identity and OIDC
286
+ for user identity.
287
+ timeout: default per-call timeout in seconds.
288
+ cluster_name: optional friendly name for error messages.
289
+ _bearer_close: internal — wired by `from_active_cluster`
290
+ when an `_OidcRefresher` owns the bearer callable, so
291
+ `close()` can release the refresher's HTTP client.
292
+ Public callers should not pass this; they own the
293
+ lifecycle of any callable they supplied as
294
+ `bearer_token`.
295
+ """
296
+ self._endpoint = endpoint
297
+ self._timeout = timeout
298
+ self._cluster_name = cluster_name
299
+ self._bearer_close = _bearer_close
300
+ if tls is None:
301
+ self._channel = grpc.insecure_channel(endpoint)
302
+ else:
303
+ # Build credentials from whatever subset of mTLS material the
304
+ # caller supplied. None for `root_certificates` makes gRPC use
305
+ # the system trust store, which is what we want for OIDC
306
+ # gateways behind a public CA.
307
+ credentials = grpc.ssl_channel_credentials(
308
+ root_certificates=(tls.ca_path.read_bytes() if tls.ca_path else None),
309
+ private_key=(tls.key_path.read_bytes() if tls.key_path else None),
310
+ certificate_chain=(
311
+ tls.cert_path.read_bytes() if tls.cert_path else None
312
+ ),
313
+ )
314
+ self._channel = grpc.secure_channel(endpoint, credentials)
315
+ provider = _normalize_bearer(bearer_token)
316
+ if provider is not None:
317
+ self._channel = grpc.intercept_channel(
318
+ self._channel,
319
+ _BearerAuthInterceptor(provider),
320
+ )
321
+ self._stub = openshell_pb2_grpc.OpenShellStub(self._channel)
322
+
323
+ @classmethod
324
+ def from_active_cluster(
325
+ cls,
326
+ *,
327
+ cluster: str | None = None,
328
+ timeout: float = 30.0,
329
+ auto_refresh: bool = True,
330
+ write_back: bool = True,
331
+ insecure: bool = False,
332
+ ) -> SandboxClient:
333
+ """Construct a `SandboxClient` from the active gateway's on-disk state.
334
+
335
+ Args:
336
+ cluster: explicit gateway name; otherwise reads
337
+ `$OPENSHELL_GATEWAY` or `~/.config/openshell/active_gateway`.
338
+ timeout: per-call gRPC timeout in seconds.
339
+ auto_refresh: when True (default) and the gateway uses OIDC,
340
+ lazily refresh the access token via the IdP's token endpoint
341
+ if the cached `oidc_token.json` is near expiry. Matches the
342
+ lazy-refresh patterns used by `google-auth` and `botocore`.
343
+ Set False to keep the SDK as a read-only consumer of the
344
+ CLI's cache (fail closed on expiry).
345
+ write_back: when True (default, and `auto_refresh=True`),
346
+ atomically persist refreshed bundles back to
347
+ `oidc_token.json` so other processes — including the
348
+ Rust CLI — see the rotation. Required for IdPs with
349
+ refresh-token rotation enabled (Keycloak, Entra in
350
+ strict mode): an in-memory-only refresh would leave the
351
+ on-disk `refresh_token` pointing at an invalidated
352
+ value, and any other process starting from that disk
353
+ state would fail on its first refresh. Set False only
354
+ when you know the SDK is the sole consumer of this
355
+ gateway directory.
356
+ insecure: when True, disables TLS certificate verification
357
+ for OIDC discovery and refresh calls. Mirrors the Rust
358
+ CLI's `--insecure` flag for issuers behind self-signed
359
+ certs. Off by default.
360
+ """
361
+ cluster_name = cluster or _resolve_active_cluster()
362
+ gateway_dir = _xdg_config_home() / "openshell" / "gateways" / cluster_name
363
+ metadata_path = gateway_dir / "metadata.json"
364
+ try:
365
+ metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
366
+ except FileNotFoundError:
367
+ raise SandboxError(f"gateway '{cluster_name}' not found") from None
368
+ if "gateway_endpoint" not in metadata:
369
+ raise SandboxError(f"gateway '{cluster_name}' metadata missing endpoint")
370
+ parsed = urlparse(metadata["gateway_endpoint"])
371
+ host = parsed.hostname or "127.0.0.1"
372
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
373
+ endpoint = f"{host}:{port}"
374
+
375
+ # TLS transport. Mirror crates/openshell-tui/src/lib.rs
376
+ # `build_oidc_channel` — for an https gateway, always build a
377
+ # secure channel and pick the strongest available trust profile.
378
+ tls: TlsConfig | None = None
379
+ if parsed.scheme == "https":
380
+ mtls_dir = gateway_dir / "mtls"
381
+ ca = mtls_dir / "ca.crt" if (mtls_dir / "ca.crt").exists() else None
382
+ cert = mtls_dir / "tls.crt" if (mtls_dir / "tls.crt").exists() else None
383
+ key = mtls_dir / "tls.key" if (mtls_dir / "tls.key").exists() else None
384
+ if ca is not None and cert is not None and key is not None:
385
+ # Full mTLS.
386
+ tls = TlsConfig(ca_path=ca, cert_path=cert, key_path=key)
387
+ elif ca is not None:
388
+ # CA-only trust (no client identity).
389
+ tls = TlsConfig(ca_path=ca)
390
+ else:
391
+ # System roots (e.g. OIDC gateway behind a public CA).
392
+ tls = TlsConfig()
393
+
394
+ # OIDC bearer. Mirror the Rust CLI/TUI: the gateway metadata's
395
+ # `auth_mode` is authoritative — a stale oidc_token.json next to
396
+ # a non-OIDC gateway should NOT cause us to attach a bearer.
397
+ bearer_token: Callable[[], str] | None = None
398
+ bearer_close: Callable[[], None] | None = None
399
+ if metadata.get("auth_mode") == "oidc":
400
+ bearer_token, bearer_close = _make_cluster_bearer_provider(
401
+ gateway_dir,
402
+ cluster_name,
403
+ auto_refresh=auto_refresh,
404
+ write_back=write_back,
405
+ insecure=insecure,
406
+ )
407
+
408
+ return cls(
409
+ endpoint,
410
+ tls=tls,
411
+ bearer_token=bearer_token,
412
+ timeout=timeout,
413
+ cluster_name=cluster_name,
414
+ _bearer_close=bearer_close,
415
+ )
416
+
417
+ def close(self) -> None:
418
+ """Release the gRPC channel and any bearer-auth resources.
419
+
420
+ Idempotent. If `from_active_cluster` wired up an OIDC refresher
421
+ for this client, the refresher's underlying httpx.Client is
422
+ closed here too — otherwise long-lived services that churn
423
+ clients would leak sockets / file descriptors until GC.
424
+ """
425
+ self._channel.close()
426
+ if self._bearer_close is not None:
427
+ with contextlib.suppress(Exception):
428
+ self._bearer_close()
429
+ self._bearer_close = None
430
+
431
+ def __enter__(self) -> SandboxClient:
432
+ return self
433
+
434
+ def __exit__(self, *args: object) -> None:
435
+ self.close()
436
+
437
+ def health(self) -> openshell_pb2.HealthResponse:
438
+ return self._stub.Health(openshell_pb2.HealthRequest(), timeout=self._timeout)
439
+
440
+ def create(
441
+ self,
442
+ *,
443
+ workspace: str,
444
+ spec: openshell_pb2.SandboxSpec | None = None,
445
+ name: str | None = None,
446
+ labels: Mapping[str, str] | None = None,
447
+ ) -> SandboxRef:
448
+ request_spec = spec if spec is not None else _default_spec()
449
+ response = self._stub.CreateSandbox(
450
+ openshell_pb2.CreateSandboxRequest(
451
+ spec=request_spec,
452
+ name=name or "",
453
+ labels=dict(labels) if labels else {},
454
+ workspace=workspace,
455
+ ),
456
+ timeout=self._timeout,
457
+ )
458
+ sandbox_ref = _sandbox_ref(response.sandbox)
459
+ if sandbox_ref.id == "":
460
+ raise SandboxError("CreateSandbox returned empty sandbox id")
461
+ return sandbox_ref
462
+
463
+ def create_session(
464
+ self,
465
+ *,
466
+ workspace: str,
467
+ spec: openshell_pb2.SandboxSpec | None = None,
468
+ name: str | None = None,
469
+ labels: Mapping[str, str] | None = None,
470
+ ) -> SandboxSession:
471
+ return SandboxSession(
472
+ self, self.create(workspace=workspace, spec=spec, name=name, labels=labels)
473
+ )
474
+
475
+ def get(self, sandbox_name: str, *, workspace: str) -> SandboxRef:
476
+ response = self._stub.GetSandbox(
477
+ openshell_pb2.GetSandboxRequest(name=sandbox_name, workspace=workspace),
478
+ timeout=self._timeout,
479
+ )
480
+ return _sandbox_ref(response.sandbox)
481
+
482
+ def get_session(self, sandbox_name: str, *, workspace: str) -> SandboxSession:
483
+ return SandboxSession(self, self.get(sandbox_name, workspace=workspace))
484
+
485
+ def list(
486
+ self,
487
+ *,
488
+ workspace: str,
489
+ limit: int = 100,
490
+ offset: int = 0,
491
+ label_selector: str | None = None,
492
+ ) -> builtins.list[SandboxRef]:
493
+ request = openshell_pb2.ListSandboxesRequest(
494
+ workspace=workspace,
495
+ limit=limit,
496
+ offset=offset,
497
+ label_selector=label_selector or "",
498
+ )
499
+ response = self._stub.ListSandboxes(request, timeout=self._timeout)
500
+ return [_sandbox_ref(item) for item in response.sandboxes]
501
+
502
+ def list_for_all_workspaces(
503
+ self,
504
+ *,
505
+ limit: int = 100,
506
+ offset: int = 0,
507
+ label_selector: str | None = None,
508
+ ) -> builtins.list[SandboxRef]:
509
+ request = openshell_pb2.ListSandboxesRequest(
510
+ all_workspaces=True,
511
+ limit=limit,
512
+ offset=offset,
513
+ label_selector=label_selector or "",
514
+ )
515
+ response = self._stub.ListSandboxes(request, timeout=self._timeout)
516
+ return [_sandbox_ref(item) for item in response.sandboxes]
517
+
518
+ def list_ids(
519
+ self,
520
+ *,
521
+ workspace: str,
522
+ limit: int = 100,
523
+ offset: int = 0,
524
+ label_selector: str | None = None,
525
+ ) -> builtins.list[str]:
526
+ return [
527
+ item.id
528
+ for item in self.list(
529
+ workspace=workspace,
530
+ limit=limit,
531
+ offset=offset,
532
+ label_selector=label_selector,
533
+ )
534
+ ]
535
+
536
+ def list_ids_for_all_workspaces(
537
+ self,
538
+ *,
539
+ limit: int = 100,
540
+ offset: int = 0,
541
+ label_selector: str | None = None,
542
+ ) -> builtins.list[str]:
543
+ return [
544
+ item.id
545
+ for item in self.list_for_all_workspaces(
546
+ limit=limit,
547
+ offset=offset,
548
+ label_selector=label_selector,
549
+ )
550
+ ]
551
+
552
+ def delete(self, sandbox_name: str, *, workspace: str) -> bool:
553
+ response = self._stub.DeleteSandbox(
554
+ openshell_pb2.DeleteSandboxRequest(name=sandbox_name, workspace=workspace),
555
+ timeout=self._timeout,
556
+ )
557
+ return bool(response.deleted)
558
+
559
+ def stop(self, sandbox_name: str, *, workspace: str) -> SandboxRef:
560
+ response = self._stub.StopSandbox(
561
+ openshell_pb2.StopSandboxRequest(name=sandbox_name, workspace=workspace),
562
+ timeout=self._timeout,
563
+ )
564
+ return _sandbox_ref(response.sandbox)
565
+
566
+ def start(self, sandbox_name: str, *, workspace: str) -> SandboxRef:
567
+ response = self._stub.StartSandbox(
568
+ openshell_pb2.StartSandboxRequest(name=sandbox_name, workspace=workspace),
569
+ timeout=self._timeout,
570
+ )
571
+ return _sandbox_ref(response.sandbox)
572
+
573
+ def wait_deleted(
574
+ self, sandbox_name: str, *, workspace: str, timeout_seconds: float = 60.0
575
+ ) -> None:
576
+ deadline = time.time() + timeout_seconds
577
+ while time.time() < deadline:
578
+ try:
579
+ self.get(sandbox_name, workspace=workspace)
580
+ except grpc.RpcError as exc:
581
+ if (
582
+ isinstance(exc, grpc.Call)
583
+ and exc.code() == grpc.StatusCode.NOT_FOUND
584
+ ):
585
+ return
586
+ raise
587
+ time.sleep(1)
588
+ raise SandboxError(f"sandbox {sandbox_name} was not deleted within timeout")
589
+
590
+ def wait_ready(
591
+ self, sandbox_name: str, *, workspace: str, timeout_seconds: float = 300.0
592
+ ) -> SandboxRef:
593
+ return self._wait_for_phase(
594
+ sandbox_name,
595
+ workspace=workspace,
596
+ target_phase=openshell_pb2.SANDBOX_PHASE_READY,
597
+ target_name="ready",
598
+ timeout_seconds=timeout_seconds,
599
+ )
600
+
601
+ def wait_stopped(
602
+ self, sandbox_name: str, *, workspace: str, timeout_seconds: float = 300.0
603
+ ) -> SandboxRef:
604
+ return self._wait_for_phase(
605
+ sandbox_name,
606
+ workspace=workspace,
607
+ target_phase=openshell_pb2.SANDBOX_PHASE_STOPPED,
608
+ target_name="stopped",
609
+ timeout_seconds=timeout_seconds,
610
+ )
611
+
612
+ def _wait_for_phase(
613
+ self,
614
+ sandbox_name: str,
615
+ *,
616
+ workspace: str,
617
+ target_phase: int,
618
+ target_name: str,
619
+ timeout_seconds: float,
620
+ ) -> SandboxRef:
621
+ deadline = time.time() + timeout_seconds
622
+ while time.time() < deadline:
623
+ sandbox = self.get(sandbox_name, workspace=workspace)
624
+ if sandbox.status.phase == target_phase:
625
+ return sandbox
626
+ if sandbox.status.phase == openshell_pb2.SANDBOX_PHASE_ERROR:
627
+ raise SandboxError(f"sandbox {sandbox_name} entered error phase")
628
+ time.sleep(1)
629
+ raise SandboxError(
630
+ f"sandbox {sandbox_name} was not {target_name} within timeout"
631
+ )
632
+
633
+ def exec_stream(
634
+ self,
635
+ sandbox_id: str,
636
+ command: Sequence[str],
637
+ *,
638
+ workdir: str | None = None,
639
+ env: Mapping[str, str] | None = None,
640
+ stdin: bytes | None = None,
641
+ timeout_seconds: int | None = None,
642
+ ) -> Iterator[ExecChunk | ExecResult]:
643
+ if not command:
644
+ raise SandboxError("command must not be empty")
645
+
646
+ request = openshell_pb2.ExecSandboxRequest(
647
+ sandbox_id=sandbox_id,
648
+ command=list(command),
649
+ workdir=workdir or "",
650
+ environment=dict(env or {}),
651
+ timeout_seconds=timeout_seconds or 0,
652
+ stdin=stdin or b"",
653
+ )
654
+ # Use whichever is larger: the default client timeout or the command
655
+ # timeout plus headroom for SSH setup / teardown overhead.
656
+ grpc_deadline = self._timeout
657
+ if timeout_seconds and timeout_seconds + 10 > grpc_deadline:
658
+ grpc_deadline = timeout_seconds + 10
659
+ stream = self._stub.ExecSandbox(request, timeout=grpc_deadline)
660
+
661
+ stdout_parts: list[bytes] = []
662
+ stderr_parts: list[bytes] = []
663
+ exit_code: int | None = None
664
+
665
+ for event in stream:
666
+ payload = event.WhichOneof("payload")
667
+ if payload == "stdout":
668
+ data = bytes(event.stdout.data)
669
+ stdout_parts.append(data)
670
+ yield ExecChunk(stream="stdout", data=data)
671
+ elif payload == "stderr":
672
+ data = bytes(event.stderr.data)
673
+ stderr_parts.append(data)
674
+ yield ExecChunk(stream="stderr", data=data)
675
+ elif payload == "exit":
676
+ exit_code = int(event.exit.exit_code)
677
+
678
+ if exit_code is None:
679
+ raise SandboxError("ExecSandbox stream ended without an exit event")
680
+
681
+ yield ExecResult(
682
+ exit_code=exit_code,
683
+ stdout=b"".join(stdout_parts).decode("utf-8", errors="replace"),
684
+ stderr=b"".join(stderr_parts).decode("utf-8", errors="replace"),
685
+ )
686
+
687
+ def exec(
688
+ self,
689
+ sandbox_id: str,
690
+ command: Sequence[str],
691
+ *,
692
+ stream_output: bool = False,
693
+ workdir: str | None = None,
694
+ env: Mapping[str, str] | None = None,
695
+ stdin: bytes | None = None,
696
+ timeout_seconds: int | None = None,
697
+ ) -> ExecResult:
698
+ result: ExecResult | None = None
699
+ for item in self.exec_stream(
700
+ sandbox_id,
701
+ command,
702
+ workdir=workdir,
703
+ env=env,
704
+ stdin=stdin,
705
+ timeout_seconds=timeout_seconds,
706
+ ):
707
+ if stream_output and isinstance(item, ExecChunk):
708
+ if item.stream == "stdout":
709
+ sys.stdout.buffer.write(item.data)
710
+ sys.stdout.flush()
711
+ else:
712
+ sys.stderr.buffer.write(item.data)
713
+ sys.stderr.flush()
714
+ if isinstance(item, ExecResult):
715
+ result = item
716
+ if result is None:
717
+ raise SandboxError("ExecSandbox did not return a result")
718
+ return result
719
+
720
+ def exec_python(
721
+ self,
722
+ sandbox_id: str,
723
+ function: Callable[..., object],
724
+ *,
725
+ args: Sequence[object] = (),
726
+ kwargs: Mapping[str, object] | None = None,
727
+ stream_output: bool = False,
728
+ workdir: str | None = None,
729
+ env: Mapping[str, str] | None = None,
730
+ timeout_seconds: int | None = None,
731
+ ) -> ExecResult:
732
+ exec_env = dict(env or {})
733
+ exec_env["OPENSHELL_PYFUNC_B64"] = _serialize_python_callable(
734
+ function,
735
+ args=args,
736
+ kwargs=kwargs,
737
+ )
738
+ return self.exec(
739
+ sandbox_id,
740
+ [_SANDBOX_PYTHON_BIN, "-c", _PYTHON_CLOUDPICKLE_BOOTSTRAP],
741
+ stream_output=stream_output,
742
+ workdir=workdir,
743
+ env=exec_env,
744
+ timeout_seconds=timeout_seconds,
745
+ )
746
+
747
+
748
+ @dataclass(frozen=True)
749
+ class InferenceRouteConfig:
750
+ provider_name: str
751
+ model_id: str
752
+ version: int
753
+
754
+
755
+ class InferenceRouteClient:
756
+ """gRPC client for workspace-scoped inference route configuration."""
757
+
758
+ def __init__(self, channel: grpc.Channel, *, timeout: float = 30.0) -> None:
759
+ self._stub = inference_pb2_grpc.InferenceStub(channel)
760
+ self._timeout = timeout
761
+
762
+ @classmethod
763
+ def from_sandbox_client(cls, client: SandboxClient) -> InferenceRouteClient:
764
+ return cls(client._channel, timeout=client._timeout)
765
+
766
+ def set_route(
767
+ self,
768
+ *,
769
+ workspace: str,
770
+ provider_name: str,
771
+ model_id: str,
772
+ no_verify: bool = False,
773
+ ) -> InferenceRouteConfig:
774
+ response = self._stub.SetInferenceRoute(
775
+ inference_pb2.SetInferenceRouteRequest(
776
+ workspace=workspace,
777
+ provider_name=provider_name,
778
+ model_id=model_id,
779
+ no_verify=no_verify,
780
+ ),
781
+ timeout=self._timeout,
782
+ )
783
+ return InferenceRouteConfig(
784
+ provider_name=response.provider_name,
785
+ model_id=response.model_id,
786
+ version=response.version,
787
+ )
788
+
789
+ def get_route(self, *, workspace: str) -> InferenceRouteConfig:
790
+ response = self._stub.GetInferenceRoute(
791
+ inference_pb2.GetInferenceRouteRequest(workspace=workspace),
792
+ timeout=self._timeout,
793
+ )
794
+ return InferenceRouteConfig(
795
+ provider_name=response.provider_name,
796
+ model_id=response.model_id,
797
+ version=response.version,
798
+ )
799
+
800
+ def delete_route(
801
+ self,
802
+ *,
803
+ workspace: str,
804
+ route_name: str = "",
805
+ ) -> bool:
806
+ response = self._stub.DeleteInferenceRoute(
807
+ inference_pb2.DeleteInferenceRouteRequest(
808
+ workspace=workspace,
809
+ route_name=route_name,
810
+ ),
811
+ timeout=self._timeout,
812
+ )
813
+ return response.deleted
814
+
815
+
816
+ @dataclass(frozen=True)
817
+ class WorkspaceRef:
818
+ name: str
819
+ phase: str
820
+ labels: dict[str, str]
821
+
822
+
823
+ def _workspace_ref(ws: datamodel_pb2.Workspace) -> WorkspaceRef:
824
+ meta = ws.metadata
825
+ return WorkspaceRef(
826
+ name=meta.name,
827
+ phase=datamodel_pb2.WorkspacePhase.Name(ws.status.phase),
828
+ labels=dict(meta.labels),
829
+ )
830
+
831
+
832
+ class WorkspaceClient:
833
+ """gRPC client for workspace lifecycle operations."""
834
+
835
+ def __init__(self, channel: grpc.Channel, *, timeout: float = 30.0) -> None:
836
+ self._stub = openshell_pb2_grpc.OpenShellStub(channel)
837
+ self._timeout = timeout
838
+
839
+ @classmethod
840
+ def from_sandbox_client(cls, client: SandboxClient) -> WorkspaceClient:
841
+ return cls(client._channel, timeout=client._timeout)
842
+
843
+ def create(
844
+ self,
845
+ name: str,
846
+ *,
847
+ labels: Mapping[str, str] | None = None,
848
+ ) -> WorkspaceRef:
849
+ response = self._stub.CreateWorkspace(
850
+ openshell_pb2.CreateWorkspaceRequest(
851
+ name=name,
852
+ labels=dict(labels) if labels else {},
853
+ ),
854
+ timeout=self._timeout,
855
+ )
856
+ return _workspace_ref(response.workspace)
857
+
858
+ def get(self, name: str) -> WorkspaceRef:
859
+ response = self._stub.GetWorkspace(
860
+ openshell_pb2.GetWorkspaceRequest(name=name),
861
+ timeout=self._timeout,
862
+ )
863
+ return _workspace_ref(response.workspace)
864
+
865
+ def list(
866
+ self,
867
+ *,
868
+ limit: int = 100,
869
+ offset: int = 0,
870
+ label_selector: str | None = None,
871
+ ) -> builtins.list[WorkspaceRef]:
872
+ response = self._stub.ListWorkspaces(
873
+ openshell_pb2.ListWorkspacesRequest(
874
+ limit=limit,
875
+ offset=offset,
876
+ label_selector=label_selector or "",
877
+ ),
878
+ timeout=self._timeout,
879
+ )
880
+ return [_workspace_ref(ws) for ws in response.workspaces]
881
+
882
+ def delete(self, name: str) -> bool:
883
+ response = self._stub.DeleteWorkspace(
884
+ openshell_pb2.DeleteWorkspaceRequest(name=name),
885
+ timeout=self._timeout,
886
+ )
887
+ return response.deleted
888
+
889
+
890
+ class Sandbox:
891
+ """Context-managed sandbox session bound to one sandbox id."""
892
+
893
+ def __init__(
894
+ self,
895
+ *,
896
+ workspace: str,
897
+ cluster: str | None = None,
898
+ sandbox: str | SandboxRef | None = None,
899
+ delete_on_exit: bool = True,
900
+ spec: openshell_pb2.SandboxSpec | None = None,
901
+ name: str | None = None,
902
+ labels: Mapping[str, str] | None = None,
903
+ timeout: float = 30.0,
904
+ ready_timeout_seconds: float = 120.0,
905
+ auto_refresh: bool = True,
906
+ write_back: bool = True,
907
+ insecure: bool = False,
908
+ ) -> None:
909
+ """Bind a Sandbox context to the active gateway.
910
+
911
+ OIDC kwargs (`auto_refresh`, `write_back`, `insecure`) forward
912
+ directly to `SandboxClient.from_active_cluster` and have the
913
+ same semantics. They're surfaced on `Sandbox` so callers using
914
+ the higher-level wrapper get parity with `SandboxClient` for
915
+ OIDC-protected gateways (e.g. passing `insecure=True` for a
916
+ self-signed dev IdP). Non-OIDC gateways ignore them.
917
+ """
918
+ self._workspace = workspace
919
+ self._cluster = cluster
920
+ self._sandbox_input = sandbox
921
+ self._delete_on_exit = delete_on_exit
922
+ self._spec = spec
923
+ self._name = name
924
+ # Copy so later caller mutation cannot change what gets sent on enter.
925
+ self._labels = dict(labels) if labels is not None else None
926
+ self._timeout = timeout
927
+ self._ready_timeout_seconds = ready_timeout_seconds
928
+ self._auto_refresh = auto_refresh
929
+ self._write_back = write_back
930
+ self._insecure = insecure
931
+ self._client: SandboxClient | None = None
932
+ self._session: SandboxSession | None = None
933
+
934
+ @property
935
+ def id(self) -> str:
936
+ if self._session is None:
937
+ raise SandboxError("sandbox context has not been entered")
938
+ return self._session.id
939
+
940
+ @property
941
+ def sandbox(self) -> SandboxRef:
942
+ if self._session is None:
943
+ raise SandboxError("sandbox context has not been entered")
944
+ return self._session.sandbox
945
+
946
+ def __enter__(self) -> Sandbox:
947
+ # Creation metadata cannot be applied when attaching to an existing
948
+ # sandbox; reject it before opening a connection.
949
+ if self._sandbox_input is not None and (
950
+ self._name is not None or self._labels is not None
951
+ ):
952
+ raise SandboxError(
953
+ "name and labels cannot be set when attaching to an existing sandbox"
954
+ )
955
+
956
+ client = SandboxClient.from_active_cluster(
957
+ cluster=self._cluster,
958
+ timeout=self._timeout,
959
+ auto_refresh=self._auto_refresh,
960
+ write_back=self._write_back,
961
+ insecure=self._insecure,
962
+ )
963
+ self._client = client
964
+
965
+ if self._sandbox_input is None:
966
+ self._session = client.create_session(
967
+ workspace=self._workspace,
968
+ spec=self._spec,
969
+ name=self._name,
970
+ labels=self._labels,
971
+ )
972
+ elif isinstance(self._sandbox_input, SandboxRef):
973
+ self._session = SandboxSession(client, self._sandbox_input)
974
+ else:
975
+ self._session = client.get_session(
976
+ self._sandbox_input, workspace=self._workspace
977
+ )
978
+
979
+ self._workspace = getattr(self._session, "_workspace", self._workspace)
980
+
981
+ ready = client.wait_ready(
982
+ self._session.sandbox.name,
983
+ workspace=self._workspace,
984
+ timeout_seconds=self._ready_timeout_seconds,
985
+ )
986
+ self._session = SandboxSession(client, ready)
987
+
988
+ return self
989
+
990
+ def __exit__(self, *args: object) -> None:
991
+ try:
992
+ if (
993
+ self._delete_on_exit
994
+ and self._session is not None
995
+ and self._client is not None
996
+ ):
997
+ try:
998
+ deleted = self._session.delete()
999
+ if deleted:
1000
+ self._client.wait_deleted(
1001
+ self._session.sandbox.name,
1002
+ workspace=self._workspace,
1003
+ )
1004
+ except grpc.RpcError as exc:
1005
+ if (
1006
+ not isinstance(exc, grpc.Call)
1007
+ or exc.code() != grpc.StatusCode.NOT_FOUND
1008
+ ):
1009
+ raise
1010
+ finally:
1011
+ if self._client is not None:
1012
+ self._client.close()
1013
+ self._session = None
1014
+ self._client = None
1015
+
1016
+ def exec(
1017
+ self,
1018
+ command: Sequence[str],
1019
+ *,
1020
+ stream_output: bool = False,
1021
+ workdir: str | None = None,
1022
+ env: Mapping[str, str] | None = None,
1023
+ stdin: bytes | None = None,
1024
+ timeout_seconds: int | None = None,
1025
+ ) -> ExecResult:
1026
+ if self._session is None:
1027
+ raise SandboxError("sandbox context has not been entered")
1028
+ return self._session.exec(
1029
+ command,
1030
+ stream_output=stream_output,
1031
+ workdir=workdir,
1032
+ env=env,
1033
+ stdin=stdin,
1034
+ timeout_seconds=timeout_seconds,
1035
+ )
1036
+
1037
+ def exec_python(
1038
+ self,
1039
+ function: Callable[..., object],
1040
+ *,
1041
+ args: Sequence[object] = (),
1042
+ kwargs: Mapping[str, object] | None = None,
1043
+ stream_output: bool = False,
1044
+ workdir: str | None = None,
1045
+ env: Mapping[str, str] | None = None,
1046
+ timeout_seconds: int | None = None,
1047
+ ) -> ExecResult:
1048
+ if self._session is None:
1049
+ raise SandboxError("sandbox context has not been entered")
1050
+ return self._session.exec_python(
1051
+ function,
1052
+ args=args,
1053
+ kwargs=kwargs,
1054
+ stream_output=stream_output,
1055
+ workdir=workdir,
1056
+ env=env,
1057
+ timeout_seconds=timeout_seconds,
1058
+ )
1059
+
1060
+
1061
+ _PYTHON_CLOUDPICKLE_BOOTSTRAP = (
1062
+ "import base64,cloudpickle,os;"
1063
+ "payload=base64.b64decode(os.environ['OPENSHELL_PYFUNC_B64']);"
1064
+ "func,args,kwargs=cloudpickle.loads(payload);"
1065
+ "result=func(*args,**kwargs);"
1066
+ "print(result) if result is not None else None"
1067
+ )
1068
+
1069
+ _SANDBOX_PYTHON_BIN = "python"
1070
+
1071
+
1072
+ def _serialize_python_callable(
1073
+ function: Callable[..., object],
1074
+ *,
1075
+ args: Sequence[object],
1076
+ kwargs: Mapping[str, object] | None,
1077
+ ) -> str:
1078
+ try:
1079
+ import cloudpickle
1080
+ except ImportError as exc: # pragma: no cover - import error path
1081
+ raise SandboxError("cloudpickle is required for exec_python") from exc
1082
+
1083
+ payload = cloudpickle.dumps((function, tuple(args), dict(kwargs or {})))
1084
+ return base64.b64encode(payload).decode("ascii")
1085
+
1086
+
1087
+ def _sandbox_ref(sandbox: openshell_pb2.Sandbox) -> SandboxRef:
1088
+ status = sandbox.status if sandbox.HasField("status") else None
1089
+ return SandboxRef(
1090
+ id=sandbox.metadata.id if sandbox.metadata else "",
1091
+ name=sandbox.metadata.name if sandbox.metadata else "",
1092
+ workspace=sandbox.metadata.workspace if sandbox.metadata else "",
1093
+ status=SandboxStatusRef(
1094
+ phase=status.phase if status else 0,
1095
+ current_policy_version=status.current_policy_version if status else 0,
1096
+ exit_code=status.exit_code
1097
+ if status is not None and status.HasField("exit_code")
1098
+ else None,
1099
+ ),
1100
+ labels=sandbox.metadata.labels if sandbox.metadata else {},
1101
+ )
1102
+
1103
+
1104
+ def _default_spec() -> openshell_pb2.SandboxSpec:
1105
+ # Omit the policy field so the sandbox container discovers its policy
1106
+ # from /etc/openshell/policy.yaml (baked into the image at build time).
1107
+ # This avoids duplicating policy defaults between the SDK and the
1108
+ # container image and ensures sandboxes get the full dev-sandbox-policy
1109
+ # (including network_policies) out of the box.
1110
+ return openshell_pb2.SandboxSpec()
1111
+
1112
+
1113
+ def _xdg_config_home() -> pathlib.Path:
1114
+ configured = os.environ.get("XDG_CONFIG_HOME")
1115
+ if configured:
1116
+ return pathlib.Path(configured)
1117
+ return pathlib.Path.home() / ".config"
1118
+
1119
+
1120
+ # Re-check the cached token roughly 30 seconds before the issuer's
1121
+ # stated expiry, to leave room for in-flight RPCs and clock skew. This
1122
+ # matches `openshell-bootstrap::oidc_token::is_token_expired`.
1123
+ _OIDC_TOKEN_EXPIRY_GRACE_SECONDS = 30
1124
+
1125
+ _IS_WINDOWS = os.name == "nt"
1126
+ _WINDOWS_REPLACE_RETRYABLE_ERRORS = frozenset({5, 32})
1127
+ _WINDOWS_REPLACE_TIMEOUT_SECONDS = 0.25
1128
+ _WINDOWS_REPLACE_INITIAL_DELAY_SECONDS = 0.005
1129
+ _WINDOWS_REPLACE_MAX_DELAY_SECONDS = 0.05
1130
+ _WINDOWS_REPLACE_LOCK = threading.Lock()
1131
+
1132
+
1133
+ def _atomic_replace(source: pathlib.Path, destination: pathlib.Path) -> None:
1134
+ """Atomically replace a file, retrying transient Windows sharing errors."""
1135
+ if not _IS_WINDOWS:
1136
+ source.replace(destination)
1137
+ return
1138
+
1139
+ # Serialize writers in this process. The retry still handles other
1140
+ # processes (including the Rust CLI) and filesystem scanners that briefly
1141
+ # open the destination without delete sharing.
1142
+ with _WINDOWS_REPLACE_LOCK:
1143
+ deadline = time.monotonic() + _WINDOWS_REPLACE_TIMEOUT_SECONDS
1144
+ delay = _WINDOWS_REPLACE_INITIAL_DELAY_SECONDS
1145
+ while True:
1146
+ try:
1147
+ source.replace(destination)
1148
+ return
1149
+ except PermissionError as error:
1150
+ winerror = getattr(error, "winerror", None)
1151
+ retryable = winerror in _WINDOWS_REPLACE_RETRYABLE_ERRORS or (
1152
+ winerror is None and error.errno == errno.EACCES
1153
+ )
1154
+ if not retryable or time.monotonic() >= deadline:
1155
+ raise
1156
+ time.sleep(delay)
1157
+ delay = min(delay * 2, _WINDOWS_REPLACE_MAX_DELAY_SECONDS)
1158
+
1159
+
1160
+ def _read_oidc_token_bundle(gateway_dir: pathlib.Path) -> dict | None:
1161
+ """Read and parse `oidc_token.json` for a gateway.
1162
+
1163
+ Returns the parsed dict, or `None` if the file is absent or unreadable.
1164
+ See `openshell-bootstrap::oidc_token::store_oidc_token` for the writer.
1165
+ """
1166
+ token_path = gateway_dir / "oidc_token.json"
1167
+ try:
1168
+ return json.loads(token_path.read_text(encoding="utf-8"))
1169
+ except FileNotFoundError:
1170
+ return None
1171
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
1172
+ return None
1173
+
1174
+
1175
+ def _normalize_issuer(bundle: dict) -> str | None:
1176
+ """Return the bundle's issuer with a trailing slash stripped.
1177
+
1178
+ Used to detect whether the issuer changed when adopting a bundle
1179
+ re-read from disk, so a cached token endpoint computed for the old
1180
+ issuer can be invalidated. Trailing-slash differences are treated as
1181
+ equal, matching `_discover_token_endpoint`'s normalization.
1182
+ """
1183
+ issuer = bundle.get("issuer")
1184
+ return issuer.rstrip("/") if isinstance(issuer, str) else None
1185
+
1186
+
1187
+ def _load_cluster_bearer_token(gateway_dir: pathlib.Path) -> str | None:
1188
+ """Read a single (possibly expired) access token from disk.
1189
+
1190
+ Lower-level helper used by both the legacy single-shot path and the
1191
+ refreshing provider. Returns the raw access_token string or None.
1192
+ """
1193
+ bundle = _read_oidc_token_bundle(gateway_dir)
1194
+ if bundle is None:
1195
+ return None
1196
+ access_token = bundle.get("access_token")
1197
+ if not isinstance(access_token, str) or not access_token:
1198
+ return None
1199
+ return access_token
1200
+
1201
+
1202
+ def _make_fail_closed_bearer_provider(
1203
+ gateway_dir: pathlib.Path,
1204
+ cluster_name: str,
1205
+ ) -> Callable[[], str]:
1206
+ """Per-RPC provider that re-reads `oidc_token.json` but does NOT refresh.
1207
+
1208
+ Raises `SandboxError` when the token is missing or expired. Available as
1209
+ an opt-out from the default `_OidcRefresher` for callers (e.g. tests)
1210
+ that want to assert expiry behavior or that don't want the SDK to make
1211
+ outbound HTTP calls to the IdP.
1212
+ """
1213
+
1214
+ def provider() -> str:
1215
+ bundle = _read_oidc_token_bundle(gateway_dir)
1216
+ if bundle is None:
1217
+ raise SandboxError(
1218
+ f"OIDC token for gateway '{cluster_name}' is missing or "
1219
+ f"unreadable. Re-authenticate with: openshell gateway login"
1220
+ )
1221
+ access_token = bundle.get("access_token")
1222
+ if not isinstance(access_token, str) or not access_token:
1223
+ raise SandboxError(
1224
+ f"OIDC token for gateway '{cluster_name}' has no access "
1225
+ f"token. Re-authenticate with: openshell gateway login"
1226
+ )
1227
+ expires_at = bundle.get("expires_at")
1228
+ if isinstance(expires_at, int):
1229
+ now = int(time.time())
1230
+ if now + _OIDC_TOKEN_EXPIRY_GRACE_SECONDS >= expires_at:
1231
+ raise SandboxError(
1232
+ f"OIDC token for gateway '{cluster_name}' has expired. "
1233
+ f"Re-authenticate with: openshell gateway login"
1234
+ )
1235
+ return access_token
1236
+
1237
+ return provider
1238
+
1239
+
1240
+ class _InvalidGrantError(SandboxError):
1241
+ """Refresh failed with OAuth2 `invalid_grant` (RFC 6749 §5.2).
1242
+
1243
+ The refresh_token was rejected — expired, revoked, or rotated out by
1244
+ a concurrent refresh in another process. Subclass of `SandboxError`
1245
+ so an uncaught instance still surfaces the standard re-authenticate
1246
+ hint; `current_access_token` catches it to retry once with a
1247
+ peer-rotated bundle before giving up.
1248
+ """
1249
+
1250
+
1251
+ class _OidcRefresher:
1252
+ """Thread-safe in-process OAuth2 refresh for a gateway's `oidc_token.json`.
1253
+
1254
+ Mirrors the lazy-refresh pattern used by `google-auth`'s `Credentials`
1255
+ and `botocore`'s `SSOTokenProvider`. Uses `httpx` for transport so
1256
+ we can pin `follow_redirects=False` and the TLS verification policy
1257
+ explicitly — same posture as the Rust CLI's use of `reqwest` with
1258
+ `Policy::none()` and an opt-in `danger_accept_invalid_certs` (see
1259
+ `crates/openshell-cli/src/oidc_auth.rs::http_client`). The OAuth2
1260
+ refresh-token grant itself (RFC 6749 §6) is a single form-encoded
1261
+ POST, handled inline rather than via an OAuth2 library.
1262
+
1263
+ Properties:
1264
+
1265
+ - **Lazy**: check expiry on every RPC; refresh only when stale.
1266
+ - **Lock-coordinated**: concurrent RPCs share a single refresh, not
1267
+ one per call. Plain `threading.Lock` (no separate worker thread,
1268
+ unlike `google-auth`'s `RefreshThreadManager` — sufficient for
1269
+ our use case).
1270
+ - **Disk-aware**: before refreshing, re-read `oidc_token.json` —
1271
+ the CLI or another process may have already rotated the bundle.
1272
+ - **Discovery-validated**: fetches the OIDC discovery document
1273
+ from `<issuer>/.well-known/openid-configuration`, rejects
1274
+ responses whose `issuer` field doesn't match the configured one
1275
+ (preventing SSRF / misdirection of the refresh_token to an
1276
+ attacker-controlled endpoint). Mirrors the Rust CLI's
1277
+ `discover()` function.
1278
+ - **Redirect-hardened**: `follow_redirects=False` on the underlying
1279
+ `httpx.Client` so a 3xx during discovery or refresh is treated
1280
+ as a failure rather than silently chasing the redirect to an
1281
+ arbitrary host.
1282
+ - **Write-back by default**: when `auto_refresh=True`, refreshed
1283
+ bundles are atomically persisted to `oidc_token.json` at mode
1284
+ 0600 so other processes (Rust CLI, TUI, other Python clients)
1285
+ see the rotated `refresh_token`. Required for IdPs that
1286
+ invalidate the old `refresh_token` on rotation (Keycloak with
1287
+ rotation enabled, Entra in strict mode); without write-back, a
1288
+ second process would `invalid_grant` on next refresh.
1289
+ - **`insecure=True` flag** disables TLS certificate verification
1290
+ for the discovery and refresh calls. Matches the Rust CLI's
1291
+ `--insecure` plumbing for OIDC issuers behind self-signed certs.
1292
+ - **Refresh failures** surface as `SandboxError` with a
1293
+ "re-authenticate with: openshell gateway login" hint.
1294
+ """
1295
+
1296
+ def __init__(
1297
+ self,
1298
+ gateway_dir: pathlib.Path,
1299
+ cluster_name: str,
1300
+ *,
1301
+ write_back: bool = True,
1302
+ insecure: bool = False,
1303
+ ) -> None:
1304
+ self._gateway_dir = gateway_dir
1305
+ self._cluster_name = cluster_name
1306
+ self._write_back = write_back
1307
+ self._lock = threading.Lock()
1308
+ self._bundle: dict | None = None
1309
+ self._token_endpoint: str | None = None
1310
+ # Single httpx.Client serves both discovery (unauthenticated
1311
+ # GET) and refresh (POST with form-encoded body) — they share
1312
+ # the same security posture.
1313
+ #
1314
+ # - follow_redirects=False: a 3xx during discovery would
1315
+ # otherwise steer us to an attacker-controlled token
1316
+ # endpoint. Matches `reqwest::redirect::Policy::none()` in
1317
+ # the Rust CLI's `oidc_auth.rs::http_client`.
1318
+ # - verify=not insecure: opt-in TLS-verification disable for
1319
+ # self-signed issuers. Matches the Rust CLI's `--insecure`
1320
+ # flag plumbing.
1321
+ #
1322
+ # We don't use authlib's `OAuth2Client` here because it
1323
+ # auto-injects an Authorization header on every request from
1324
+ # its stored token, which would break the unauthenticated
1325
+ # discovery GET. The refresh_token grant for a public client
1326
+ # is a single form-encoded POST — small enough to spell out
1327
+ # directly with httpx, and easier to test.
1328
+ self._http = httpx.Client(
1329
+ follow_redirects=False,
1330
+ verify=not insecure,
1331
+ timeout=15.0,
1332
+ )
1333
+
1334
+ def close(self) -> None:
1335
+ self._http.close()
1336
+
1337
+ def __del__(self) -> None:
1338
+ # Best-effort cleanup; tests + short-lived callers may not call
1339
+ # close() explicitly.
1340
+ with contextlib.suppress(Exception):
1341
+ self._http.close()
1342
+
1343
+ def current_access_token(self) -> str:
1344
+ """Return a non-expired access token, refreshing if needed."""
1345
+ with self._lock:
1346
+ if self._bundle is None:
1347
+ self._bundle = _read_oidc_token_bundle(self._gateway_dir)
1348
+ if self._bundle is None:
1349
+ raise SandboxError(
1350
+ f"OIDC token for gateway '{self._cluster_name}' is "
1351
+ f"missing or unreadable. Re-authenticate with: "
1352
+ f"openshell gateway login"
1353
+ )
1354
+ if self._is_fresh(self._bundle):
1355
+ return self._bundle["access_token"]
1356
+ # Cached bundle is stale. Before refreshing, re-read disk —
1357
+ # another process (CLI, TUI, another SDK client) may have
1358
+ # rotated the bundle while we were idle. Adopt the disk
1359
+ # bundle when it was refreshed more recently than ours, EVEN
1360
+ # WHEN its access token is also stale: otherwise we'd refresh
1361
+ # with our in-memory refresh_token, which a rotating IdP may
1362
+ # have already invalidated when the other process refreshed
1363
+ # (Keycloak with rotation, Entra in strict mode).
1364
+ #
1365
+ # "More recently" is judged by `expires_at`: a refresh issues
1366
+ # a new access token with a forward expiry alongside the
1367
+ # (possibly rotated) refresh_token, so the bundle with the
1368
+ # later expiry carries the newest refresh_token. This also
1369
+ # preserves the write_back=False case, where our in-memory
1370
+ # bundle has already rotated past the on-disk one and must
1371
+ # NOT be clobbered by the older disk copy.
1372
+ disk = _read_oidc_token_bundle(self._gateway_dir)
1373
+ if disk is not None and self._expiry(disk) > self._expiry(self._bundle):
1374
+ # If the issuer changed under us, the cached token
1375
+ # endpoint no longer applies — force re-discovery.
1376
+ if _normalize_issuer(disk) != _normalize_issuer(self._bundle):
1377
+ self._token_endpoint = None
1378
+ self._bundle = disk
1379
+ if self._is_fresh(disk):
1380
+ return disk["access_token"]
1381
+ # Truly stale; refresh against the IdP using the freshest
1382
+ # bundle we have (disk if it was newer, else in-memory).
1383
+ try:
1384
+ self._bundle = self._refresh(self._bundle)
1385
+ except _InvalidGrantError as exc:
1386
+ # We lost a cross-process rotation race: between our disk
1387
+ # re-read above and our refresh POST, a peer (CLI, TUI,
1388
+ # another SDK client) rotated the refresh_token and the
1389
+ # IdP invalidated ours. This is the residual window that
1390
+ # neither google-auth nor botocore close without an OS
1391
+ # file lock. Rather than lock, recover: re-read disk once
1392
+ # and, if a peer wrote a *different* refresh_token, retry
1393
+ # with it before surfacing a re-authenticate error.
1394
+ self._bundle = self._recover_from_invalid_grant(self._bundle, exc)
1395
+ if self._write_back:
1396
+ self._write_to_disk(self._bundle)
1397
+ return self._bundle["access_token"]
1398
+
1399
+ def _recover_from_invalid_grant(
1400
+ self, attempted: dict, exc: _InvalidGrantError
1401
+ ) -> dict:
1402
+ """Re-read disk after an `invalid_grant` and retry once if a peer
1403
+ rotated the refresh_token.
1404
+
1405
+ `attempted` is the bundle whose refresh_token the IdP just
1406
+ rejected. If disk now holds a different refresh_token, a
1407
+ concurrent process won the rotation race — adopt and reuse it
1408
+ (returning early if it is already fresh, otherwise refreshing
1409
+ with it). If disk offers nothing new, the rejection is genuine:
1410
+ re-raise so the caller sees the re-authenticate hint.
1411
+ """
1412
+ disk = _read_oidc_token_bundle(self._gateway_dir)
1413
+ if disk is None or disk.get("refresh_token") == attempted.get("refresh_token"):
1414
+ # No peer rotation — the refresh_token really is dead.
1415
+ raise exc
1416
+ if _normalize_issuer(disk) != _normalize_issuer(attempted):
1417
+ self._token_endpoint = None
1418
+ if self._is_fresh(disk):
1419
+ return disk
1420
+ # Single retry with the peer's rotated token; a second
1421
+ # _InvalidGrantError here propagates (no further retry).
1422
+ return self._refresh(disk)
1423
+
1424
+ @staticmethod
1425
+ def _is_fresh(bundle: dict) -> bool:
1426
+ access_token = bundle.get("access_token")
1427
+ if not isinstance(access_token, str) or not access_token:
1428
+ return False
1429
+ exp = bundle.get("expires_at")
1430
+ if not isinstance(exp, int):
1431
+ # No expiry info — treat as fresh (matches the
1432
+ # `is_token_expired` semantics in the Rust CLI).
1433
+ return True
1434
+ return int(time.time()) + _OIDC_TOKEN_EXPIRY_GRACE_SECONDS < exp
1435
+
1436
+ @staticmethod
1437
+ def _expiry(bundle: dict) -> float:
1438
+ """Access-token expiry as a comparable number.
1439
+
1440
+ A bundle without an `expires_at` is treated as non-expiring
1441
+ (`+inf`) — consistent with `_is_fresh`, which treats a missing
1442
+ expiry as always fresh. Used to decide which of two stale
1443
+ bundles (in-memory vs. on-disk) was refreshed more recently and
1444
+ therefore holds the newest refresh_token.
1445
+ """
1446
+ exp = bundle.get("expires_at")
1447
+ return float(exp) if isinstance(exp, int) else float("inf")
1448
+
1449
+ def _discover_token_endpoint(self, bundle: dict) -> str:
1450
+ if self._token_endpoint is not None:
1451
+ return self._token_endpoint
1452
+ issuer = bundle.get("issuer")
1453
+ if not isinstance(issuer, str) or not issuer:
1454
+ raise SandboxError(
1455
+ f"OIDC bundle for gateway '{self._cluster_name}' has no "
1456
+ f"`issuer`; cannot refresh. Re-authenticate with: openshell "
1457
+ f"gateway login"
1458
+ )
1459
+ normalized_issuer = issuer.rstrip("/")
1460
+ discovery_url = f"{normalized_issuer}/.well-known/openid-configuration"
1461
+ try:
1462
+ resp = self._http.get(discovery_url)
1463
+ except httpx.HTTPError as e:
1464
+ raise SandboxError(
1465
+ f"OIDC discovery failed for gateway "
1466
+ f"'{self._cluster_name}': {e}. Re-authenticate with: "
1467
+ f"openshell gateway login"
1468
+ ) from e
1469
+ # follow_redirects=False means a 3xx surfaces as a non-2xx
1470
+ # status; treat any non-success as a discovery failure rather
1471
+ # than silently following.
1472
+ if not 200 <= resp.status_code < 300:
1473
+ raise SandboxError(
1474
+ f"OIDC discovery failed for gateway "
1475
+ f"'{self._cluster_name}': HTTP {resp.status_code} "
1476
+ f"from {discovery_url}. Re-authenticate with: openshell "
1477
+ f"gateway login"
1478
+ )
1479
+ try:
1480
+ disco = resp.json()
1481
+ except ValueError as e:
1482
+ raise SandboxError(
1483
+ f"OIDC discovery returned invalid JSON for gateway "
1484
+ f"'{self._cluster_name}': {e}"
1485
+ ) from e
1486
+ # Critical: validate that the discovery document's `issuer`
1487
+ # matches the configured one. Without this, a misdirected or
1488
+ # malicious discovery response could steer the refresh_token
1489
+ # POST to an attacker-controlled endpoint.
1490
+ discovered_issuer = disco.get("issuer", "")
1491
+ if not isinstance(discovered_issuer, str) or (
1492
+ discovered_issuer.rstrip("/") != normalized_issuer
1493
+ ):
1494
+ raise SandboxError(
1495
+ f"OIDC discovery issuer mismatch for gateway "
1496
+ f"'{self._cluster_name}': expected '{normalized_issuer}', "
1497
+ f"got '{discovered_issuer}'."
1498
+ )
1499
+ endpoint = disco.get("token_endpoint")
1500
+ if not isinstance(endpoint, str) or not endpoint:
1501
+ raise SandboxError(
1502
+ f"OIDC discovery for gateway '{self._cluster_name}' did "
1503
+ f"not include a token_endpoint."
1504
+ )
1505
+ self._token_endpoint = endpoint
1506
+ return endpoint
1507
+
1508
+ def _refresh(self, bundle: dict) -> dict:
1509
+ refresh_token = bundle.get("refresh_token")
1510
+ if not isinstance(refresh_token, str) or not refresh_token:
1511
+ raise SandboxError(
1512
+ f"OIDC token for gateway '{self._cluster_name}' has no "
1513
+ f"refresh token. Re-authenticate with: openshell gateway "
1514
+ f"login"
1515
+ )
1516
+ token_endpoint = self._discover_token_endpoint(bundle)
1517
+ client_id = bundle.get("client_id", "openshell-cli")
1518
+
1519
+ # RFC 6749 §6: refresh_token grant. Form-encoded POST with
1520
+ # grant_type, refresh_token, and (for a public client) client_id.
1521
+ # No Authorization header (token_endpoint_auth_method="none").
1522
+ try:
1523
+ resp = self._http.post(
1524
+ token_endpoint,
1525
+ data={
1526
+ "grant_type": "refresh_token",
1527
+ "refresh_token": refresh_token,
1528
+ "client_id": client_id,
1529
+ },
1530
+ )
1531
+ except httpx.HTTPError as e:
1532
+ raise SandboxError(
1533
+ f"OIDC token refresh failed for gateway "
1534
+ f"'{self._cluster_name}': {type(e).__name__}: {e}. "
1535
+ f"Re-authenticate with: openshell gateway login"
1536
+ ) from e
1537
+ if resp.status_code != 200:
1538
+ # Include the IdP's error body for diagnostics — RFC 6749
1539
+ # mandates a JSON body like {"error":"invalid_grant", ...}
1540
+ # on failure, which is the most useful signal to surface.
1541
+ error_code = None
1542
+ with contextlib.suppress(Exception):
1543
+ body = resp.json()
1544
+ if isinstance(body, dict):
1545
+ error_code = body.get("error")
1546
+ detail = ""
1547
+ with contextlib.suppress(Exception):
1548
+ detail = f": {resp.text[:200]}"
1549
+ message = (
1550
+ f"OIDC token refresh failed for gateway "
1551
+ f"'{self._cluster_name}': HTTP {resp.status_code}"
1552
+ f"{detail}. Re-authenticate with: openshell gateway "
1553
+ f"login"
1554
+ )
1555
+ # `invalid_grant` specifically means the refresh_token was
1556
+ # rejected — distinguished from transport/5xx errors so the
1557
+ # caller can retry once with a peer-rotated bundle (a lost
1558
+ # cross-process rotation race) before surfacing the failure.
1559
+ if error_code == "invalid_grant":
1560
+ raise _InvalidGrantError(message)
1561
+ raise SandboxError(message)
1562
+ try:
1563
+ token = resp.json()
1564
+ except ValueError as e:
1565
+ raise SandboxError(
1566
+ f"OIDC refresh response for gateway '{self._cluster_name}' "
1567
+ f"is not JSON: {e}"
1568
+ ) from e
1569
+
1570
+ access_token = token.get("access_token")
1571
+ if not isinstance(access_token, str) or not access_token:
1572
+ raise SandboxError(
1573
+ f"OIDC refresh response for gateway '{self._cluster_name}' "
1574
+ f"is missing access_token."
1575
+ )
1576
+ expires_at = token.get("expires_at")
1577
+ if expires_at is None:
1578
+ expires_in = token.get("expires_in")
1579
+ if isinstance(expires_in, (int, float)):
1580
+ expires_at = int(time.time()) + int(expires_in)
1581
+ return {
1582
+ "access_token": access_token,
1583
+ # Refresh-token rotation: some IdPs (Keycloak with rotation
1584
+ # enabled, Entra in strict mode) reissue and invalidate the
1585
+ # old one. Honor the new value when present.
1586
+ "refresh_token": token.get("refresh_token", refresh_token),
1587
+ "expires_at": int(expires_at) if expires_at is not None else None,
1588
+ "issuer": bundle.get("issuer", ""),
1589
+ "client_id": client_id,
1590
+ }
1591
+
1592
+ def _write_to_disk(self, bundle: dict) -> None:
1593
+ """Atomic-replace `oidc_token.json` with the refreshed bundle.
1594
+
1595
+ Strips `None` values to match the Rust writer's
1596
+ `skip_serializing_if = "Option::is_none"` behavior so a Python-
1597
+ written file is byte-identical in shape to what the CLI writes.
1598
+
1599
+ Uses `tempfile.mkstemp` (PID + random suffix) so two writers
1600
+ racing on the same gateway directory don't share a tmp file
1601
+ and trample each other's content. Each writer gets a unique
1602
+ path; `.replace()` is atomic per-writer, and POSIX rename
1603
+ semantics ensure the final `oidc_token.json` is always
1604
+ complete-and-readable to anyone observing.
1605
+ """
1606
+ path = self._gateway_dir / "oidc_token.json"
1607
+ serializable = {k: v for k, v in bundle.items() if v is not None}
1608
+ payload = json.dumps(serializable, indent=2)
1609
+
1610
+ # mkstemp creates the file with mode 0600 already on POSIX
1611
+ # (it uses O_CREAT | O_EXCL with restrictive umask), so chmod
1612
+ # is a belt-and-braces step for filesystems that don't honor
1613
+ # the initial mode.
1614
+ fd, tmp_name = tempfile.mkstemp(
1615
+ prefix=".oidc_token.",
1616
+ suffix=".tmp",
1617
+ dir=str(self._gateway_dir),
1618
+ )
1619
+ tmp_path = pathlib.Path(tmp_name)
1620
+ try:
1621
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
1622
+ f.write(payload)
1623
+ with contextlib.suppress(OSError):
1624
+ tmp_path.chmod(0o600)
1625
+ _atomic_replace(tmp_path, path)
1626
+ except BaseException:
1627
+ # Clean up our tmp on failure so we don't leave orphaned
1628
+ # `.oidc_token.<rand>.tmp` files lying around. The replace
1629
+ # already moved the file on the success path.
1630
+ with contextlib.suppress(OSError):
1631
+ tmp_path.unlink()
1632
+ raise
1633
+
1634
+
1635
+ def _make_cluster_bearer_provider(
1636
+ gateway_dir: pathlib.Path,
1637
+ cluster_name: str,
1638
+ *,
1639
+ auto_refresh: bool = True,
1640
+ write_back: bool = True,
1641
+ insecure: bool = False,
1642
+ ) -> tuple[Callable[[], str], Callable[[], None] | None]:
1643
+ """Build a per-RPC token provider for a gateway directory.
1644
+
1645
+ Returns `(token_provider, close_fn_or_none)`. `close_fn` is non-None
1646
+ only when an `_OidcRefresher` was constructed; callers that own the
1647
+ provider's lifecycle (e.g. `SandboxClient.close()`) should invoke
1648
+ it during teardown so the underlying httpx.Client is released
1649
+ rather than relying on `__del__`.
1650
+
1651
+ With `auto_refresh=True` (the default), returns an `_OidcRefresher`-
1652
+ backed callable that lazily refreshes against the IdP's token endpoint
1653
+ when the cached bundle is stale. This mirrors the lazy-refresh pattern
1654
+ used by `google.oauth2.credentials.Credentials` and
1655
+ `botocore.tokens.SSOTokenProvider` and lets long-running scripts
1656
+ survive token rotation without intervention.
1657
+
1658
+ With `auto_refresh=False`, falls back to the read-only / fail-closed
1659
+ behavior: the SDK consumes whatever the CLI most recently wrote and
1660
+ raises `SandboxError` when the token expires. Useful for tests or
1661
+ callers that don't want the SDK to make outbound HTTP calls to the
1662
+ IdP. No close_fn is returned in this case.
1663
+
1664
+ `write_back=True` (only meaningful when `auto_refresh=True`) makes the
1665
+ refresher atomically persist the rotated bundle back to
1666
+ `oidc_token.json` so other processes — including the Rust CLI — see
1667
+ the new token. Defaults to True because OIDC providers with
1668
+ refresh-token rotation (Keycloak, Entra) invalidate the old
1669
+ refresh_token on rotation; an in-memory-only refresh would leave the
1670
+ on-disk bundle pointing at an invalidated value, and any other
1671
+ process starting from that disk state would fail on its first
1672
+ refresh.
1673
+
1674
+ `insecure=True` disables TLS certificate verification for both the
1675
+ OIDC discovery document fetch and the refresh-token POST. Mirrors
1676
+ the Rust CLI's `--insecure` flag for OIDC issuers behind self-signed
1677
+ certs.
1678
+ """
1679
+ if not auto_refresh:
1680
+ return _make_fail_closed_bearer_provider(gateway_dir, cluster_name), None
1681
+ refresher = _OidcRefresher(
1682
+ gateway_dir,
1683
+ cluster_name,
1684
+ write_back=write_back,
1685
+ insecure=insecure,
1686
+ )
1687
+ return refresher.current_access_token, refresher.close
1688
+
1689
+
1690
+ def _resolve_active_cluster() -> str:
1691
+ env_gateway = os.environ.get("OPENSHELL_GATEWAY")
1692
+ if env_gateway:
1693
+ return env_gateway
1694
+ active_file = _xdg_config_home() / "openshell" / "active_gateway"
1695
+ try:
1696
+ value = active_file.read_text(encoding="utf-8").strip()
1697
+ except FileNotFoundError:
1698
+ raise SandboxError("no active gateway configured") from None
1699
+ if value == "":
1700
+ raise SandboxError("no active gateway configured")
1701
+ return value