diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index bc408c4ecd..d7e369b98a 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -242,6 +242,44 @@ with SandboxClient.from_active_cluster() as client: assert sandbox.id in {s.id for s in matches} ``` +## Configure the Python Client from the Environment + +`SandboxClient.from_active_cluster()` reads the CLI's on-disk gateway +directory (selected by `$OPENSHELL_GATEWAY` or the active gateway). When that +directory is not available — CI runners, containers, serverless functions — +use `SandboxClient.from_env()` to build a client entirely from environment +variables: + +```python +from openshell import SandboxClient + +# OPENSHELL_ENDPOINT=https://gateway.example:8443 +# OPENSHELL_TOKEN= +with SandboxClient.from_env() as client: + client.health() +``` + +`from_env()` reads the following variables, all following the `OPENSHELL_` +convention: + +| Variable | Purpose | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------ | +| `OPENSHELL_ENDPOINT` | Gateway gRPC endpoint. Required. Either a bare `host:port` or a URL (`https://host`, `http://host:port`); an `https` scheme selects a TLS channel and defaults the port to 443. | +| `OPENSHELL_TOKEN` | OIDC access token attached as `Bearer` to every RPC. | +| `OPENSHELL_TLS` | Force TLS on/off (`1/0/true/false/yes/no/on/off`). Overrides scheme inference; use it to enable system-root TLS for a bare `host:port` endpoint with no certificate material. | +| `OPENSHELL_TLS_CA` | Path to a CA certificate (custom trust root). | +| `OPENSHELL_TLS_CERT` | Path to a client certificate for mTLS. Requires `OPENSHELL_TLS_KEY`. | +| `OPENSHELL_TLS_KEY` | Path to a client private key for mTLS. Requires `OPENSHELL_TLS_CERT`. | +| `OPENSHELL_TIMEOUT` | Per-call gRPC timeout in seconds. Overrides the `timeout` argument when set. | +| `OPENSHELL_CLUSTER_NAME` | Friendly name used in error messages. | + +The TLS trust profile matches `from_active_cluster`: supply CA + cert + key +for full mTLS, CA alone for custom-CA trust, or neither (with an `https` +endpoint or `OPENSHELL_TLS=1`) for the OS trust store. Unlike +`from_active_cluster`, the bearer token is read once from `OPENSHELL_TOKEN` +and never refreshed — use `from_active_cluster` with a gateway directory when +you need lazy OIDC token refresh. + ## Expose Long Running Services Service forwarding makes a long-running process inside a sandbox reachable through a gateway-managed URL. Use it for development servers, notebooks, dashboards, or other services that keep listening after the sandbox starts. Run the service on loopback inside the sandbox, expose its port, then open the URL printed by OpenShell. diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index b62a9c8855..068eb3434f 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -404,6 +404,109 @@ def from_active_cluster( _bearer_close=bearer_close, ) + @classmethod + def from_env(cls, *, timeout: float = 30.0) -> SandboxClient: + """Construct a `SandboxClient` entirely from environment variables. + + A disk-free alternative to `from_active_cluster` for environments + that inject configuration through the process environment rather + than the CLI's on-disk gateway directory — CI runners, containers, + and serverless functions. It complements the existing + `$OPENSHELL_GATEWAY` + XDG gateway-directory discovery: reach for + `from_active_cluster` when the CLI has already logged in and + written a gateway directory, and for `from_env` when the only + configuration available is a set of environment variables. + + All variables follow the `OPENSHELL_*` convention: + + | Variable | Purpose | + | ------------------------ | --------------------------------------------- | + | `OPENSHELL_ENDPOINT` | Gateway gRPC endpoint. Required. Either a | + | | bare `host:port` or a URL (`https://host` / | + | | `http://host:port`); an `https` scheme | + | | selects a TLS channel and defaults the port | + | | to 443. | + | `OPENSHELL_TOKEN` | OIDC access token attached as `Bearer` to | + | | every RPC. | + | `OPENSHELL_TLS` | Force TLS on/off (`1/0/true/false/yes/no`). | + | | Overrides scheme inference; use it to enable | + | | system-root TLS for a bare `host:port` | + | | endpoint with no certificate material. | + | `OPENSHELL_TLS_CA` | Path to a CA certificate (custom trust root). | + | `OPENSHELL_TLS_CERT` | Path to a client certificate (mTLS; requires | + | | `OPENSHELL_TLS_KEY`). | + | `OPENSHELL_TLS_KEY` | Path to a client private key (mTLS; requires | + | | `OPENSHELL_TLS_CERT`). | + | `OPENSHELL_TIMEOUT` | Per-call gRPC timeout in seconds. Overrides | + | | the `timeout` argument when set. | + | `OPENSHELL_CLUSTER_NAME` | Friendly name used in error messages. | + + The TLS trust profile mirrors `from_active_cluster`: supply CA + + cert + key for full mTLS, CA alone for custom-CA trust, or neither + (with an `https` endpoint or `OPENSHELL_TLS=1`) for the OS trust + store. Unlike `from_active_cluster`, the bearer token is read once + from `OPENSHELL_TOKEN` and never refreshed — supply a gateway + directory and use `from_active_cluster` when you need lazy OIDC + refresh. + + Args: + timeout: default per-call gRPC timeout in seconds, used when + `OPENSHELL_TIMEOUT` is not set. + + Raises: + SandboxError: when `OPENSHELL_ENDPOINT` is unset, or a numeric + or boolean variable is malformed. + ValueError: when the mTLS material is partially specified + (`OPENSHELL_TLS_CERT` without `OPENSHELL_TLS_KEY`, or vice + versa), surfaced by `TlsConfig`. + """ + raw_endpoint = os.environ.get("OPENSHELL_ENDPOINT") + if not raw_endpoint: + raise SandboxError( + "OPENSHELL_ENDPOINT is not set; export it (host:port or a " + "URL) or use SandboxClient.from_active_cluster()" + ) + endpoint, scheme_is_https = _parse_env_endpoint(raw_endpoint) + + ca = os.environ.get("OPENSHELL_TLS_CA") + cert = os.environ.get("OPENSHELL_TLS_CERT") + key = os.environ.get("OPENSHELL_TLS_KEY") + tls_material = any((ca, cert, key)) + tls_override = _env_flag("OPENSHELL_TLS") + use_tls = ( + tls_override + if tls_override is not None + else (scheme_is_https or tls_material) + ) + + tls: TlsConfig | None = None + if use_tls: + # TlsConfig validates that cert/key are set together and treats + # all-None as the system-roots profile. + tls = TlsConfig( + ca_path=pathlib.Path(ca) if ca else None, + cert_path=pathlib.Path(cert) if cert else None, + key_path=pathlib.Path(key) if key else None, + ) + + resolved_timeout = timeout + raw_timeout = os.environ.get("OPENSHELL_TIMEOUT") + if raw_timeout is not None: + try: + resolved_timeout = float(raw_timeout) + except ValueError: + raise SandboxError( + f"OPENSHELL_TIMEOUT must be a number, got {raw_timeout!r}" + ) from None + + return cls( + endpoint, + tls=tls, + bearer_token=os.environ.get("OPENSHELL_TOKEN") or None, + timeout=resolved_timeout, + cluster_name=os.environ.get("OPENSHELL_CLUSTER_NAME") or None, + ) + def close(self) -> None: """Release the gRPC channel and any bearer-auth resources. @@ -1060,6 +1163,55 @@ def _xdg_config_home() -> pathlib.Path: return pathlib.Path.home() / ".config" +# Truthy/falsy spellings accepted for boolean OPENSHELL_* variables, matching +# the env parsing used elsewhere in the toolchain (Rust CLI `--insecure` +# plumbing and friends). +_ENV_TRUE = frozenset({"1", "true", "yes", "on"}) +_ENV_FALSE = frozenset({"0", "false", "no", "off"}) + + +def _env_flag(name: str) -> bool | None: + """Parse a boolean-ish environment variable used by `from_env`. + + Returns `None` when the variable is unset so callers can distinguish + "unset" (fall back to inference) from an explicit `true`/`false`. + Accepts `1/true/yes/on` and `0/false/no/off` (case-insensitive) and + raises `SandboxError` on anything else. + """ + raw = os.environ.get(name) + if raw is None: + return None + value = raw.strip().lower() + if value in _ENV_TRUE: + return True + if value in _ENV_FALSE: + return False + raise SandboxError( + f"environment variable {name} must be a boolean " + f"(1/0/true/false/yes/no/on/off), got {raw!r}" + ) + + +def _parse_env_endpoint(raw: str) -> tuple[str, bool]: + """Normalize `OPENSHELL_ENDPOINT` to `host:port` and report TLS intent. + + Accepts either a bare `host:port` or a URL. A URL's scheme selects the + transport: `https` implies TLS (default port 443); any other scheme + (`http`, `grpc`, ...) implies plaintext (default port 80). This mirrors + `from_active_cluster`'s parsing of the gateway metadata + `gateway_endpoint`. A bare `host:port` is returned unchanged with no TLS + intent, leaving the TLS decision to `OPENSHELL_TLS` / cert material. + Returns `(endpoint, scheme_is_https)`. + """ + if "://" not in raw: + return raw, False + parsed = urlparse(raw) + scheme_is_https = parsed.scheme == "https" + host = parsed.hostname or "127.0.0.1" + port = parsed.port or (443 if scheme_is_https else 80) + return f"{host}:{port}", scheme_is_https + + # Re-check the cached token roughly 30 seconds before the issuer's # stated expiry, to leave room for in-flight RPCs and clock skew. This # matches `openshell-bootstrap::oidc_token::is_token_expired`. diff --git a/python/openshell/sandbox_test.py b/python/openshell/sandbox_test.py index f1cb06148c..f949decf19 100644 --- a/python/openshell/sandbox_test.py +++ b/python/openshell/sandbox_test.py @@ -16,6 +16,7 @@ import pytest +from openshell import sandbox as sandbox_mod from openshell._proto import openshell_pb2 from openshell.sandbox import ( _PYTHON_CLOUDPICKLE_BOOTSTRAP, @@ -28,10 +29,12 @@ SandboxStatusRef, TlsConfig, _BearerAuthInterceptor, + _env_flag, _load_cluster_bearer_token, _make_cluster_bearer_provider, _normalize_bearer, _OidcRefresher, + _parse_env_endpoint, _read_oidc_token_bundle, _sandbox_ref, ) @@ -1860,3 +1863,271 @@ def test_sandbox_session_delete_passes_workspace() -> None: assert stub.delete_request is not None assert stub.delete_request.workspace == "staging" + + +# --------------------------------------------------------------------------- +# from_env(): environment-variable-driven configuration. Complements the +# on-disk `from_active_cluster` discovery for CI/container/serverless callers. +# --------------------------------------------------------------------------- + + +_FROM_ENV_VARS = ( + "OPENSHELL_ENDPOINT", + "OPENSHELL_TOKEN", + "OPENSHELL_TLS", + "OPENSHELL_TLS_CA", + "OPENSHELL_TLS_CERT", + "OPENSHELL_TLS_KEY", + "OPENSHELL_TIMEOUT", + "OPENSHELL_CLUSTER_NAME", +) + + +def _clear_from_env(monkeypatch: Any) -> None: + """Start each from_env test from a clean OPENSHELL_* slate so ambient + values in the developer's shell can't leak into assertions.""" + for name in _FROM_ENV_VARS: + monkeypatch.delenv(name, raising=False) + + +def _spy_transport(monkeypatch: Any) -> dict[str, int]: + """Count secure vs plaintext channel construction without stubbing gRPC. + + Wraps (not replaces) the real constructors so the returned channel is a + genuine grpc channel — `SandboxClient` builds a real stub and the bearer + interceptor wraps a real channel — while still recording which transport + path `from_env` selected.""" + counts = {"secure": 0, "insecure": 0} + real_ssl = sandbox_mod.grpc.ssl_channel_credentials + real_insecure = sandbox_mod.grpc.insecure_channel + + def spy_ssl(*args: Any, **kwargs: Any) -> Any: + counts["secure"] += 1 + return real_ssl(*args, **kwargs) + + def spy_insecure(*args: Any, **kwargs: Any) -> Any: + counts["insecure"] += 1 + return real_insecure(*args, **kwargs) + + monkeypatch.setattr(sandbox_mod.grpc, "ssl_channel_credentials", spy_ssl) + monkeypatch.setattr(sandbox_mod.grpc, "insecure_channel", spy_insecure) + return counts + + +def test_parse_env_endpoint_passes_through_host_port() -> None: + assert _parse_env_endpoint("127.0.0.1:8080") == ("127.0.0.1:8080", False) + + +def test_parse_env_endpoint_https_url_defaults_port_443() -> None: + assert _parse_env_endpoint("https://gateway.example") == ( + "gateway.example:443", + True, + ) + + +def test_parse_env_endpoint_http_url_defaults_port_80() -> None: + assert _parse_env_endpoint("http://gateway.example") == ( + "gateway.example:80", + False, + ) + + +def test_parse_env_endpoint_url_keeps_explicit_port() -> None: + assert _parse_env_endpoint("https://gateway.example:8443") == ( + "gateway.example:8443", + True, + ) + + +def test_env_flag_parses_truthy_and_falsy(monkeypatch: Any) -> None: + for value in ("1", "true", "YES", "On"): + monkeypatch.setenv("OPENSHELL_TLS", value) + assert _env_flag("OPENSHELL_TLS") is True + for value in ("0", "false", "No", "OFF"): + monkeypatch.setenv("OPENSHELL_TLS", value) + assert _env_flag("OPENSHELL_TLS") is False + + +def test_env_flag_returns_none_when_unset(monkeypatch: Any) -> None: + monkeypatch.delenv("OPENSHELL_TLS", raising=False) + assert _env_flag("OPENSHELL_TLS") is None + + +def test_env_flag_rejects_garbage(monkeypatch: Any) -> None: + monkeypatch.setenv("OPENSHELL_TLS", "maybe") + with pytest.raises(SandboxError, match="must be a boolean"): + _env_flag("OPENSHELL_TLS") + + +def test_from_env_requires_endpoint(monkeypatch: Any) -> None: + _clear_from_env(monkeypatch) + with pytest.raises(SandboxError, match="OPENSHELL_ENDPOINT is not set"): + SandboxClient.from_env() + + +def test_from_env_plaintext_host_port(monkeypatch: Any) -> None: + _clear_from_env(monkeypatch) + counts = _spy_transport(monkeypatch) + monkeypatch.setenv("OPENSHELL_ENDPOINT", "127.0.0.1:8080") + + client = SandboxClient.from_env() + try: + assert client._endpoint == "127.0.0.1:8080" + assert client._timeout == 30.0 + assert counts == {"secure": 0, "insecure": 1} + # No token -> no bearer interceptor wrapping the channel. + assert not _channel_is_intercepted(client._channel) + finally: + client.close() + + +def test_from_env_https_scheme_selects_tls(monkeypatch: Any) -> None: + _clear_from_env(monkeypatch) + counts = _spy_transport(monkeypatch) + monkeypatch.setenv("OPENSHELL_ENDPOINT", "https://gateway.example:8443") + + client = SandboxClient.from_env() + try: + assert client._endpoint == "gateway.example:8443" + assert counts == {"secure": 1, "insecure": 0} + finally: + client.close() + + +def test_from_env_tls_flag_forces_system_roots(monkeypatch: Any) -> None: + """A bare host:port with OPENSHELL_TLS=1 must build a system-roots TLS + channel (no cert material), mirroring from_active_cluster's fallback for + OIDC gateways behind a public CA.""" + _clear_from_env(monkeypatch) + counts = _spy_transport(monkeypatch) + monkeypatch.setenv("OPENSHELL_ENDPOINT", "gateway.example:443") + monkeypatch.setenv("OPENSHELL_TLS", "true") + + client = SandboxClient.from_env() + try: + assert counts == {"secure": 1, "insecure": 0} + finally: + client.close() + + +def test_from_env_tls_flag_false_overrides_https_scheme(monkeypatch: Any) -> None: + """An explicit OPENSHELL_TLS=0 wins over an https scheme, matching the + documented precedence (explicit flag overrides scheme inference).""" + _clear_from_env(monkeypatch) + counts = _spy_transport(monkeypatch) + monkeypatch.setenv("OPENSHELL_ENDPOINT", "https://gateway.example:8443") + monkeypatch.setenv("OPENSHELL_TLS", "0") + + client = SandboxClient.from_env() + try: + assert counts == {"secure": 0, "insecure": 1} + finally: + client.close() + + +def test_from_env_cert_material_implies_tls(monkeypatch: Any, tmp_path: Path) -> None: + ca = tmp_path / "ca.crt" + cert = tmp_path / "tls.crt" + key = tmp_path / "tls.key" + ca.write_text("ca") + cert.write_text("cert") + key.write_text("key") + + _clear_from_env(monkeypatch) + counts = _spy_transport(monkeypatch) + monkeypatch.setenv("OPENSHELL_ENDPOINT", "gateway.example:8443") + monkeypatch.setenv("OPENSHELL_TLS_CA", str(ca)) + monkeypatch.setenv("OPENSHELL_TLS_CERT", str(cert)) + monkeypatch.setenv("OPENSHELL_TLS_KEY", str(key)) + + client = SandboxClient.from_env() + try: + assert counts == {"secure": 1, "insecure": 0} + finally: + client.close() + + +def test_from_env_partial_mtls_material_rejected( + monkeypatch: Any, tmp_path: Path +) -> None: + """A cert without its key is a misconfiguration; TlsConfig surfaces it.""" + cert = tmp_path / "tls.crt" + cert.write_text("cert") + + _clear_from_env(monkeypatch) + monkeypatch.setenv("OPENSHELL_ENDPOINT", "gateway.example:8443") + monkeypatch.setenv("OPENSHELL_TLS_CERT", str(cert)) + + with pytest.raises(ValueError, match="cert_path and key_path"): + SandboxClient.from_env() + + +def test_from_env_attaches_bearer_token(monkeypatch: Any) -> None: + _clear_from_env(monkeypatch) + _spy_transport(monkeypatch) + monkeypatch.setenv("OPENSHELL_ENDPOINT", "127.0.0.1:8080") + monkeypatch.setenv("OPENSHELL_TOKEN", "jwt-from-env") + + client = SandboxClient.from_env() + try: + # A bearer token wraps the channel in an intercepting channel. + assert _channel_is_intercepted(client._channel) + finally: + client.close() + + +def test_from_env_timeout_override(monkeypatch: Any) -> None: + _clear_from_env(monkeypatch) + _spy_transport(monkeypatch) + monkeypatch.setenv("OPENSHELL_ENDPOINT", "127.0.0.1:8080") + monkeypatch.setenv("OPENSHELL_TIMEOUT", "12.5") + + client = SandboxClient.from_env(timeout=99.0) + try: + # The env var wins over the constructor default. + assert client._timeout == 12.5 + finally: + client.close() + + +def test_from_env_timeout_falls_back_to_argument(monkeypatch: Any) -> None: + _clear_from_env(monkeypatch) + _spy_transport(monkeypatch) + monkeypatch.setenv("OPENSHELL_ENDPOINT", "127.0.0.1:8080") + + client = SandboxClient.from_env(timeout=7.0) + try: + assert client._timeout == 7.0 + finally: + client.close() + + +def test_from_env_rejects_non_numeric_timeout(monkeypatch: Any) -> None: + _clear_from_env(monkeypatch) + monkeypatch.setenv("OPENSHELL_ENDPOINT", "127.0.0.1:8080") + monkeypatch.setenv("OPENSHELL_TIMEOUT", "soon") + + with pytest.raises(SandboxError, match="OPENSHELL_TIMEOUT must be a number"): + SandboxClient.from_env() + + +def test_from_env_sets_cluster_name(monkeypatch: Any) -> None: + _clear_from_env(monkeypatch) + _spy_transport(monkeypatch) + monkeypatch.setenv("OPENSHELL_ENDPOINT", "127.0.0.1:8080") + monkeypatch.setenv("OPENSHELL_CLUSTER_NAME", "ci-gateway") + + client = SandboxClient.from_env() + try: + assert client._cluster_name == "ci-gateway" + finally: + client.close() + + +def test_from_env_rejects_invalid_tls_flag(monkeypatch: Any) -> None: + _clear_from_env(monkeypatch) + monkeypatch.setenv("OPENSHELL_ENDPOINT", "127.0.0.1:8080") + monkeypatch.setenv("OPENSHELL_TLS", "sometimes") + + with pytest.raises(SandboxError, match="must be a boolean"): + SandboxClient.from_env()