feat(sandbox): expose services during creation (#3439)

* feat(sandbox): expose services during creation

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* feat(providers): refresh Codex credentials in gateway

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(cli): normalize create-time service URLs

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(server): roll back failed service exposure

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* docs(example): simplify Codex provider setup

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* docs(example): separate provider setup commands

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(sandbox): harden create-time service exposure

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* docs(example): bundle Codex provider profile

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(example): allow npm-installed Codex binary

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

---------

Signed-off-by: Drew Newberry <anewberry@nvidia.com>
This commit is contained in:
Drew Newberry
2026-09-20 23:19:25 -07:00
committed by GitHub
parent 29e89a2f22
commit 1905069948
43 changed files with 1902 additions and 573 deletions
+2
View File
@@ -21,6 +21,7 @@ from .sandbox import (
SandboxStatusRef,
SandboxTemplateClient,
SandboxWorkloadTemplateProvenanceRef,
ServiceExposure,
TlsConfig,
WorkspaceClient,
WorkspaceRef,
@@ -52,6 +53,7 @@ __all__ = [
"SandboxStatusRef",
"SandboxTemplateClient",
"SandboxWorkloadTemplateProvenanceRef",
"ServiceExposure",
"TlsConfig",
"WorkspaceClient",
"WorkspaceRef",
+47 -4
View File
@@ -80,6 +80,18 @@ def _all_workspaces_scope() -> datamodel_pb2.WorkspaceSelector:
return datamodel_pb2.WorkspaceSelector(all_workspaces=datamodel_pb2.AllWorkspaces())
def _service_exposure_messages(
exposures: Sequence[ServiceExposure] | None,
) -> list[openshell_pb2.SandboxServiceExposure]:
return [
openshell_pb2.SandboxServiceExposure(
service=exposure.service,
target_port=exposure.target_port,
)
for exposure in exposures or ()
]
class _ClientCallDetails(_ClientCallDetailsBase, grpc.ClientCallDetails):
pass
@@ -410,6 +422,14 @@ class SandboxStatusRef:
exit_code: int | None = None
@dataclass(frozen=True)
class ServiceExposure:
"""A loopback HTTP service to expose during sandbox creation."""
target_port: int
service: str = ""
class _ImmutableLabels(dict[str, str]):
"""A read-only, copy- and pickle-safe label mapping."""
@@ -451,9 +471,14 @@ class SandboxRef:
# immutable mapping remains safe for deepcopy, pickle, and asdict.
labels: Mapping[str, str] = field(default_factory=_ImmutableLabels, compare=False)
created_from_workload_template: SandboxWorkloadTemplateProvenanceRef | None = None
# Populated by create operations. The empty key identifies the unnamed service.
service_urls: Mapping[str, str] = field(
default_factory=_ImmutableLabels, compare=False
)
def __post_init__(self) -> None:
object.__setattr__(self, "labels", _ImmutableLabels(self.labels))
object.__setattr__(self, "service_urls", _ImmutableLabels(self.service_urls))
@property
def phase(self) -> int:
@@ -764,6 +789,7 @@ class SandboxClient:
spec: openshell_pb2.SandboxSpec | None = None,
name: str | None = None,
labels: Mapping[str, str] | None = None,
service_exposures: Sequence[ServiceExposure] | None = None,
) -> SandboxRef:
request_spec = spec if spec is not None else _default_spec()
response = self._stub.CreateSandbox(
@@ -772,10 +798,11 @@ class SandboxClient:
spec=request_spec,
name=name or "",
labels=dict(labels) if labels else {},
service_exposures=_service_exposure_messages(service_exposures),
),
timeout=self._timeout,
)
sandbox_ref = _sandbox_ref(response.sandbox)
sandbox_ref = _sandbox_ref(response.sandbox, response.service_urls)
if sandbox_ref.id == "":
raise SandboxError("CreateSandbox returned empty sandbox id")
return sandbox_ref
@@ -788,6 +815,7 @@ class SandboxClient:
spec: openshell_pb2.SandboxSpec | None = None,
name: str | None = None,
labels: Mapping[str, str] | None = None,
service_exposures: Sequence[ServiceExposure] | None = None,
) -> SandboxRef:
if not workload_template.strip():
raise SandboxError("workload_template is required")
@@ -799,10 +827,11 @@ class SandboxClient:
name=name or "",
labels=dict(labels) if labels else {},
workload_template=workload_template,
service_exposures=_service_exposure_messages(service_exposures),
),
timeout=self._timeout,
)
sandbox_ref = _sandbox_ref(response.sandbox)
sandbox_ref = _sandbox_ref(response.sandbox, response.service_urls)
if sandbox_ref.id == "":
raise SandboxError("CreateSandbox returned empty sandbox id")
return sandbox_ref
@@ -814,9 +843,17 @@ class SandboxClient:
spec: openshell_pb2.SandboxSpec | None = None,
name: str | None = None,
labels: Mapping[str, str] | None = None,
service_exposures: Sequence[ServiceExposure] | None = None,
) -> SandboxSession:
return SandboxSession(
self, self.create(workspace=workspace, spec=spec, name=name, labels=labels)
self,
self.create(
workspace=workspace,
spec=spec,
name=name,
labels=labels,
service_exposures=service_exposures,
),
)
def create_session_from_template(
@@ -827,6 +864,7 @@ class SandboxClient:
spec: openshell_pb2.SandboxSpec | None = None,
name: str | None = None,
labels: Mapping[str, str] | None = None,
service_exposures: Sequence[ServiceExposure] | None = None,
) -> SandboxSession:
return SandboxSession(
self,
@@ -836,6 +874,7 @@ class SandboxClient:
spec=spec,
name=name,
labels=labels,
service_exposures=service_exposures,
),
)
@@ -1677,7 +1716,10 @@ def _serialize_python_callable(
return base64.b64encode(payload).decode("ascii")
def _sandbox_ref(sandbox: openshell_pb2.Sandbox) -> SandboxRef:
def _sandbox_ref(
sandbox: openshell_pb2.Sandbox,
service_urls: Mapping[str, str] | None = None,
) -> SandboxRef:
status = sandbox.status if sandbox.HasField("status") else None
provenance = (
SandboxWorkloadTemplateProvenanceRef(
@@ -1700,6 +1742,7 @@ def _sandbox_ref(sandbox: openshell_pb2.Sandbox) -> SandboxRef:
),
labels=sandbox.metadata.labels if sandbox.metadata else {},
created_from_workload_template=provenance,
service_urls=service_urls or {},
)
+30 -1
View File
@@ -32,6 +32,7 @@ from openshell.sandbox import (
SandboxRef,
SandboxStatusRef,
SandboxTemplateClient,
ServiceExposure,
TlsConfig,
_atomic_replace,
_BearerAuthInterceptor,
@@ -2073,7 +2074,11 @@ class _FakeSandboxStub:
request.name or "generated",
dict(request.labels),
workspace=_request_workspace(request) or "default",
)
),
service_urls={
exposure.service: f"https://{exposure.service}.example.test/"
for exposure in request.service_exposures
},
)
def ListSandboxes(
@@ -2205,6 +2210,30 @@ def test_create_forwards_name_and_labels() -> None:
assert dict(ref.labels) == {"aiq": "deep-research"}
def test_create_forwards_service_exposures() -> None:
stub = _FakeSandboxStub()
client = _client_with_fake_stub(stub)
ref = client.create(
workspace="default",
name="app-server",
service_exposures=[
ServiceExposure(target_port=4500),
ServiceExposure(service="metrics", target_port=9090),
],
)
assert stub.create_request is not None
assert [
(exposure.service, exposure.target_port)
for exposure in stub.create_request.service_exposures
] == [("", 4500), ("metrics", 9090)]
assert dict(ref.service_urls) == {
"": "https://.example.test/",
"metrics": "https://metrics.example.test/",
}
def test_create_from_template_forwards_workload_template() -> None:
stub = _FakeSandboxStub()
client = _client_with_fake_stub(stub)