Files
OpenShell/python/openshell/sandbox_test.py
Mesut Oezdil b689c82075 fix(python): add encoding=utf-8 to file reads and writes in sandbox.py (#1912)
* fix(python): add encoding=utf-8 to all file reads and writes in sandbox.py

read_text(), fdopen() without encoding use the system locale, which can
differ on non-UTF-8 systems. All config files (metadata.json,
oidc_token.json, active_gateway) are UTF-8 text.

* fix(python): catch UnicodeDecodeError in _read_oidc_token_bundle, add encoding tests

Corrupt or non-UTF-8 oidc_token.json now returns None consistently.
Add regression tests for non-ASCII UTF-8 paths in metadata, oidc token,
and active_gateway files.

* fix(python): use ensure_ascii=False in encoding tests, add from_active_cluster UTF-8 bytes test

* fix(python): assert non-ASCII cluster name and endpoint in encoding test

* fix(python): wrap long write_bytes line for ruff format

* fix(python): apply ruff format to sandbox_test.py
2026-06-22 12:35:11 -05:00

1416 lines
49 KiB
Python

# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import json
import os
import threading
import time
from pathlib import Path
from typing import Any, cast
from openshell._proto import openshell_pb2
from openshell.sandbox import (
_PYTHON_CLOUDPICKLE_BOOTSTRAP,
_SANDBOX_PYTHON_BIN,
InferenceRouteClient,
Sandbox,
SandboxClient,
SandboxError,
TlsConfig,
_BearerAuthInterceptor,
_load_cluster_bearer_token,
_make_cluster_bearer_provider,
_normalize_bearer,
_OidcRefresher,
_read_oidc_token_bundle,
)
class _FakeStub:
def __init__(self) -> None:
self.request: openshell_pb2.ExecSandboxRequest | None = None
def ExecSandbox(
self,
request: openshell_pb2.ExecSandboxRequest,
timeout: float | None = None,
):
self.request = request
_ = timeout
yield openshell_pb2.ExecSandboxEvent(
exit=openshell_pb2.ExecSandboxExit(exit_code=0)
)
class _FakeInferenceStub:
def __init__(self) -> None:
self.request = None
def SetClusterInference(self, request: Any, timeout: float | None = None) -> Any:
self.request = request
_ = timeout
class _Response:
provider_name = request.provider_name
model_id = request.model_id
version = 1
return _Response()
def _client_with_fake_stub(stub: _FakeStub) -> SandboxClient:
client = cast("SandboxClient", object.__new__(SandboxClient))
client._timeout = 30.0
client._stub = cast("Any", stub)
return client
def test_exec_sends_stdin_payload() -> None:
stub = _FakeStub()
client = _client_with_fake_stub(stub)
result = client.exec("sandbox-1", ["python", "-c", "print('ok')"], stdin=b"payload")
assert result.exit_code == 0
assert stub.request is not None
assert stub.request.stdin == b"payload"
def test_exec_python_serializes_callable_payload() -> None:
stub = _FakeStub()
client = _client_with_fake_stub(stub)
def add(a: int, b: int) -> int:
return a + b
result = client.exec_python("sandbox-1", add, args=(2, 3))
assert result.exit_code == 0
assert stub.request is not None
assert stub.request.command == [
_SANDBOX_PYTHON_BIN,
"-c",
_PYTHON_CLOUDPICKLE_BOOTSTRAP,
]
assert stub.request.environment["OPENSHELL_PYFUNC_B64"]
assert stub.request.stdin == b""
def test_from_active_cluster_reads_gateway_metadata_layout(
tmp_path: Path,
monkeypatch: Any,
) -> None:
gateway_name = "test-gateway"
gateway_dir = tmp_path / "openshell" / "gateways" / gateway_name
mtls_dir = gateway_dir / "mtls"
mtls_dir.mkdir(parents=True)
(tmp_path / "openshell" / "active_gateway").write_text(gateway_name)
(gateway_dir / "metadata.json").write_text(
json.dumps({"gateway_endpoint": "https://127.0.0.1:8443"})
)
(mtls_dir / "ca.crt").write_text("ca")
(mtls_dir / "tls.crt").write_text("cert")
(mtls_dir / "tls.key").write_text("key")
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
monkeypatch.delenv("OPENSHELL_GATEWAY", raising=False)
client = SandboxClient.from_active_cluster()
try:
assert client._cluster_name == gateway_name
finally:
client.close()
def test_from_active_cluster_prefers_openshell_gateway_env(
tmp_path: Path,
monkeypatch: Any,
) -> None:
gateway_name = "env-gateway"
gateway_dir = tmp_path / "openshell" / "gateways" / gateway_name
mtls_dir = gateway_dir / "mtls"
mtls_dir.mkdir(parents=True)
(gateway_dir / "metadata.json").write_text(
json.dumps({"gateway_endpoint": "https://127.0.0.1:8443"})
)
(mtls_dir / "ca.crt").write_text("ca")
(mtls_dir / "tls.crt").write_text("cert")
(mtls_dir / "tls.key").write_text("key")
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
monkeypatch.setenv("OPENSHELL_GATEWAY", gateway_name)
client = SandboxClient.from_active_cluster()
try:
assert client._cluster_name == gateway_name
finally:
client.close()
# ---------------------------------------------------------------------------
# OIDC bearer auth
# ---------------------------------------------------------------------------
class _FakeClientCallDetails:
"""grpc.ClientCallDetails is a NamedTuple in real gRPC; for unit tests we
just need an object with the same field set and a ._replace shim."""
__slots__ = ("credentials", "metadata", "method", "timeout", "wait_for_ready")
def __init__(
self,
method: str = "/Test/Method",
timeout: float | None = None,
metadata: Any = None,
credentials: Any = None,
wait_for_ready: Any = None,
) -> None:
self.method = method
self.timeout = timeout
self.metadata = metadata
self.credentials = credentials
self.wait_for_ready = wait_for_ready
def _replace(self, **kwargs: Any) -> _FakeClientCallDetails:
return _FakeClientCallDetails(
method=kwargs.get("method", self.method),
timeout=kwargs.get("timeout", self.timeout),
metadata=kwargs.get("metadata", self.metadata),
credentials=kwargs.get("credentials", self.credentials),
wait_for_ready=kwargs.get("wait_for_ready", self.wait_for_ready),
)
def test_normalize_bearer_accepts_str_or_callable() -> None:
assert _normalize_bearer(None) is None
static = _normalize_bearer("abc")
assert static is not None
assert static() == "abc"
counter = [0]
def provider() -> str:
counter[0] += 1
return f"token-{counter[0]}"
dynamic = _normalize_bearer(provider)
assert dynamic is not None
assert dynamic() == "token-1"
assert dynamic() == "token-2"
def test_bearer_interceptor_attaches_authorization_header() -> None:
interceptor = _BearerAuthInterceptor(lambda: "secret-token")
captured: dict[str, Any] = {}
def continuation(details: Any, request: Any) -> str:
captured["details"] = details
captured["request"] = request
return "result"
details = _FakeClientCallDetails(metadata=[("x-existing", "yes")])
result = interceptor.intercept_unary_unary(continuation, details, "payload")
assert result == "result"
md = list(captured["details"].metadata)
# Pre-existing metadata preserved, authorization appended last.
assert ("x-existing", "yes") in md
assert ("authorization", "Bearer secret-token") in md
assert captured["request"] == "payload"
def test_bearer_interceptor_handles_empty_metadata() -> None:
interceptor = _BearerAuthInterceptor(lambda: "t")
captured: dict[str, Any] = {}
def continuation(details: Any, _request: Any) -> None:
captured["metadata"] = list(details.metadata)
details = _FakeClientCallDetails(metadata=None)
interceptor.intercept_unary_unary(continuation, details, request="x")
assert captured["metadata"] == [("authorization", "Bearer t")]
def test_bearer_interceptor_calls_token_provider_per_request() -> None:
tokens = iter(["t1", "t2", "t3"])
interceptor = _BearerAuthInterceptor(lambda: next(tokens))
seen: list[str] = []
def continuation(details: Any, _request: Any) -> None:
for key, value in details.metadata:
if key == "authorization":
seen.append(value)
for _ in range(3):
interceptor.intercept_unary_unary(
continuation, _FakeClientCallDetails(), request="x"
)
assert seen == ["Bearer t1", "Bearer t2", "Bearer t3"]
def test_load_cluster_bearer_token_reads_oidc_token_json(tmp_path: Path) -> None:
gateway_dir = tmp_path / "gw"
gateway_dir.mkdir()
(gateway_dir / "oidc_token.json").write_text(
json.dumps(
{
"access_token": "jwt-blob",
"refresh_token": "rt",
"expires_at": 9999999999,
"issuer": "https://idp.example/realms/openshell",
"client_id": "openshell-cli",
}
)
)
assert _load_cluster_bearer_token(gateway_dir) == "jwt-blob"
def test_load_cluster_bearer_token_returns_none_when_missing(
tmp_path: Path,
) -> None:
assert _load_cluster_bearer_token(tmp_path / "absent") is None
def test_load_cluster_bearer_token_tolerates_unreadable_file(
tmp_path: Path,
) -> None:
gateway_dir = tmp_path / "gw"
gateway_dir.mkdir()
(gateway_dir / "oidc_token.json").write_text("not json")
assert _load_cluster_bearer_token(gateway_dir) is None
def test_load_cluster_bearer_token_rejects_missing_access_token(
tmp_path: Path,
) -> None:
gateway_dir = tmp_path / "gw"
gateway_dir.mkdir()
(gateway_dir / "oidc_token.json").write_text(json.dumps({"refresh_token": "rt"}))
assert _load_cluster_bearer_token(gateway_dir) is None
def _setup_gateway_dir(
tmp_path: Path,
monkeypatch: Any,
*,
name: str = "g",
endpoint: str = "http://127.0.0.1:8080",
auth_mode: str | None = None,
mtls_files: dict[str, str] | None = None,
oidc_bundle: dict | None = None,
) -> Path:
gateway_dir = tmp_path / "openshell" / "gateways" / name
gateway_dir.mkdir(parents=True)
(tmp_path / "openshell" / "active_gateway").write_text(name)
meta: dict[str, Any] = {"gateway_endpoint": endpoint}
if auth_mode is not None:
meta["auth_mode"] = auth_mode
(gateway_dir / "metadata.json").write_text(json.dumps(meta))
if mtls_files:
mtls_dir = gateway_dir / "mtls"
mtls_dir.mkdir()
for fname, body in mtls_files.items():
(mtls_dir / fname).write_text(body)
if oidc_bundle is not None:
(gateway_dir / "oidc_token.json").write_text(json.dumps(oidc_bundle))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
monkeypatch.delenv("OPENSHELL_GATEWAY", raising=False)
return gateway_dir
def _channel_is_intercepted(channel: Any) -> bool:
"""grpc.intercept_channel returns a _Channel whose module name ends in
`interceptor`. We don't depend on the class name (it varies across
gRPC versions); module is stable."""
return type(channel).__module__.endswith("interceptor")
def test_from_active_cluster_loads_bearer_when_auth_mode_is_oidc(
tmp_path: Path,
monkeypatch: Any,
) -> None:
"""Finding 3: bearer is attached iff metadata.auth_mode == "oidc"."""
_setup_gateway_dir(
tmp_path,
monkeypatch,
auth_mode="oidc",
oidc_bundle={"access_token": "from-disk"},
)
client = SandboxClient.from_active_cluster()
try:
assert _channel_is_intercepted(client._channel)
finally:
client.close()
def test_from_active_cluster_ignores_stale_token_when_auth_mode_not_oidc(
tmp_path: Path,
monkeypatch: Any,
) -> None:
"""Finding 3: a stale oidc_token.json alongside a non-OIDC gateway must
NOT cause bearer auth to be attached."""
_setup_gateway_dir(
tmp_path,
monkeypatch,
# auth_mode omitted (or "mtls", "plaintext") — anything but "oidc".
oidc_bundle={"access_token": "stale-from-disk"},
)
client = SandboxClient.from_active_cluster()
try:
# Plain channel, no interceptor wrapper.
assert not _channel_is_intercepted(client._channel)
finally:
client.close()
def test_from_active_cluster_https_oidc_without_mtls_uses_tls_with_system_roots(
tmp_path: Path,
monkeypatch: Any,
) -> None:
"""Finding 1: https OIDC gateways without mTLS material must still use a
TLS channel (system roots) — NOT fall back to insecure_channel."""
_setup_gateway_dir(
tmp_path,
monkeypatch,
endpoint="https://gateway.example:443",
auth_mode="oidc",
oidc_bundle={"access_token": "t"},
)
client = SandboxClient.from_active_cluster()
try:
# The bearer interceptor wraps the channel, so inspect the
# wrapped channel's class to confirm it's a secure (TLS) channel.
inner = getattr(client._channel, "_channel", client._channel)
# gRPC's `grpc.secure_channel` returns a `_Channel` from
# `grpc._channel`; we can't trivially introspect "secure" vs
# "insecure" on the wrapper itself. Probe by attempting to
# extract the connectivity state — both kinds expose it — and
# rely on a behavioral assertion: an insecure channel against
# a hostname-only endpoint would have already attached TCP-only
# subchannels. Easier: verify TlsConfig() was used by checking
# the SandboxClient endpoint normalized correctly.
# The most direct assertion is on the client config:
assert client._endpoint == "gateway.example:443"
# And the channel must not be insecure.
assert "InsecureChannelCredentials" not in repr(inner)
finally:
client.close()
def test_from_active_cluster_https_ca_only_layout(
tmp_path: Path,
monkeypatch: Any,
) -> None:
"""Finding 1: a CA-only mtls directory (ca.crt but no tls.crt/tls.key)
must produce a CA-only TLS channel, not a FileNotFoundError."""
_setup_gateway_dir(
tmp_path,
monkeypatch,
endpoint="https://gateway.example:443",
auth_mode="oidc",
mtls_files={
"ca.crt": "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n"
},
oidc_bundle={"access_token": "t"},
)
# Should not raise.
client = SandboxClient.from_active_cluster()
try:
assert client._endpoint == "gateway.example:443"
finally:
client.close()
def test_tls_config_rejects_partial_client_identity() -> None:
"""Cert without key (or vice versa) is a misconfiguration."""
import pytest as _pytest
with _pytest.raises(ValueError, match="cert_path and key_path"):
TlsConfig(cert_path=Path("/x.crt"))
def test_tls_config_allows_empty_for_system_roots() -> None:
"""`TlsConfig()` is the system-roots-trust flavor."""
cfg = TlsConfig()
assert cfg.ca_path is None and cfg.cert_path is None and cfg.key_path is None
# ---------------------------------------------------------------------------
# Provider semantics: per-RPC reload + expiry
# ---------------------------------------------------------------------------
def test_cluster_bearer_provider_reloads_on_every_call(tmp_path: Path) -> None:
"""The fail-closed (no-refresh) provider re-reads oidc_token.json each
invocation, so a long-lived SandboxClient picks up CLI rotations
without reconstruction."""
gateway_dir = tmp_path
token_file = gateway_dir / "oidc_token.json"
token_file.write_text(json.dumps({"access_token": "first"}))
provider, _ = _make_cluster_bearer_provider(gateway_dir, "g", auto_refresh=False)
assert provider() == "first"
# Simulate `openshell gateway login` writing a new token.
token_file.write_text(json.dumps({"access_token": "second"}))
assert provider() == "second"
def test_cluster_bearer_provider_raises_on_expired_token(tmp_path: Path) -> None:
"""Fail-closed provider raises on expiry with a clear re-login hint."""
gateway_dir = tmp_path
(gateway_dir / "oidc_token.json").write_text(
json.dumps({"access_token": "expired", "expires_at": 1})
)
provider, _ = _make_cluster_bearer_provider(
gateway_dir, "stale-gateway", auto_refresh=False
)
import pytest as _pytest
with _pytest.raises(SandboxError, match="expired"):
provider()
def test_cluster_bearer_provider_raises_when_file_missing(tmp_path: Path) -> None:
provider, _ = _make_cluster_bearer_provider(
tmp_path / "absent", "g", auto_refresh=False
)
import pytest as _pytest
with _pytest.raises(SandboxError, match="missing or unreadable"):
provider()
def test_cluster_bearer_provider_raises_on_missing_access_token(
tmp_path: Path,
) -> None:
(tmp_path / "oidc_token.json").write_text(json.dumps({"refresh_token": "r"}))
provider, _ = _make_cluster_bearer_provider(tmp_path, "g", auto_refresh=False)
import pytest as _pytest
with _pytest.raises(SandboxError, match="no access token"):
provider()
# ---------------------------------------------------------------------------
# OAuth2 native refresh (_OidcRefresher) — opt-in via auto_refresh=True.
# ---------------------------------------------------------------------------
def _write_bundle(
gateway_dir: Path,
*,
access_token: str = "fresh",
refresh_token: str = "r-orig",
expires_at: int | None = None,
issuer: str = "https://idp.example/realms/openshell",
client_id: str = "openshell-cli",
) -> None:
bundle: dict[str, Any] = {
"access_token": access_token,
"refresh_token": refresh_token,
"issuer": issuer,
"client_id": client_id,
}
if expires_at is not None:
bundle["expires_at"] = expires_at
(gateway_dir / "oidc_token.json").write_text(json.dumps(bundle))
DEFAULT_ISSUER = "https://idp.example/realms/openshell"
DEFAULT_TOKEN_ENDPOINT = (
"https://idp.example/realms/openshell/protocol/openid-connect/token"
)
def _make_mock_transport(
*,
discovery: dict | None = None,
refresh_responses: list[dict] | None = None,
discovery_status: int = 200,
refresh_status: int = 200,
seen_refresh: list[Any] | None = None,
seen_discovery: list[Any] | None = None,
):
"""Build an httpx.MockTransport that serves OIDC discovery + token
refresh from an in-memory script.
`refresh_responses` is consumed in order across successive POSTs to
the token endpoint (which lets tests assert refresh-token rotation
semantics across multiple refreshes).
"""
import httpx as _httpx
refresh_iter = iter(
refresh_responses or [{"access_token": "refreshed-jwt", "expires_in": 3600}]
)
def handler(request: _httpx.Request) -> _httpx.Response:
if request.url.path.endswith("/.well-known/openid-configuration"):
if seen_discovery is not None:
seen_discovery.append(str(request.url))
body = discovery or {
"issuer": DEFAULT_ISSUER,
"token_endpoint": DEFAULT_TOKEN_ENDPOINT,
}
return _httpx.Response(discovery_status, json=body)
# Anything else is a refresh exchange.
if seen_refresh is not None:
seen_refresh.append((str(request.url), bytes(request.content)))
try:
body = next(refresh_iter)
except StopIteration:
return _httpx.Response(500, json={"error": "test_script_exhausted"})
return _httpx.Response(refresh_status, json=body)
return _httpx.MockTransport(handler)
def _install_mock_transport(refresher: Any, transport: Any) -> None:
"""Swap the refresher's httpx.Client for one bound to a mock transport.
We rebuild with `follow_redirects=False` so the redirect-rejection
test still exercises the real policy.
"""
import httpx as _httpx
refresher._http.close()
refresher._http = _httpx.Client(transport=transport, follow_redirects=False)
def test_refresher_returns_cached_token_when_fresh(tmp_path: Path) -> None:
"""No refresh round-trip when the cached bundle is still fresh."""
_write_bundle(tmp_path, expires_at=int(time.time()) + 3600)
seen: list[Any] = []
transport = _make_mock_transport(
seen_discovery=seen,
seen_refresh=seen,
)
r = _OidcRefresher(tmp_path, "g")
_install_mock_transport(r, transport)
assert r.current_access_token() == "fresh"
assert seen == [] # no discovery, no refresh
def test_refresher_picks_up_disk_rotation_before_refreshing(
tmp_path: Path,
) -> None:
"""If the in-memory bundle is stale but the CLI just wrote a fresh one,
re-read disk instead of hitting the IdP."""
_write_bundle(tmp_path, access_token="old", expires_at=1)
seen_refresh: list[Any] = []
transport = _make_mock_transport(seen_refresh=seen_refresh)
r = _OidcRefresher(tmp_path, "g", write_back=False)
_install_mock_transport(r, transport)
# First call: refresh against IdP — exercise that path first.
assert r.current_access_token() == "refreshed-jwt"
assert len(seen_refresh) == 1
# Now simulate the CLI writing a fresh bundle. Force the in-memory
# state to look stale so the disk re-read path triggers.
_write_bundle(
tmp_path, access_token="cli-rotated", expires_at=int(time.time()) + 3600
)
r._bundle = {
"access_token": "stale-in-memory",
"expires_at": 1,
"refresh_token": "r",
}
# Replace the transport with one that asserts on any request.
import httpx as _httpx
def assert_no_calls(_req: _httpx.Request) -> _httpx.Response:
raise AssertionError("should not refresh — disk was fresh")
_install_mock_transport(r, _httpx.MockTransport(assert_no_calls))
assert r.current_access_token() == "cli-rotated"
def test_refresher_adopts_stale_disk_refresh_token_before_refreshing(
tmp_path: Path,
) -> None:
"""Regression: when both the in-memory and on-disk access tokens are
stale but another process rotated the on-disk refresh_token, refresh
with the disk refresh_token (r2), not the invalidated in-memory one (r1).
Without this, a rotating IdP (Keycloak with rotation, Entra strict) would
invalid_grant because process A still holds the pre-rotation r1.
"""
# Disk holds a rotated-but-stale bundle (r2) written by another process.
# Its access token was minted more recently than ours (later expiry,
# though still inside the grace window), so disk carries the newer
# refresh_token even though both are due for refresh.
disk_exp = int(time.time()) + 5
_write_bundle(
tmp_path, access_token="disk-old", expires_at=disk_exp, refresh_token="r2"
)
seen: list[Any] = []
transport = _make_mock_transport(
refresh_responses=[
{"access_token": "a-new", "refresh_token": "r3", "expires_in": 3600},
],
seen_refresh=seen,
)
r = _OidcRefresher(tmp_path, "g", write_back=False)
_install_mock_transport(r, transport)
# Seed older stale in-memory state holding the pre-rotation token r1.
r._bundle = {
"access_token": "mem-old",
"expires_at": 1,
"refresh_token": "r1",
"issuer": DEFAULT_ISSUER,
}
assert r.current_access_token() == "a-new"
# The refresh POST must carry the disk's r2, never the stale r1.
_, body = seen[-1]
assert b"refresh_token=r2" in body
assert b"refresh_token=r1" not in body
def test_refresher_resets_token_endpoint_when_disk_issuer_changes(
tmp_path: Path,
) -> None:
"""When the adopted disk bundle has a different issuer than the cached
one, the previously discovered token endpoint must be re-discovered
against the new issuer rather than reused."""
new_issuer = "https://other-idp.example/realms/openshell"
# Disk is newer than the in-memory bundle (later expiry) so it is
# adopted, but still stale so a refresh — and thus re-discovery — runs.
_write_bundle(
tmp_path,
access_token="disk-old",
expires_at=int(time.time()) + 5,
refresh_token="r2",
issuer=new_issuer,
)
seen_discovery: list[Any] = []
transport = _make_mock_transport(
discovery={
"issuer": new_issuer,
"token_endpoint": f"{new_issuer}/protocol/openid-connect/token",
},
seen_discovery=seen_discovery,
)
r = _OidcRefresher(tmp_path, "g", write_back=False)
_install_mock_transport(r, transport)
# Pretend we already discovered an endpoint for the OLD issuer.
r._token_endpoint = f"{DEFAULT_ISSUER}/protocol/openid-connect/token"
r._bundle = {
"access_token": "mem-old",
"expires_at": 1,
"refresh_token": "r1",
"issuer": DEFAULT_ISSUER,
}
r.current_access_token()
# Re-discovery happened against the new issuer.
assert len(seen_discovery) == 1
assert new_issuer in seen_discovery[0]
def test_refresher_recovers_from_invalid_grant_after_peer_rotation(
tmp_path: Path,
) -> None:
"""If our refresh POST loses a rotation race (peer already rotated r1→r2
and the IdP rejects our r1 with invalid_grant), re-read disk, pick up the
peer's r2, and retry — succeeding without forcing a re-authenticate."""
import httpx as _httpx
_write_bundle(tmp_path, access_token="old", expires_at=1, refresh_token="r1")
posts: list[bytes] = []
def handler(request: _httpx.Request) -> _httpx.Response:
if request.url.path.endswith("/.well-known/openid-configuration"):
return _httpx.Response(
200,
json={
"issuer": DEFAULT_ISSUER,
"token_endpoint": DEFAULT_TOKEN_ENDPOINT,
},
)
body = bytes(request.content)
posts.append(body)
if b"refresh_token=r1" in body:
# Simulate the peer: it already rotated r1→r2 and wrote r2 to
# disk, so the IdP rejects our now-stale r1.
_write_bundle(
tmp_path,
access_token="peer",
expires_at=int(time.time()) + 5,
refresh_token="r2",
)
return _httpx.Response(400, json={"error": "invalid_grant"})
# The retry carries the peer's r2 and succeeds.
return _httpx.Response(
200,
json={"access_token": "a-final", "refresh_token": "r3", "expires_in": 3600},
)
r = _OidcRefresher(tmp_path, "g", write_back=False)
_install_mock_transport(r, _httpx.MockTransport(handler))
assert r.current_access_token() == "a-final"
# Exactly two refresh POSTs: the failed r1 then the recovered r2.
assert any(b"refresh_token=r1" in p for p in posts)
assert any(b"refresh_token=r2" in p for p in posts)
assert len(posts) == 2
def test_refresher_reraises_invalid_grant_without_peer_rotation(
tmp_path: Path,
) -> None:
"""invalid_grant with no peer rotation (disk still holds our refresh_token)
is a genuine dead token — surface the re-authenticate hint and do NOT loop
on the retry path."""
import httpx as _httpx
import pytest as _pytest
_write_bundle(tmp_path, access_token="old", expires_at=1, refresh_token="r1")
posts: list[bytes] = []
def handler(request: _httpx.Request) -> _httpx.Response:
if request.url.path.endswith("/.well-known/openid-configuration"):
return _httpx.Response(
200,
json={
"issuer": DEFAULT_ISSUER,
"token_endpoint": DEFAULT_TOKEN_ENDPOINT,
},
)
posts.append(bytes(request.content))
return _httpx.Response(400, json={"error": "invalid_grant"})
r = _OidcRefresher(tmp_path, "g", write_back=False)
_install_mock_transport(r, _httpx.MockTransport(handler))
with _pytest.raises(SandboxError, match="Re-authenticate"):
r.current_access_token()
# Only one POST — disk offered no new refresh_token, so no retry.
assert len(posts) == 1
def test_refresher_exchanges_refresh_token_when_stale(tmp_path: Path) -> None:
"""When both memory and disk are stale, do the OAuth2 refresh exchange."""
_write_bundle(tmp_path, access_token="old", expires_at=1)
seen_refresh: list[Any] = []
transport = _make_mock_transport(seen_refresh=seen_refresh)
r = _OidcRefresher(tmp_path, "g", write_back=False)
_install_mock_transport(r, transport)
assert r.current_access_token() == "refreshed-jwt"
# The refresh request should be a POST to the discovered token endpoint
# with grant_type=refresh_token in the body.
url, body = seen_refresh[-1]
assert url.endswith("/protocol/openid-connect/token")
assert b"grant_type=refresh_token" in body
assert b"refresh_token=r-orig" in body
def test_refresher_writes_back_when_enabled(tmp_path: Path) -> None:
"""write_back=True persists rotated bundle to disk atomically with 0600."""
_write_bundle(tmp_path, access_token="old", expires_at=1)
transport = _make_mock_transport(
refresh_responses=[
{
"access_token": "rotated",
"refresh_token": "r-new",
"expires_in": 3600,
}
],
)
r = _OidcRefresher(tmp_path, "g", write_back=True)
_install_mock_transport(r, transport)
assert r.current_access_token() == "rotated"
on_disk = json.loads((tmp_path / "oidc_token.json").read_text())
assert on_disk["access_token"] == "rotated"
assert on_disk["refresh_token"] == "r-new"
# Mode should be 0600 on POSIX.
if os.name == "posix":
mode = (tmp_path / "oidc_token.json").stat().st_mode & 0o777
assert mode == 0o600, f"got {oct(mode)}"
def test_refresher_write_back_is_default(tmp_path: Path) -> None:
"""Default IS write_back=True so refresh-token rotation propagates to
disk for other processes (Rust CLI, TUI, second Python client)."""
_write_bundle(tmp_path, access_token="old", expires_at=1)
transport = _make_mock_transport(
refresh_responses=[
{
"access_token": "rotated",
"refresh_token": "r-new",
"expires_in": 3600,
}
],
)
r = _OidcRefresher(tmp_path, "g") # default write_back=True
_install_mock_transport(r, transport)
r.current_access_token()
on_disk = json.loads((tmp_path / "oidc_token.json").read_text())
assert on_disk["access_token"] == "rotated"
assert on_disk["refresh_token"] == "r-new"
def test_refresher_honors_refresh_token_rotation(tmp_path: Path) -> None:
"""When the IdP returns a new refresh_token, use it for subsequent refreshes
instead of the original. Some IdPs (Keycloak with rotation enabled, Entra
in strict mode) reissue and invalidate the old refresh_token."""
_write_bundle(tmp_path, access_token="old", expires_at=1, refresh_token="r1")
seen: list[Any] = []
transport = _make_mock_transport(
refresh_responses=[
{"access_token": "a2", "refresh_token": "r2", "expires_in": 1},
{"access_token": "a3", "refresh_token": "r3", "expires_in": 3600},
],
seen_refresh=seen,
)
r = _OidcRefresher(tmp_path, "g", write_back=False)
_install_mock_transport(r, transport)
assert r.current_access_token() == "a2"
# Second call: a2 is also expired (expires_in=1), so we refresh again,
# this time the request body should carry the rotated r2 (not r1).
assert r.current_access_token() == "a3"
assert b"refresh_token=r1" in seen[0][1]
assert b"refresh_token=r2" in seen[1][1]
def test_refresher_second_process_can_refresh_after_rotation(
tmp_path: Path,
) -> None:
"""Two-process simulation (Finding #2): process A refreshes r1→r2 with
write_back=True (default). Process B starts from disk and successfully
uses r2 — proving the rotation reached the shared cache."""
_write_bundle(tmp_path, access_token="old", expires_at=1, refresh_token="r1")
transport_a = _make_mock_transport(
refresh_responses=[
{"access_token": "a2", "refresh_token": "r2", "expires_in": 1},
],
)
process_a = _OidcRefresher(tmp_path, "g") # write_back=True (default)
_install_mock_transport(process_a, transport_a)
assert process_a.current_access_token() == "a2"
# Process B picks up the cache fresh. The IdP now expects r2; if the
# disk still held r1, this would fail at the IdP. With write_back the
# disk has r2, and B refreshes successfully.
seen_b: list[Any] = []
transport_b = _make_mock_transport(
refresh_responses=[
{"access_token": "a3", "refresh_token": "r3", "expires_in": 3600},
],
seen_refresh=seen_b,
)
process_b = _OidcRefresher(tmp_path, "g")
_install_mock_transport(process_b, transport_b)
assert process_b.current_access_token() == "a3"
# Process B should have presented r2, not r1.
assert b"refresh_token=r2" in seen_b[0][1]
def test_refresher_concurrent_calls_share_one_refresh(tmp_path: Path) -> None:
"""N threads racing on a stale token should produce exactly one
refresh exchange (not N). Mirrors google-auth's RefreshThreadManager
coordination."""
_write_bundle(tmp_path, access_token="old", expires_at=1)
refresh_count = [0]
barrier = threading.Barrier(8)
import httpx as _httpx
def handler(request: _httpx.Request) -> _httpx.Response:
if request.url.path.endswith("/.well-known/openid-configuration"):
return _httpx.Response(
200,
json={
"issuer": DEFAULT_ISSUER,
"token_endpoint": DEFAULT_TOKEN_ENDPOINT,
},
)
refresh_count[0] += 1
return _httpx.Response(
200,
json={
"access_token": "refreshed",
"expires_in": 3600,
},
)
r = _OidcRefresher(tmp_path, "g", write_back=False)
_install_mock_transport(r, _httpx.MockTransport(handler))
results: list[str] = []
errors: list[BaseException] = []
def worker() -> None:
try:
barrier.wait()
results.append(r.current_access_token())
except BaseException as e:
errors.append(e)
threads = [threading.Thread(target=worker) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
assert not errors, errors
assert results == ["refreshed"] * 8
# One refresh exchange, regardless of thread count.
assert refresh_count[0] == 1, f"expected one refresh, got {refresh_count[0]}"
def test_refresher_surfaces_idp_failure_as_sandbox_error(
tmp_path: Path,
) -> None:
"""A non-2xx from the token endpoint becomes a SandboxError."""
_write_bundle(tmp_path, access_token="old", expires_at=1)
transport = _make_mock_transport(
refresh_status=400,
refresh_responses=[
{
"error": "invalid_grant",
"error_description": "Token is not active",
}
],
)
r = _OidcRefresher(tmp_path, "g", write_back=False)
_install_mock_transport(r, transport)
import pytest as _pytest
with _pytest.raises(SandboxError, match="refresh failed"):
r.current_access_token()
def test_refresher_rejects_issuer_mismatch_in_discovery(tmp_path: Path) -> None:
"""Finding #1 (Critical): a discovery doc claiming a different issuer
must be rejected. Without this, a malicious or misdirected discovery
response could steer the refresh_token POST to an attacker-
controlled endpoint."""
_write_bundle(tmp_path, access_token="old", expires_at=1)
transport = _make_mock_transport(
discovery={
"issuer": "https://evil.example/realms/openshell",
"token_endpoint": "https://evil.example/token",
},
)
r = _OidcRefresher(tmp_path, "g", write_back=False)
_install_mock_transport(r, transport)
import pytest as _pytest
with _pytest.raises(SandboxError, match="issuer mismatch"):
r.current_access_token()
def test_refresher_rejects_redirect_during_discovery(tmp_path: Path) -> None:
"""Finding #1 (Critical): a 3xx during OIDC discovery must NOT be
auto-followed — that would let a network attacker steer the SDK to
an arbitrary token_endpoint URL. The Rust CLI sets
`Policy::none()`; we set httpx's `follow_redirects=False`."""
_write_bundle(tmp_path, access_token="old", expires_at=1)
transport = _make_mock_transport(
discovery_status=302,
discovery={"location": "https://evil.example/...."},
)
r = _OidcRefresher(tmp_path, "g", write_back=False)
_install_mock_transport(r, transport)
import pytest as _pytest
with _pytest.raises(SandboxError, match=r"discovery failed.*HTTP 302"):
r.current_access_token()
def test_refresher_insecure_disables_tls_verification() -> None:
"""Finding #3: insecure=True propagates to httpx as verify=False so
self-signed OIDC issuers work the same way they do in the Rust CLI's
`--insecure` plumbing."""
import pathlib
r = _OidcRefresher(
pathlib.Path("/tmp/does-not-exist"),
"g",
insecure=True,
)
try:
# httpx exposes the configured verify policy on the client; we
# don't depend on its precise type, just on it being a falsy
# value (the default is True / an SSLContext).
# In recent httpx versions this lives on the underlying transport.
# The simplest stable check is: an insecure client allows
# connect to self-signed hosts; the rest of the contract is
# httpx's responsibility.
# Verify the client's verify attribute (whether top-level or via
# transport) is False.
assert _client_verify_is_disabled(r._http)
finally:
r.close()
def _client_verify_is_disabled(client: Any) -> bool:
"""Inspect an httpx.Client for verify=False. httpx surfaces verify
either on the client directly (older) or via the default transport
(newer)."""
if getattr(client, "verify", None) is False:
return True
transport = getattr(client, "_transport", None)
if transport is None:
return False
# httpx's default HTTPTransport wraps an SSL context or a bool.
pool = getattr(transport, "_pool", None)
if pool is not None:
ssl_context = getattr(pool, "_ssl_context", None)
# When verify=False, httpx builds a context without verification.
if ssl_context is not None:
import ssl
return ssl_context.verify_mode == ssl.CERT_NONE
# Fallback: check for any internal `_verify` attribute set to False.
return getattr(transport, "_verify", None) is False
def test_refresher_raises_when_bundle_has_no_refresh_token(
tmp_path: Path,
) -> None:
"""Without a refresh_token (e.g. client_credentials grant — different
code path entirely), refresh has nothing to exchange and surfaces a
clear error."""
(tmp_path / "oidc_token.json").write_text(
json.dumps({"access_token": "old", "expires_at": 1, "issuer": "x"})
)
r = _OidcRefresher(tmp_path, "g", write_back=False)
import pytest as _pytest
with _pytest.raises(SandboxError, match="no refresh token"):
r.current_access_token()
# ---------------------------------------------------------------------------
# auth_mode gate: only metadata.json["auth_mode"] == "oidc" wires the bearer
# interceptor. A stray oidc_token.json next to a non-OIDC gateway must not
# trigger it.
# ---------------------------------------------------------------------------
def test_mtls_only_from_active_cluster_skips_bearer_interceptor(
tmp_path: Path,
monkeypatch: Any,
) -> None:
"""from_active_cluster against an mTLS-only gateway (no auth_mode set)
does not wrap the channel with a bearer interceptor, even if a stale
oidc_token.json is present in the gateway directory."""
gateway_name = "mtls-only"
gateway_dir = tmp_path / "openshell" / "gateways" / gateway_name
mtls_dir = gateway_dir / "mtls"
mtls_dir.mkdir(parents=True)
(tmp_path / "openshell" / "active_gateway").write_text(gateway_name)
# No auth_mode field — the chart-default path.
(gateway_dir / "metadata.json").write_text(
json.dumps({"gateway_endpoint": "https://127.0.0.1:8443"})
)
for f in ("ca.crt", "tls.crt", "tls.key"):
(mtls_dir / f).write_text(f"-----BEGIN {f}-----\n-----END {f}-----\n")
# Stray oidc_token.json — proving the auth_mode gate (and not the
# file's presence) is what would trigger the refresher.
(gateway_dir / "oidc_token.json").write_text(json.dumps({"access_token": "stale"}))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
monkeypatch.delenv("OPENSHELL_GATEWAY", raising=False)
client = SandboxClient.from_active_cluster()
try:
# No bearer interceptor wraps the channel.
assert not type(client._channel).__module__.endswith("interceptor")
finally:
client.close()
# ---------------------------------------------------------------------------
# Lifecycle plumbing: close() releases refresher resources, concurrent
# write-back doesn't trample.
# ---------------------------------------------------------------------------
def test_sandbox_client_close_invokes_bearer_close() -> None:
"""`SandboxClient.close()` must invoke the `_bearer_close` callable
wired by `from_active_cluster`. Otherwise the refresher's
httpx.Client leaks sockets/FDs until GC runs."""
closed = [0]
def bearer_close() -> None:
closed[0] += 1
client = SandboxClient(
"localhost:8080",
bearer_token="tok",
_bearer_close=bearer_close,
)
client.close()
assert closed[0] == 1
# close() is idempotent — re-invoking does not double-call.
client.close()
assert closed[0] == 1
def test_sandbox_client_close_releases_refresher_http_client(
tmp_path: Path,
monkeypatch: Any,
) -> None:
"""End-to-end check: an OIDC-backed client built by
from_active_cluster() must close the refresher's httpx.Client when
the SandboxClient is closed."""
gateway_name = "oidc-gw"
gateway_dir = tmp_path / "openshell" / "gateways" / gateway_name
mtls_dir = gateway_dir / "mtls"
mtls_dir.mkdir(parents=True)
(tmp_path / "openshell" / "active_gateway").write_text(gateway_name)
(gateway_dir / "metadata.json").write_text(
json.dumps(
{
"gateway_endpoint": "https://127.0.0.1:8443",
"auth_mode": "oidc",
}
)
)
for f in ("ca.crt", "tls.crt", "tls.key"):
(mtls_dir / f).write_text(f"-----BEGIN {f}-----\n-----END {f}-----\n")
_write_bundle(gateway_dir, expires_at=int(time.time()) + 3600)
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
monkeypatch.delenv("OPENSHELL_GATEWAY", raising=False)
# Capture the httpx.Client instance created inside the refresher by
# monkey-patching _OidcRefresher to record it on construction.
created: list[Any] = []
real_init = _OidcRefresher.__init__
def recording_init(self: Any, *args: Any, **kwargs: Any) -> None:
real_init(self, *args, **kwargs)
created.append(self._http)
monkeypatch.setattr(_OidcRefresher, "__init__", recording_init)
client = SandboxClient.from_active_cluster()
assert len(created) == 1
http_client = created[0]
assert not http_client.is_closed
client.close()
assert http_client.is_closed
def test_refresher_concurrent_write_back_does_not_trample(tmp_path: Path) -> None:
"""Two writers calling `_write_to_disk` concurrently must each use
their own tempfile (PID+random) and not corrupt each other's content.
The final file must be valid JSON from exactly one of the writers,
and no orphaned `.oidc_token.<rand>.tmp` files should remain."""
_write_bundle(tmp_path, expires_at=int(time.time()) + 3600)
r = _OidcRefresher(tmp_path, "g", write_back=False)
try:
N = 16
barrier = threading.Barrier(N)
errors: list[BaseException] = []
def writer(idx: int) -> None:
try:
barrier.wait()
r._write_to_disk(
{
"access_token": f"a-{idx}",
"refresh_token": f"r-{idx}",
"expires_at": 1_700_000_000 + idx,
"issuer": DEFAULT_ISSUER,
"client_id": "openshell-cli",
}
)
except BaseException as e:
errors.append(e)
threads = [threading.Thread(target=writer, args=(i,)) for i in range(N)]
for t in threads:
t.start()
for t in threads:
t.join()
assert not errors, errors
# Final file is valid JSON from one of the writers (race winner).
final = json.loads((tmp_path / "oidc_token.json").read_text())
assert final["access_token"].startswith("a-")
assert final["refresh_token"].startswith("r-")
# No orphan tmp files left behind. mkstemp uses a random suffix
# so each writer's tmp is distinct; the cleanup path on the
# success branch is `.replace()`, which atomically moves the
# tmp to the final path — no straggler tmp should remain.
leftovers = sorted(tmp_path.glob(".oidc_token.*.tmp"))
assert leftovers == [], f"orphan tmp files: {leftovers}"
finally:
r.close()
def test_sandbox_wrapper_forwards_auth_kwargs_to_from_active_cluster(
monkeypatch: Any,
) -> None:
"""The high-level `Sandbox` context manager must pass auto_refresh,
write_back, and insecure through to SandboxClient.from_active_cluster
so callers using the wrapper get parity with SandboxClient for
OIDC-protected gateways."""
captured: dict[str, Any] = {}
class _Sentinel(Exception):
pass
def fake_from_active_cluster(**kwargs: Any) -> Any:
captured.update(kwargs)
# Short-circuit the rest of __enter__ (which would try to create
# a session against a real gateway). The kwargs we care about
# have already been recorded.
raise _Sentinel
monkeypatch.setattr(
SandboxClient, "from_active_cluster", staticmethod(fake_from_active_cluster)
)
sandbox = Sandbox(
cluster="my-gw",
timeout=42.0,
auto_refresh=False,
write_back=False,
insecure=True,
)
import pytest as _pytest
with _pytest.raises(_Sentinel):
sandbox.__enter__()
assert captured["cluster"] == "my-gw"
assert captured["timeout"] == 42.0
assert captured["auto_refresh"] is False
assert captured["write_back"] is False
assert captured["insecure"] is True
def test_sandbox_wrapper_defaults_match_from_active_cluster(
monkeypatch: Any,
) -> None:
"""Sandbox(...) with no auth kwargs forwards the same defaults
(auto_refresh=True, write_back=True, insecure=False) that
SandboxClient.from_active_cluster uses, so the wrapper doesn't
silently weaken the security posture."""
captured: dict[str, Any] = {}
class _Sentinel(Exception):
pass
def fake_from_active_cluster(**kwargs: Any) -> Any:
captured.update(kwargs)
raise _Sentinel
monkeypatch.setattr(
SandboxClient, "from_active_cluster", staticmethod(fake_from_active_cluster)
)
import pytest as _pytest
with _pytest.raises(_Sentinel):
Sandbox().__enter__()
assert captured["auto_refresh"] is True
assert captured["write_back"] is True
assert captured["insecure"] is False
def test_inference_set_cluster_forwards_no_verify_flag() -> None:
stub = _FakeInferenceStub()
client = cast("InferenceRouteClient", object.__new__(InferenceRouteClient))
client._timeout = 30.0
client._stub = cast("Any", stub)
client.set_cluster(
provider_name="openai-dev",
model_id="gpt-4.1",
no_verify=True,
)
assert stub.request is not None
assert stub.request.no_verify is True
# ---------------------------------------------------------------------------
# Encoding regression tests (utf-8 explicit on all config file reads/writes)
# ---------------------------------------------------------------------------
def test_read_oidc_token_bundle_parses_non_ascii_utf8(tmp_path: Path) -> None:
gateway_dir = tmp_path / "gw"
gateway_dir.mkdir()
payload = {"refresh_token": "tok", "issuer": "https://example.com/é"}
(gateway_dir / "oidc_token.json").write_bytes(
json.dumps(payload, ensure_ascii=False).encode("utf-8")
)
result = _read_oidc_token_bundle(gateway_dir)
assert result == payload
def test_read_oidc_token_bundle_returns_none_on_corrupt_bytes(tmp_path: Path) -> None:
gateway_dir = tmp_path / "gw"
gateway_dir.mkdir()
(gateway_dir / "oidc_token.json").write_bytes(b"\xff\xfe not utf-8")
assert _read_oidc_token_bundle(gateway_dir) is None
def test_load_cluster_bearer_token_handles_non_ascii_utf8_oidc(tmp_path: Path) -> None:
gateway_dir = tmp_path / "gw"
gateway_dir.mkdir()
bundle = {
"access_token": "accéss",
"refresh_token": "ref",
"expiry": "2099-01-01T00:00:00Z",
"issuer": "https://example.com",
"client_id": "c",
"client_secret": "s",
}
(gateway_dir / "oidc_token.json").write_bytes(
json.dumps(bundle, ensure_ascii=False).encode("utf-8")
)
token = _load_cluster_bearer_token(gateway_dir)
assert token == "accéss"
def test_from_active_cluster_reads_utf8_bytes_from_active_gateway_and_metadata(
tmp_path: Path,
monkeypatch: Any,
) -> None:
gateway_name = "gw-é"
gateway_dir = tmp_path / "openshell" / "gateways" / gateway_name
gateway_dir.mkdir(parents=True)
(tmp_path / "openshell" / "active_gateway").write_bytes(
gateway_name.encode("utf-8")
)
meta = {"gateway_endpoint": "http://tést.example:8080"}
(gateway_dir / "metadata.json").write_bytes(
json.dumps(meta, ensure_ascii=False).encode("utf-8")
)
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
monkeypatch.delenv("OPENSHELL_GATEWAY", raising=False)
client = SandboxClient.from_active_cluster()
try:
assert client._cluster_name == gateway_name
assert client._endpoint == "tést.example:8080"
finally:
client.close()