Files
OpenShell/e2e/python/test_sandbox_providers.py
Philippe MartinandJohn Myers 04146692d9 refactor(providers)!: make provider profiles import-only (#3383)
* chore(providers): remove dead provider plugin modules

Twelve modules under crates/openshell-providers/src/providers/ were never
declared in providers/mod.rs, so they have not been compiled since the plugin
registry was narrowed to the two adapters it still registers. Four of them
(generic, gitlab, opencode, outlook) key off provider type identifiers that
normalize_provider_type already retired.

Keep google_cloud and vertex, which are the only plugins ProviderRegistry::new
registers.

Signed-off-by: Philippe Martin <phmartin@redhat.com>

* test(providers): load example profiles from providers/ at test time

Add an example_profiles module that reads the YAML under providers/ from the
source checkout at run time and parses it with the existing profile loader. It
is gated behind a new non-default example-profiles feature so it is available to
this crate's own tests and, once wired into dev-dependencies, to gateway and CLI
tests, while never reaching a release binary.

Nothing consumes it yet; later changes move the test fixtures off the compiled
catalog and onto these files.

Signed-off-by: Philippe Martin <phmartin@redhat.com>

* test: source profile fixtures from providers/ instead of the compiled catalog

Unit and integration fixtures reached into builtin_profiles() to get a profile
to work with, which ties the tests to the compiled catalog rather than to the
files an operator would import. Point them at the example_profiles loader
instead.

The profiles.rs tests keep their coverage unchanged and become explicit golden
tests over providers/*.yaml: they are what keeps those files valid once nothing
compiles them. builtin_profiles_are_sorted_by_id widens into a test that the
whole example set parses, sorts, and lints clean as one catalog.

The CLI fake gateways serve the example profiles the way a real gateway serves
what an operator imported, through a shared helper.

No behavior change: the gateway still loads the built-in source by default and
serves the same profiles.

Signed-off-by: Philippe Martin <phmartin@redhat.com>

* docs(providers): document the example provider profiles

Each file under providers/ now opens with a header naming its expected client
binary identities, the image layout those paths assume, the credential scope,
the endpoint access it grants, and a smoke test. Add a README covering the
import commands and why a profile should be copied and edited rather than
imported unchanged.

Several of these profiles bind network access to paths that only exist in the
OpenShell Community image — /sandbox/.venv, /app/.venv, /sandbox/.cursor-server,
/usr/lib/node_modules. Imported unchanged into another image the profile matches
nothing: the catalog still advertises it, but the credential is never injected
and the traffic is denied. The headers say so where it applies.

Comments only; the profile schema has no documentation fields and all fifteen
files still lint clean.

Signed-off-by: Philippe Martin <phmartin@redhat.com>

* test(server): seed unit-test state with the example provider profiles

Server unit tests inherited the builtin + user source default from ServerState,
so around a hundred and forty assertions about github, openai and the rest
resolved against the compiled catalog. Point the test state at the user source
alone and import the example profiles from providers/ into its store first, the
way an operator would. Every one of those assertions keeps passing unchanged,
which is the point: it proves the gateway behaves identically with an imported
catalog before the default moves.

Four tests asserted builtin-source semantics specifically. Under import-only the
only profiles a gateway cannot edit are the ones a non-user source vends, so
they now exercise a source-managed profile composed with the user source; the
read-only guard they cover is the one that still applies to interceptor
catalogs. The list test asserts the imported profile's user/platform identity
instead of builtin with an empty scope.

test_server_state_with_user_only_github_profile is gone: the default test state
now is a user-only gateway with github imported.

Signed-off-by: Philippe Martin <phmartin@redhat.com>

* refactor(cli): resolve credential suggestions from the gateway catalog

The --env credential warning scanned a profile table compiled into the CLI, so
its suggestions described the binary rather than the gateway the user is talking
to: a profile the gateway does not serve was suggested anyway, and an imported
custom profile never was.

Fetch the catalog from the connected gateway instead. The warning moves out of
argument parsing and into sandbox create and sandbox template create, where a
client already exists. A catalog fetch failure is not fatal — the warning
degrades to its generic form rather than blocking sandbox creation.

Extract the ListProviderProfiles paging loop from provider list-profiles into a
shared fetch_provider_profile_catalog, which the profile-driven paths now share.

Signed-off-by: Philippe Martin <phmartin@redhat.com>

* refactor(cli)!: infer providers from the gateway catalog

Command-to-provider inference went through a hardcoded alias table in
openshell-providers, a second copy of the built-in catalog's identifiers. A
custom profile could never be inferred no matter what binaries it declared, and
the table drifted from the profiles it mirrored.

Infer from the connected gateway's catalog instead: match the command's basename
against each profile's ID and against the basenames of the binaries the profile
authorizes. A profile that names /usr/bin/claude is the profile for running
claude. The match must be unique — where several profiles claim a command, the
user names one with --provider — and an empty catalog infers nothing.

No command is special-cased. `binaries` is the operator's authorization
statement, so a profile that declares a binary claims the command that runs it,
whatever that binary is; narrowing that belongs in the profile rather than in a
list compiled into the CLI, which could never cover an unbounded catalog anyway.

Breaking: the retired aliases stop resolving, and commands the old table never
listed can now infer. git, pip and uv are declared by the github and pypi
example profiles, so they infer where they previously did not.

Signed-off-by: Philippe Martin <phmartin@redhat.com>

* refactor(providers,server)!: resolve provider profiles by exact ID

normalize_provider_type was a hardcoded alias map — gh to github, claude to
claude-code, vertex to google-vertex-ai — and a second copy of the built-in
catalog's identifiers, independent of the YAML it mirrored. It let a provider
type resolve to a profile the operator never named, and it made the built-in IDs
behave as a reserved namespace.

Remove it, along with detect_provider_from_command and the alias-normalizing
ProviderRegistry::inject_env. A provider type now names a profile exactly:

- the effective catalog resolves an ID or reports it absent, with no alias retry
- plugins activate only for the ID of a profile the gateway resolved; a provider
  with no resolvable profile gets no plugin projection, instead of falling back
  to an alias guess
- the CLI surfaces the gateway's not-found instead of retrying under an alias
- telemetry buckets by profile ID, and the gitlab, opencode and outlook buckets
  go with the aliases that were their only source

Breaking: `--type gh`, `--type claude` and the other aliases no longer resolve.
Use the profile's own ID.

Signed-off-by: Philippe Martin <phmartin@redhat.com>

* feat(server): fail closed when a provider's profile is absent

A sandbox composed from a provider whose profile the gateway cannot resolve
started anyway: the credential and policy builders warned and skipped, so the
sandbox came up carrying none of that provider's credentials or network policy.
The operator learned about it later, as a denied connection or a missing
environment variable, rather than as the configuration error it is.

Check at the two composition boundaries — CreateSandbox and
AttachSandboxProvider — right beside the catalog snapshot already taken there,
and reject with a bounded diagnostic naming the provider, the profile it refers
to, and the import command that supplies it. Scope-aware: a platform-scoped
provider is told to import with --global.

Read paths are untouched. ListProviders, GetProvider and profile export keep
working so an operator can see and recover an affected provider, and the shared
policy builders keep their warn-and-skip for the diagnostic paths that also
reach them.

Signed-off-by: Philippe Martin <phmartin@redhat.com>

* refactor(server): scope the vendor base-URL pin to declared endpoints

provider_profile_endpoints_are_active withheld a credential and the provider's
policy layer when an openai or anthropic provider pointed its client somewhere
other than the public vendor endpoint. The guard was keyed on
profile.source == "builtin" and on those two profile IDs, so it protected only
profiles OpenShell shipped. Once profiles are import-only no profile is ever
builtin, and the guard would silently stop applying — including to an operator
who imported providers/openai.yaml verbatim.

Key it on the profile instead of on where the profile came from. A profile's
endpoints are the boundary its credential is bound to, so if the provider
configures a *_BASE_URL pointing at a host the profile does not declare, the
profile no longer describes where that credential goes and is treated as
endpointless. Host matching reuses the DNS-label-aware matcher in
openshell-core, so wildcard endpoints such as Vertex's
*-aiplatform.googleapis.com resolve correctly.

A profile with no declared endpoints has no boundary to contradict, and a config
value that names no host is not a redirect this can reason about. Both keep the
profile active. The control now covers every endpoint-bearing profile, including
an operator's own, and the last "builtin" string leaves the gateway.

Signed-off-by: Philippe Martin <phmartin@redhat.com>

* test(e2e): import example provider profiles during gateway bring-up

The e2e suites create providers from github, openai, nvidia, claude-code and
google-cloud, and the GitHub lane runs a real git clone through the profile's
binary attribution. A gateway serves only the profiles an operator imported, so
the lanes have to import them.

Add e2e_import_example_provider_profiles to the shared bring-up helpers and call
it from the Docker, Podman, Kubernetes and VM wrappers once the gateway is
healthy and registered. It runs the same command the upgrade notes give
operators, against the repository's own providers/ directory, so the lanes
exercise the documented path rather than a test-only shortcut.

Lands before the default changes: a same-ID user profile already shadows a
built-in, so importing works today.

Signed-off-by: Philippe Martin <phmartin@redhat.com>

* refactor(providers)!: make provider profiles import-only

OpenShell compiled fifteen provider profile YAML files into every release binary
and selected them by default, so a fresh gateway published a catalog it was
never configured with. Those profiles are not image-neutral: their binary
selectors name paths that exist in the OpenShell Community image, so changing
the sandbox image could make a profile inert while the catalog still advertised
it. Every endpoint, credential name and binary path in providers/ was also
effectively part of the 0.1.0 public contract.

A gateway's catalog is now exactly what an operator imported:

- providers/*.yaml is no longer include_str!'d, and builtin_profiles() is gone
  from the openshell-providers API
- the default provider_profile_sources is [{ type = "user" }], and a gateway
  with nothing imported reaches ready and serves an empty catalog — an empty
  catalog is a valid state, not a startup failure
- the builtin source type is removed from the configuration schema, and a
  gateway.toml that still names it is rejected at parse time with the import
  command rather than an unknown-variant error
- nothing reserves the canonical identifiers any more, so github, pypi,
  anthropic and the rest import at their own IDs and the imported profile is the
  only definition for that ID; the static_fallback that kept a shipped
  definition resident behind an imported one is gone with the source that
  produced it

A collision between an interceptor-vended profile and an imported one still
fails closed, which is the behavior that shadowing quietly bypassed for
built-ins.

Operators upgrading should export the profiles their deployment relies on
before upgrading, or copy them from the providers/ directory of the matching
release tag, then import them at the scope their providers use.

Signed-off-by: Philippe Martin <phmartin@redhat.com>

* docs(providers): document import-only provider profiles

Rewrite the provider profile documentation around a catalog the operator builds
rather than one the gateway ships.

- gateway-config: the default is [{ type = "user" }], the builtin source type is
  gone and rejected at startup, and an empty catalog is a valid ready state
- profiles: replace the built-in profile table and its shadowing semantics with
  the import workflow, and say plainly that a profile whose binary paths do not
  match the image is inert
- manage-providers: replace the two fixed provider-type tables with
  `provider list-profiles`, and describe catalog-driven command inference
- inference-routing: drop the "still loads built-in profiles for compatibility"
  transition text; its migration walkthrough is now the normal path
- quickstart and the Docker Compose, GitHub, AWS, Google Cloud and Vertex
  tutorials: import the profile before creating the provider, since nothing
  resolves without it
- release notes: a 0.1.0 migration section covering export-before-upgrade,
  importing from the release tag's providers/ directory, what happens to a
  provider whose profile is missing, and the removal of the legacy type aliases
- README, architecture, the openshell-cli and debug-openshell-cluster skills,
  and the governance interceptor example follow the same change; the example's
  smoke assertion now imports a profile to prove an authoritative interceptor
  hides it

Signed-off-by: Philippe Martin <phmartin@redhat.com>

* fix(cli,e2e): distinguish catalog failures and authenticate OIDC seeding

Addresses two findings from review (GATOR-c1bd9867-02 and -03).

The provider profile catalog lookup collapsed its error into an empty
catalog, so an authorization, availability or transport failure on
ListProviderProfiles was indistinguishable from a gateway that genuinely has
no profiles. Command inference then resolved nothing and the sandbox was
created without the provider it needed, deferring the failure to the workload.

Keep the lookup's outcome instead of discarding it. The credential warning is
advisory and still degrades to its generic form, but inference now consults the
catalog only when there is a command to resolve and surfaces the lookup failure
when there is, naming the gateway and pointing at explicit --provider selection.
Two regression tests cover it through a fake gateway whose ListProviderProfiles
returns UNAVAILABLE while every other RPC succeeds: sandbox creation fails
without sending a provider-less create request, and a sandbox with no trailing
command still succeeds because it needs no catalog.

The e2e profile seeding also ran unauthenticated in the OIDC lanes. Those lanes
deliberately skip gateway registration and start the gateway without a TLS
client CA, so no mTLS identity exists and no token has been acquired when the
import runs; the wrapper exited during setup. Skipping the import is not
sufficient because the provider tests now require the claude-code profile.

Add e2e_register_oidc_admin_session, which mints an administrator token with
Keycloak's password grant — the same grant the OIDC test helpers use — and
writes the gateway metadata and token bundle that an interactive login would
have stored, so the import runs as an authenticated administrator. The mTLS
lanes keep the direct import unchanged. Both affected wrappers are covered:
with-podman-gateway.sh had the same defect as with-docker-gateway.sh.

Signed-off-by: Philippe Martin <phmartin@redhat.com>

* refactor(cli)!: remove command-derived provider attachment

Addresses GATOR-c1bd9867-01.

A profile's `binaries` list authorizes a binary to reach that profile's
endpoints. It is not a statement that running the binary asks for the provider,
and reading it as attachment intent let a command silently gain provider
authority: the aws-s3 example declares /bin/bash, so `sandbox create -- bash`
resolved an existing aws-s3 provider and attached its credential-backed
capability and network policy to the shell and its descendants. Attachment of
an already-created provider never prompted, so the confirmation flow did not
guard it.

The distinction that would make inference sound — whether a declared binary is
a profile's client or merely a permitted runtime — cannot be expressed:
NetworkBinary carries only a path, and the field that encoded it was removed in
0.1.0. Any substitute is a guess. Restricting the guess to a unique claimant
does not help, because uniqueness measures how sparse the catalog is rather
than what the user intended, and a compiled list of "generic" commands could
never cover an unbounded operator catalog.

Remove trailing-command inference rather than approximate it. A provider is
attached only when named with --provider, which still creates a missing
provider from local discovery when the name matches an imported profile ID.
Sandboxes with no providers remain a normal, fully supported state.

Removing inference also settles GATOR-c1bd9867-02: with no consumer deriving
authority from the catalog, its only remaining use is the advisory credential
warning, so a failed lookup degrades that warning instead of blocking creation.
The regression test now asserts that an unreachable catalog still creates the
sandbox and attaches nothing.

While repurposing the deduplication test, a pre-existing defect surfaced:
repeating a name in --provider auto-created it twice and the second attempt
failed with "provider already exists", because the explicit pass never
consulted the set of names it had already handled. Guard it.

Signed-off-by: Philippe Martin <phmartin@redhat.com>

* fix(e2e): load the OIDC token and trust the gateway when seeding profiles

Addresses the carried finding GATOR-c1bd9867-03.

e2e_register_oidc_admin_session established a session the CLI never used. The
CLI decides whether to load a stored bearer token by matching on the auth_mode
field of the gateway metadata alone; the helper omitted that field, so the
metadata fell through to the default arm and oidc_token.json stayed on disk
unread. The profile import went out unauthenticated exactly as it had before
the helper existed. The helper also installed no trust anchor, leaving the CLI
unable to verify the gateway's self-signed serving certificate.

Write auth_mode = "oidc" so the stored token is loaded, and install the CA at
<gateway>/mtls/ca.crt. Only the CA is installed: with no client certificate or
key on disk the CLI falls back to CA-only server verification and authenticates
with the bearer token, which is what these lanes need because they start the
gateway without --tls-client-ca. Certificate verification stays on; no insecure
transport override is introduced.

Assert the session before anything depends on it. ListProviderProfiles is
annotated auth_mode: "bearer", so it cannot succeed unless the token was loaded
and accepted. The helper now fails at that point, naming the gateway config
directory and echoing the CLI output, rather than letting the defect surface
later as an opaque profile import error.

Both affected wrappers pass the PKI directory and CLI binary the helper needs;
the mTLS lanes are untouched.

Signed-off-by: Philippe Martin <phmartin@redhat.com>

* fix(e2e): request the OpenShell scopes when minting the admin token

The OIDC lanes failed at the session assertion added in b25dd0298 with
PERMISSION_DENIED and "scope 'provider:read' required". Authentication was
working -- the gateway logged ListProviderProfiles at gRPC status 7, which it
can only reach once the bearer token has been loaded and accepted. The token
simply carried no OpenShell scope.

sandbox:*, provider:*, config:*, workspace:* and openshell:all are optional
client scopes on the openshell-cli client in scripts/keycloak-realm.json, so
Keycloak mints them only when the request asks for them. The password grant
here asked for nothing, leaving the realm defaults (openid, profile, email,
roles, web-origins, acr) and an access token that authorizes no RPC.

Request "openid openshell:all", as e2e/python/oidc/oidc_auth_test.py already
does for its administrator tokens. openshell:all is SCOPE_ALL in
crates/openshell-server/src/auth/authz.rs, so one scope covers the setup calls
without enumerating them. Verified against the realm: the token goes from
"email profile openid" to "email profile openshell:all openid".

Record the same scopes in metadata.json. The helper writes the bundle
openshell gateway login would have stored, and oidc_scopes is the field that
login path reads back, so leaving it out would misdescribe the stored token.

Signed-off-by: Philippe Martin <phmartin@redhat.com>

* fix(e2e): seed provider profiles once per VM lane gateway

The VM lanes failed on the second test target with "custom provider profile
'anthropic' already exists" for all fifteen example profiles, and the import
exited non-zero.

e2e_import_example_provider_profiles was called from run_e2e_test, so it ran
once per target -- four times against one long-lived gateway. That was
harmless while import overwrote silently, but profiles are now import-only:
ImportProviderProfiles is create-only and reports an existing id as an
error-severity diagnostic, with no overwrite flag on the request. The first
import therefore succeeds and every later one fails.

Hoist the call to just after the conformance run, which is where the docker,
podman and kube lanes already seed their catalogs. The profiles persist for
the gateway's lifetime, so every target still finds them, and the
E2E_TEST_OVERRIDE path is covered by the same single call.

Signed-off-by: Philippe Martin <phmartin@redhat.com>

* test(e2e): name the provider explicitly in the OIDC workspace-user test

user_can_create_sandbox_with_inferred_provider_command reached the gateway
once the admin session was fixed, and then failed: it asserted a
missing-provider error, but the sandbox was created and died provisioning
with "failed to spawn sandbox entrypoint process 'claude-code'".

The test drove provider resolution by passing claude-code as the trailing
command and relying on the CLI to infer the provider type from it. That
inference is what this branch removed, so the trailing word is now nothing
but an entrypoint, and the image has no such binary.

The regression the test guards is not inference itself: it is that a
workspace user resolving a provider is not gated behind Platform Admin.
Name the provider with --provider, the only remaining way to attach one.
The CLI still has to fetch the claude-code profile before it can auto-create
the provider, so the lookup a workspace user must be allowed to make still
happens, and auto-creation still stops at the non-interactive branch with
"missing required provider". Both assertions therefore keep their meaning.

Rename the test and rework its comments to describe what it now exercises;
the old name would otherwise outlive the behavior it was named for.

Signed-off-by: Philippe Martin <phmartin@redhat.com>

* fix(providers): reconcile import-only profiles with main

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

---------

Signed-off-by: Philippe Martin <phmartin@redhat.com>
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
Co-authored-by: John Myers <9696606+johntmyers@users.noreply.github.com>
2026-09-17 22:59:27 +00:00

1153 lines
40 KiB
Python

# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""E2E tests for supervisor-managed provider placeholders in sandboxes.
Provider credentials are fetched at runtime by the sandbox supervisor via the
GetSandboxProviderEnvironment gRPC call. Sandboxed child processes should see
placeholder values (not raw secrets). Credentials must never be present in the
persisted sandbox spec environment map.
"""
from __future__ import annotations
import json
import socket
import subprocess
import sys
import textwrap
import time
from contextlib import contextmanager
from typing import TYPE_CHECKING
import grpc
import pytest
from openshell._proto import datamodel_pb2, openshell_pb2, sandbox_pb2
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
from openshell import Sandbox, SandboxClient, WorkspaceClient
# ---------------------------------------------------------------------------
# Policy helpers
# ---------------------------------------------------------------------------
def _is_placeholder_for_env_key(value: str, key: str) -> bool:
"""Return true when value is an OpenShell credential placeholder for key."""
prefix = "openshell:resolve:env:"
if value == f"{prefix}{key}":
return True
token = value.removeprefix(prefix)
if token == value:
return False
return token.startswith(("v", "s")) and token.endswith(f"_{key}")
def _default_policy() -> sandbox_pb2.SandboxPolicy:
"""Build a sandbox policy with standard filesystem/process/landlock settings."""
return sandbox_pb2.SandboxPolicy(
version=1,
filesystem=sandbox_pb2.FilesystemPolicy(
include_workdir=True,
read_only=["/usr", "/lib", "/etc", "/app", "/dev/urandom"],
read_write=["/sandbox", "/tmp"],
),
landlock=sandbox_pb2.LandlockPolicy(compatibility="best_effort"),
process=sandbox_pb2.ProcessPolicy(
run_as_user="sandbox", run_as_group="sandbox"
),
)
# ---------------------------------------------------------------------------
# Provider lifecycle helper
# ---------------------------------------------------------------------------
@contextmanager
def provider(
stub: object,
*,
name: str,
provider_type: str,
credentials: dict[str, str],
profile_workspace: str = "",
) -> Iterator[str]:
"""Create a provider for the duration of the block, then delete it."""
_delete_provider(stub, name)
stub.CreateProvider(
openshell_pb2.CreateProviderRequest(
workspace_scope=datamodel_pb2.WorkspaceSelector(workspace="default"),
provider=datamodel_pb2.Provider(
metadata=datamodel_pb2.ObjectMeta(name=name),
type=provider_type,
credentials=credentials,
profile_workspace=profile_workspace,
),
)
)
try:
yield name
finally:
_delete_provider(stub, name)
def _delete_provider(stub: object, name: str) -> None:
"""Delete a provider, ignoring not-found errors."""
try:
stub.DeleteProvider(
openshell_pb2.DeleteProviderRequest(
workspace_scope=datamodel_pb2.WorkspaceSelector(workspace="default"),
name=name,
)
)
except grpc.RpcError as exc:
if hasattr(exc, "code") and exc.code() == grpc.StatusCode.NOT_FOUND:
pass
else:
raise
def _delete_provider_profile(stub: object, profile_id: str) -> None:
"""Delete a provider profile, ignoring not-found errors."""
try:
stub.DeleteProviderProfile(
openshell_pb2.DeleteProviderProfileRequest(
id=profile_id,
workspace="default",
)
)
except grpc.RpcError as exc:
if hasattr(exc, "code") and exc.code() == grpc.StatusCode.NOT_FOUND:
pass
else:
raise
@contextmanager
def imported_provider_profile(
stub: object,
*,
profile: openshell_pb2.ProviderProfile,
source: str,
) -> Iterator[str]:
"""Import a workspace-scoped provider profile for the duration of the block."""
_delete_provider_profile(stub, profile.id)
response = stub.ImportProviderProfiles(
openshell_pb2.ImportProviderProfilesRequest(
profiles=[
openshell_pb2.ProviderProfileImportItem(
profile=profile,
source=source,
)
],
workspace="default",
)
)
assert response.imported, f"profile import failed: {response.diagnostics!r}"
try:
yield profile.id
finally:
_delete_provider_profile(stub, profile.id)
def _native_inference_profile(
*,
profile_id: str,
env_var: str,
port: int,
rules: list[sandbox_pb2.L7Rule],
auth_style: str = "bearer",
header_name: str = "authorization",
) -> openshell_pb2.ProviderProfile:
return openshell_pb2.ProviderProfile(
id=profile_id,
display_name=f"{profile_id} display",
description="E2E imported inference profile fixture",
category=openshell_pb2.PROVIDER_PROFILE_CATEGORY_INFERENCE,
inference_capable=True,
credentials=[
openshell_pb2.ProviderProfileCredential(
name="api_key",
description="API key",
env_vars=[env_var],
required=True,
auth_style=auth_style,
header_name=header_name,
)
],
endpoints=[
sandbox_pb2.NetworkEndpoint(
host="host.openshell.internal",
port=port,
protocol="rest",
tls="none",
enforcement="enforce",
rules=rules,
allowed_ips=[
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"fc00::/7",
],
)
],
binaries=[
sandbox_pb2.NetworkBinary(path="/usr/bin/python*"),
sandbox_pb2.NetworkBinary(path="/usr/local/bin/python*"),
sandbox_pb2.NetworkBinary(path="/sandbox/.uv/python/**/python*"),
],
)
@contextmanager
def native_endpoint_server() -> Iterator[int]:
"""Start a small host-side HTTP fixture that echoes auth and request data."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
listener.bind(("127.0.0.1", 0))
port = listener.getsockname()[1]
script = textwrap.dedent(
"""
import json
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
PORT = int(sys.argv[1])
class Handler(BaseHTTPRequestHandler):
def _reply(self):
content_length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(content_length) if content_length else b""
payload = {
"method": self.command,
"path": self.path,
"authorization": self.headers.get("Authorization"),
"x_api_key": self.headers.get("x-api-key"),
"body": body.decode("utf-8"),
}
encoded = json.dumps(payload).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
def do_GET(self):
self._reply()
def do_POST(self):
self._reply()
def log_message(self, fmt, *args):
pass
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
"""
)
proc = subprocess.Popen(
[sys.executable, "-c", script, str(port)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
if proc.poll() is not None:
stdout, stderr = proc.communicate(timeout=5)
raise RuntimeError(
"native endpoint fixture exited early: "
f"stdout={stdout!r} stderr={stderr!r}"
)
try:
with socket.create_connection(("127.0.0.1", port), timeout=1):
break
except OSError:
time.sleep(0.2)
else:
proc.kill()
stdout, stderr = proc.communicate(timeout=5)
raise RuntimeError(
"native endpoint fixture did not become ready: "
f"stdout={stdout!r} stderr={stderr!r}"
)
try:
yield port
finally:
proc.kill()
proc.communicate(timeout=5)
# ===========================================================================
# Tests: placeholder visibility
# ===========================================================================
def test_provider_credentials_available_as_env_vars(
sandbox: Callable[..., Sandbox],
sandbox_client: SandboxClient,
) -> None:
"""Sandbox child processes see provider env vars as placeholders."""
with provider(
sandbox_client._stub,
name="e2e-test-provider-env",
provider_type="claude-code",
credentials={"ANTHROPIC_API_KEY": "sk-e2e-test-key-12345"},
) as provider_name:
spec = datamodel_pb2.SandboxSpec(
policy=_default_policy(),
providers=[provider_name],
)
def read_env_var() -> str:
import os
return os.environ.get("ANTHROPIC_API_KEY", "NOT_SET")
with sandbox(spec=spec, delete_on_exit=True) as sb:
result = sb.exec_python(read_env_var)
assert result.exit_code == 0, result.stderr
value = result.stdout.strip()
assert _is_placeholder_for_env_key(value, "ANTHROPIC_API_KEY")
assert value != "sk-e2e-test-key-12345"
def test_profileless_provider_creation_is_rejected(
sandbox_client: SandboxClient,
) -> None:
"""New providers must reference a built-in or imported profile."""
with pytest.raises(grpc.RpcError) as exc_info:
sandbox_client._stub.CreateProvider(
openshell_pb2.CreateProviderRequest(
workspace_scope=datamodel_pb2.WorkspaceSelector(workspace="default"),
provider=datamodel_pb2.Provider(
metadata=datamodel_pb2.ObjectMeta(
name="e2e-test-profileless-provider"
),
type="generic",
credentials={"CUSTOM_SERVICE_TOKEN": "token-generic-123"},
),
)
)
assert exc_info.value.code() == grpc.StatusCode.INVALID_ARGUMENT
assert "provider profile 'generic' was not found" in exc_info.value.details()
def test_endpointless_profile_credentials_fail_closed_without_policy_binding(
sandbox: Callable[..., Sandbox],
sandbox_client: SandboxClient,
) -> None:
"""Endpointless profile credentials are withheld without an explicit binding."""
with provider(
sandbox_client._stub,
name="e2e-test-google-cloud-without-policy-binding",
provider_type="google-cloud",
credentials={"GCP_ADC_ACCESS_TOKEN": "gcp-e2e-token"},
) as provider_name:
spec = datamodel_pb2.SandboxSpec(
policy=_default_policy(),
providers=[provider_name],
)
def read_gcp_token() -> str:
import os
return os.environ.get("GCP_ADC_ACCESS_TOKEN", "NOT_SET")
with sandbox(spec=spec, delete_on_exit=True) as sb:
result = sb.exec_python(read_gcp_token)
assert result.exit_code == 0, result.stderr
assert result.stdout.strip() == "NOT_SET"
def test_endpointless_profile_credentials_use_explicit_policy_binding(
sandbox: Callable[..., Sandbox],
sandbox_client: SandboxClient,
) -> None:
"""An endpointless profile emits credentials only with an explicit binding."""
with provider(
sandbox_client._stub,
name="e2e-test-google-cloud-policy-binding",
provider_type="google-cloud",
credentials={"GCP_ADC_ACCESS_TOKEN": "gcp-e2e-token"},
) as provider_name:
policy = _default_policy()
policy.network_policies["gcp_storage"].CopyFrom(
sandbox_pb2.NetworkPolicyRule(
name="gcp_storage",
endpoints=[
sandbox_pb2.NetworkEndpoint(
host="storage.googleapis.com",
port=443,
protocol="rest",
access="full",
credential_binding=sandbox_pb2.NetworkCredentialBinding(
provider=provider_name
),
)
],
)
)
spec = datamodel_pb2.SandboxSpec(
policy=policy,
providers=[provider_name],
)
def read_gcp_token() -> str:
import os
return os.environ.get("GCP_ADC_ACCESS_TOKEN", "NOT_SET")
with sandbox(spec=spec, delete_on_exit=True) as sb:
result = sb.exec_python(read_gcp_token)
assert result.exit_code == 0, result.stderr
assert _is_placeholder_for_env_key(
result.stdout.strip(), "GCP_ADC_ACCESS_TOKEN"
)
def test_nvidia_provider_injects_nvidia_api_key_env_var(
sandbox: Callable[..., Sandbox],
sandbox_client: SandboxClient,
) -> None:
"""NVIDIA provider projects a placeholder env value into child processes."""
with provider(
sandbox_client._stub,
name="e2e-test-nvidia-provider-env",
provider_type="nvidia",
credentials={"NVIDIA_API_KEY": "nvapi-e2e-test-key"},
) as provider_name:
spec = datamodel_pb2.SandboxSpec(
policy=_default_policy(),
providers=[provider_name],
)
def read_nvidia_key() -> str:
import os
return os.environ.get("NVIDIA_API_KEY", "NOT_SET")
with sandbox(spec=spec, delete_on_exit=True) as sb:
result = sb.exec_python(read_nvidia_key)
assert result.exit_code == 0, result.stderr
assert _is_placeholder_for_env_key(result.stdout.strip(), "NVIDIA_API_KEY")
def test_attach_detach_updates_credentials_for_later_exec_launches(
sandbox: Callable[..., Sandbox],
sandbox_client: SandboxClient,
) -> None:
"""Later exec launches see provider attach/detach credential changes."""
stub = sandbox_client._stub
provider_name = "e2e-test-attach-detach-env"
with provider(
stub,
name=provider_name,
provider_type="nvidia",
credentials={"NVIDIA_API_KEY": "token-attach-detach"},
):
spec = datamodel_pb2.SandboxSpec(policy=_default_policy(), providers=[])
def read_attach_token() -> str:
import os
return os.environ.get("NVIDIA_API_KEY", "NOT_SET")
def exec_token(sb: Sandbox) -> str:
result = sb.exec_python(read_attach_token)
assert result.exit_code == 0, result.stderr
return result.stdout.strip()
def wait_for_token(sb: Sandbox, expected: str) -> None:
deadline = time.monotonic() + 35
last = None
while time.monotonic() < deadline:
last = exec_token(sb)
if expected == "NOT_SET":
matched = last == expected
else:
matched = _is_placeholder_for_env_key(last, "NVIDIA_API_KEY")
if matched:
return
time.sleep(2)
pytest.fail(f"expected {expected!r}, last exec saw {last!r}")
with sandbox(spec=spec, delete_on_exit=True) as sb:
assert exec_token(sb) == "NOT_SET"
try:
stub.AttachSandboxProvider(
openshell_pb2.AttachSandboxProviderRequest(
workspace_scope=datamodel_pb2.WorkspaceSelector(
workspace="default"
),
sandbox_name=sb.sandbox.name,
provider_name=provider_name,
)
)
wait_for_token(
sb,
"openshell:resolve:env:NVIDIA_API_KEY",
)
stub.DetachSandboxProvider(
openshell_pb2.DetachSandboxProviderRequest(
workspace_scope=datamodel_pb2.WorkspaceSelector(
workspace="default"
),
sandbox_name=sb.sandbox.name,
provider_name=provider_name,
)
)
wait_for_token(sb, "NOT_SET")
finally:
try:
stub.DetachSandboxProvider(
openshell_pb2.DetachSandboxProviderRequest(
workspace_scope=datamodel_pb2.WorkspaceSelector(
workspace="default"
),
sandbox_name=sb.sandbox.name,
provider_name=provider_name,
)
)
except grpc.RpcError as exc:
if exc.code() != grpc.StatusCode.NOT_FOUND:
raise
def test_imported_openai_profile_allows_native_endpoint_with_attached_provider(
sandbox: Callable[..., Sandbox],
sandbox_client: SandboxClient,
) -> None:
"""Imported fixture profiles should support native OpenAI-style access."""
stub = sandbox_client._stub
profile_id = f"e2e-native-openai-{int(time.time() * 1000)}"
provider_name = f"{profile_id}-provider"
secret = "sk-native-openai-secret"
profile = _native_inference_profile(
profile_id=profile_id,
env_var="OPENAI_API_KEY",
port=0,
rules=[
sandbox_pb2.L7Rule(
allow=sandbox_pb2.L7Allow(
method="POST",
path="/v1/chat/completions",
)
)
],
)
def call_native_openai(host: str, port: int) -> str:
import json
import os
import urllib.error
import urllib.request
body = json.dumps(
{
"model": "fixture-openai-model",
"messages": [{"role": "user", "content": "hello"}],
}
).encode()
request = urllib.request.Request(
f"http://{host}:{port}/v1/chat/completions",
data=body,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
return response.read().decode()
except urllib.error.HTTPError as exc:
raise RuntimeError(
f"native OpenAI request failed with {exc.code}: "
f"{exc.read().decode(errors='replace')}"
) from exc
with native_endpoint_server() as port:
profile.endpoints[0].port = port
with imported_provider_profile(
stub,
profile=profile,
source=f"{profile_id}.yaml",
):
with provider(
stub,
name=provider_name,
provider_type=profile_id,
credentials={"OPENAI_API_KEY": secret},
profile_workspace="default",
) as attached_provider:
spec = datamodel_pb2.SandboxSpec(
policy=_default_policy(),
providers=[attached_provider],
)
with sandbox(spec=spec, delete_on_exit=True) as sb:
result = sb.exec_python(
call_native_openai,
args=("host.openshell.internal", port),
timeout_seconds=60,
)
assert result.exit_code == 0, result.stderr
payload = json.loads(result.stdout)
body = json.loads(payload["body"])
assert payload["method"] == "POST"
assert payload["path"] == "/v1/chat/completions"
assert payload["authorization"] == f"Bearer {secret}"
assert body["model"] == "fixture-openai-model"
def test_imported_anthropic_profile_allows_native_endpoint_with_attached_provider(
sandbox: Callable[..., Sandbox],
sandbox_client: SandboxClient,
) -> None:
stub = sandbox_client._stub
profile_id = f"e2e-native-anthropic-{int(time.time() * 1000)}"
provider_name = f"{profile_id}-provider"
secret = "sk-native-anthropic-secret"
profile = _native_inference_profile(
profile_id=profile_id,
env_var="ANTHROPIC_API_KEY",
port=0,
rules=[
sandbox_pb2.L7Rule(
allow=sandbox_pb2.L7Allow(
method="POST",
path="/v1/messages",
)
)
],
auth_style="header",
header_name="x-api-key",
)
def call_native_anthropic(host: str, port: int) -> str:
import json
import os
import urllib.error
import urllib.request
body = json.dumps(
{
"model": "fixture-anthropic-model",
"messages": [{"role": "user", "content": "hello"}],
}
).encode()
request = urllib.request.Request(
f"http://{host}:{port}/v1/messages",
data=body,
headers={
"Content-Type": "application/json",
"x-api-key": os.environ["ANTHROPIC_API_KEY"],
"anthropic-version": "2023-06-01",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
return response.read().decode()
except urllib.error.HTTPError as exc:
raise RuntimeError(
f"native Anthropic request failed with {exc.code}: "
f"{exc.read().decode(errors='replace')}"
) from exc
with native_endpoint_server() as port:
profile.endpoints[0].port = port
with imported_provider_profile(
stub,
profile=profile,
source=f"{profile_id}.yaml",
):
with provider(
stub,
name=provider_name,
provider_type=profile_id,
credentials={"ANTHROPIC_API_KEY": secret},
profile_workspace="default",
) as attached_provider:
spec = datamodel_pb2.SandboxSpec(
policy=_default_policy(),
providers=[attached_provider],
)
with sandbox(spec=spec, delete_on_exit=True) as sb:
native_result = sb.exec_python(
call_native_anthropic,
args=("host.openshell.internal", port),
timeout_seconds=60,
)
assert native_result.exit_code == 0, native_result.stderr
payload = json.loads(native_result.stdout)
body = json.loads(payload["body"])
assert payload["method"] == "POST"
assert payload["path"] == "/v1/messages"
assert payload["x_api_key"] == secret
assert body["model"] == "fixture-anthropic-model"
# ===========================================================================
# Tests: security & edge cases
# ===========================================================================
def test_create_sandbox_rejects_unknown_provider(
sandbox_client: SandboxClient,
) -> None:
"""CreateSandbox fails fast when a provider name does not exist."""
spec = datamodel_pb2.SandboxSpec(
policy=_default_policy(),
providers=["nonexistent-provider-xyz"],
)
with pytest.raises(grpc.RpcError) as exc_info:
sandbox_client.create(workspace="default", spec=spec)
assert exc_info.value.code() == grpc.StatusCode.FAILED_PRECONDITION
assert "nonexistent-provider-xyz" in (exc_info.value.details() or "")
def test_credentials_not_in_persisted_spec_environment(
sandbox: Callable[..., Sandbox],
sandbox_client: SandboxClient,
) -> None:
"""Provider credentials should NOT appear in the sandbox spec's environment map."""
with provider(
sandbox_client._stub,
name="e2e-test-no-persist",
provider_type="claude-code",
credentials={"ANTHROPIC_API_KEY": "sk-should-not-persist"},
) as provider_name:
spec = datamodel_pb2.SandboxSpec(
policy=_default_policy(),
providers=[provider_name],
)
with sandbox(spec=spec, delete_on_exit=True) as sb:
fetched = sandbox_client._stub.GetSandbox(
openshell_pb2.GetSandboxRequest(
workspace_scope=datamodel_pb2.WorkspaceSelector(
workspace="default"
),
name=sb.sandbox.name,
)
)
persisted_env = dict(fetched.sandbox.spec.environment)
assert "ANTHROPIC_API_KEY" not in persisted_env, (
"credentials should not be persisted in sandbox spec environment"
)
# ===========================================================================
# Tests: provider update merge semantics
# ===========================================================================
def test_update_provider_preserves_unset_credentials_and_config(
sandbox_client: SandboxClient,
) -> None:
"""Updating one credential must not clobber other credentials or config."""
stub = sandbox_client._stub
name = "merge-test-preserve"
_delete_provider(stub, name)
try:
stub.CreateProvider(
openshell_pb2.CreateProviderRequest(
workspace_scope=datamodel_pb2.WorkspaceSelector(workspace="default"),
provider=datamodel_pb2.Provider(
metadata=datamodel_pb2.ObjectMeta(name=name),
type="codex",
credentials={
"CODEX_AUTH_ACCESS_TOKEN": "val-a",
"CODEX_AUTH_REFRESH_TOKEN": "val-b",
"CODEX_AUTH_ACCOUNT_ID": "account-id",
},
config={"BASE_URL": "https://example.com"},
),
)
)
stub.UpdateProvider(
openshell_pb2.UpdateProviderRequest(
workspace_scope=datamodel_pb2.WorkspaceSelector(workspace="default"),
provider=datamodel_pb2.Provider(
metadata=datamodel_pb2.ObjectMeta(name=name),
type="",
credentials={"CODEX_AUTH_ACCESS_TOKEN": "rotated-a"},
),
)
)
got = stub.GetProvider(
openshell_pb2.GetProviderRequest(
workspace_scope=datamodel_pb2.WorkspaceSelector(workspace="default"),
name=name,
)
)
p = got.provider
# Credential keys are preserved but values are redacted.
assert len(p.credentials) > 0, "credential keys should be preserved"
for key, val in p.credentials.items():
assert val == "REDACTED", (
f"credential '{key}' should be REDACTED, got '{val}'"
)
assert p.config["BASE_URL"] == "https://example.com", (
"config should be preserved"
)
finally:
_delete_provider(stub, name)
def test_update_provider_empty_maps_preserves_all(
sandbox_client: SandboxClient,
) -> None:
"""Sending empty credential and config maps should be a no-op."""
stub = sandbox_client._stub
name = "merge-test-noop"
_delete_provider(stub, name)
try:
stub.CreateProvider(
openshell_pb2.CreateProviderRequest(
workspace_scope=datamodel_pb2.WorkspaceSelector(workspace="default"),
provider=datamodel_pb2.Provider(
metadata=datamodel_pb2.ObjectMeta(name=name),
type="openai",
credentials={"OPENAI_API_KEY": "secret"},
config={"URL": "https://api.example.com"},
),
)
)
stub.UpdateProvider(
openshell_pb2.UpdateProviderRequest(
workspace_scope=datamodel_pb2.WorkspaceSelector(workspace="default"),
provider=datamodel_pb2.Provider(
metadata=datamodel_pb2.ObjectMeta(name=name),
type="",
),
)
)
got = stub.GetProvider(
openshell_pb2.GetProviderRequest(
workspace_scope=datamodel_pb2.WorkspaceSelector(workspace="default"),
name=name,
)
)
p = got.provider
# Credential keys are preserved but values are redacted.
assert len(p.credentials) > 0, "credential keys should be preserved"
for key, val in p.credentials.items():
assert val == "REDACTED", (
f"credential '{key}' should be REDACTED, got '{val}'"
)
assert p.config["URL"] == "https://api.example.com"
finally:
_delete_provider(stub, name)
def test_update_provider_merges_config_preserves_credentials(
sandbox_client: SandboxClient,
) -> None:
"""Updating only config should not touch credentials."""
stub = sandbox_client._stub
name = "merge-test-config-only"
_delete_provider(stub, name)
try:
stub.CreateProvider(
openshell_pb2.CreateProviderRequest(
workspace_scope=datamodel_pb2.WorkspaceSelector(workspace="default"),
provider=datamodel_pb2.Provider(
metadata=datamodel_pb2.ObjectMeta(name=name),
type="openai",
credentials={"OPENAI_API_KEY": "original-key"},
config={"ENDPOINT": "https://old.example.com"},
),
)
)
stub.UpdateProvider(
openshell_pb2.UpdateProviderRequest(
workspace_scope=datamodel_pb2.WorkspaceSelector(workspace="default"),
provider=datamodel_pb2.Provider(
metadata=datamodel_pb2.ObjectMeta(name=name),
type="",
config={"ENDPOINT": "https://new.example.com"},
),
)
)
got = stub.GetProvider(
openshell_pb2.GetProviderRequest(
workspace_scope=datamodel_pb2.WorkspaceSelector(workspace="default"),
name=name,
)
)
p = got.provider
# Credential keys are preserved but values are redacted.
assert len(p.credentials) > 0, "credential keys should be preserved"
for key, val in p.credentials.items():
assert val == "REDACTED", (
f"credential '{key}' should be REDACTED, got '{val}'"
)
assert p.config["ENDPOINT"] == "https://new.example.com"
finally:
_delete_provider(stub, name)
def test_update_provider_rejects_type_change(
sandbox_client: SandboxClient,
) -> None:
"""Attempting to change a provider's type must be rejected."""
stub = sandbox_client._stub
name = "merge-test-type-reject"
_delete_provider(stub, name)
try:
stub.CreateProvider(
openshell_pb2.CreateProviderRequest(
workspace_scope=datamodel_pb2.WorkspaceSelector(workspace="default"),
provider=datamodel_pb2.Provider(
metadata=datamodel_pb2.ObjectMeta(name=name),
type="openai",
credentials={"OPENAI_API_KEY": "val"},
),
)
)
with pytest.raises(grpc.RpcError) as exc_info:
stub.UpdateProvider(
openshell_pb2.UpdateProviderRequest(
workspace_scope=datamodel_pb2.WorkspaceSelector(
workspace="default"
),
provider=datamodel_pb2.Provider(
metadata=datamodel_pb2.ObjectMeta(name=name),
type="nvidia",
),
)
)
assert exc_info.value.code() == grpc.StatusCode.INVALID_ARGUMENT
assert "type cannot be changed" in exc_info.value.details()
finally:
_delete_provider(stub, name)
# ===========================================================================
# Tests: git transport network policy
# ===========================================================================
def test_github_provider_allows_https_git_clone(
sandbox: Callable[..., Sandbox],
sandbox_client: SandboxClient,
) -> None:
"""Built-in github provider permits anonymous HTTPS clone/fetch (#1769).
Git smart HTTP clone/fetch issues a POST to ``*/git-upload-pack``. The
read-only preset (GET/HEAD/OPTIONS) denied that POST, so ``git clone`` over
HTTPS failed. Attaching the github provider composes its network policy onto
the sandbox, exercising provider attachment, effective-policy composition,
TLS interception, and real git behavior end to end. git delegates HTTPS to a
``git-remote-https`` helper whose ancestor is ``/usr/bin/git``, so the
profile's git binary covers it via ancestor matching.
"""
with provider(
sandbox_client._stub,
name="e2e-test-github-clone",
provider_type="github",
# A required credential value is needed to create the provider, but an
# anonymous clone of a public repo never uses it: git only sends the
# token when a credential helper is configured.
credentials={"GITHUB_TOKEN": "e2e-placeholder-unused"},
) as provider_name:
# git opens /dev/null O_RDWR, so it must be read-write; the shared
# _default_policy only grants /dev/urandom. Everything else (binaries,
# CA bundle, clone target) is covered by the standard allowlist.
policy = _default_policy()
policy.filesystem.read_write.append("/dev/null")
spec = datamodel_pb2.SandboxSpec(
policy=policy,
providers=[provider_name],
)
with sandbox(spec=spec, delete_on_exit=True) as sb:
clone = sb.exec(
[
"git",
"clone",
"--depth",
"1",
"https://github.com/octocat/Hello-World.git",
"/tmp/hello-world",
],
timeout_seconds=120,
)
assert clone.exit_code == 0, (
"git clone over HTTPS should succeed with the github provider "
f"attached; stdout={clone.stdout!r} stderr={clone.stderr!r}"
)
# A completed clone materializes .git/HEAD, proving ref discovery
# (GET) and upload-pack (POST) both succeeded, not just a handshake.
head = sb.exec(["cat", "/tmp/hello-world/.git/HEAD"])
assert head.exit_code == 0, (
f"cloned repo is missing .git/HEAD; stderr={head.stderr!r}"
)
# ===========================================================================
# Tests: provider profile platform vs workspace scope isolation
# ===========================================================================
def test_provider_profile_platform_vs_workspace_isolation(
sandbox_client: "SandboxClient",
) -> None:
"""Platform-scoped profiles are visible in workspace listings; workspace profiles are not visible in platform listings."""
stub = sandbox_client._stub
platform_id = "e2e-platform-profile"
workspace_id = "e2e-workspace-profile"
def _make_profile(profile_id: str) -> openshell_pb2.ProviderProfileImportItem:
return openshell_pb2.ProviderProfileImportItem(
profile=openshell_pb2.ProviderProfile(
id=profile_id,
display_name=f"{profile_id} display",
category=openshell_pb2.PROVIDER_PROFILE_CATEGORY_OTHER,
),
source=f"{profile_id}.yaml",
)
def _cleanup() -> None:
for pid, ws in [(platform_id, ""), (workspace_id, "default")]:
try:
stub.DeleteProviderProfile(
openshell_pb2.DeleteProviderProfileRequest(id=pid, workspace=ws)
)
except grpc.RpcError:
pass
_cleanup()
try:
resp = stub.ImportProviderProfiles(
openshell_pb2.ImportProviderProfilesRequest(
profiles=[_make_profile(platform_id)],
workspace="",
)
)
assert resp.imported, "platform-scoped import should succeed"
resp = stub.ImportProviderProfiles(
openshell_pb2.ImportProviderProfilesRequest(
profiles=[_make_profile(workspace_id)],
workspace="default",
)
)
assert resp.imported, "workspace-scoped import should succeed"
platform_list = stub.ListProviderProfiles(
openshell_pb2.ListProviderProfilesRequest(page_size=200, workspace="")
)
platform_ids = [p.id for p in platform_list.profiles]
assert platform_id in platform_ids, (
"platform profile should appear in platform list"
)
assert workspace_id not in platform_ids, (
"workspace profile should NOT appear in platform list"
)
workspace_list = stub.ListProviderProfiles(
openshell_pb2.ListProviderProfilesRequest(page_size=200, workspace="default")
)
workspace_ids = [p.id for p in workspace_list.profiles]
assert workspace_id in workspace_ids, (
"workspace profile should appear in workspace list"
)
assert platform_id in workspace_ids, (
"platform profile should appear in workspace list (visible as fallback)"
)
finally:
_cleanup()
def test_cross_workspace_profile_ids_do_not_collide(
sandbox_client: "SandboxClient",
workspace_client: "WorkspaceClient",
) -> None:
"""Same profile ID in two workspaces must not cause a catalog collision."""
import contextlib
import uuid
stub = sandbox_client._stub
profile_id = f"e2e-xws-{uuid.uuid4().hex[:8]}"
ws_a = f"ws-a-{uuid.uuid4().hex[:8]}"
ws_b = f"ws-b-{uuid.uuid4().hex[:8]}"
def _make_profile() -> openshell_pb2.ProviderProfileImportItem:
return openshell_pb2.ProviderProfileImportItem(
profile=openshell_pb2.ProviderProfile(
id=profile_id,
display_name=f"{profile_id} display",
category=openshell_pb2.PROVIDER_PROFILE_CATEGORY_OTHER,
),
source=f"{profile_id}.yaml",
)
workspace_client.create(ws_a)
workspace_client.create(ws_b)
try:
resp_a = stub.ImportProviderProfiles(
openshell_pb2.ImportProviderProfilesRequest(
profiles=[_make_profile()],
workspace=ws_a,
)
)
assert resp_a.imported, "import into ws-a should succeed"
resp_b = stub.ImportProviderProfiles(
openshell_pb2.ImportProviderProfilesRequest(
profiles=[_make_profile()],
workspace=ws_b,
)
)
assert resp_b.imported, "import into ws-b should succeed"
list_a = stub.ListProviderProfiles(
openshell_pb2.ListProviderProfilesRequest(page_size=200, workspace=ws_a)
)
assert any(p.id == profile_id for p in list_a.profiles), (
"profile should appear in ws-a"
)
list_b = stub.ListProviderProfiles(
openshell_pb2.ListProviderProfilesRequest(page_size=200, workspace=ws_b)
)
assert any(p.id == profile_id for p in list_b.profiles), (
"profile should appear in ws-b"
)
finally:
for ws in [ws_a, ws_b]:
with contextlib.suppress(Exception):
stub.DeleteProviderProfile(
openshell_pb2.DeleteProviderProfileRequest(
id=profile_id, workspace=ws
)
)
with contextlib.suppress(Exception):
workspace_client.delete(ws)