Merge branch 'main' into mgrossman/remove-sandbox-user

Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
This commit is contained in:
Matthew Grossman
2026-07-23 12:21:41 -07:00
231 changed files with 23405 additions and 4186 deletions
+2
View File
@@ -444,6 +444,8 @@ same branch. If the change affects user-facing compute-driver setup, also
update `docs/reference/sandbox-compute-drivers.mdx` or the relevant deployment
page.
Use the `sync-agent-infra` skill's maintenance map to identify related skill updates when the implementation changes behavior, commands, or development workflows. Run its full consistency check when the implementation adds, removes, or renames skills or crates; changes workflow relationships or skill coverage; modifies issue or PR templates; or changes agent cross-references. Fix any drift before committing.
### Step 12: Commit and Push
Commit all changes using conventional commit format. The `<type>` comes from the issue type in the plan:
+4
View File
@@ -24,6 +24,10 @@ in the same branch. If the change affects user-facing compute-driver setup,
also update `docs/reference/sandbox-compute-drivers.mdx` or the relevant
deployment docs.
### Check Agent Infrastructure
Use the `sync-agent-infra` skill's maintenance map to identify related skill updates when the branch changes behavior, commands, or development workflows. Run its full consistency check when the branch adds, removes, or renames skills or crates; changes workflow relationships or skill coverage; modifies issue or PR templates; or changes agent cross-references. Resolve any drift before creating the PR.
### Run Pre-commit Checks
Run the local pre-commit task before opening a PR:
+57 -17
View File
@@ -1,6 +1,6 @@
---
name: debug-inference
description: Debug why inference.local or external inference setup is failing. Use when the user cannot reach a local model server, has provider base URL issues, sees inference verification failures, hits protocol mismatches, or needs to diagnose inference on local vs remote gateways. Trigger keywords - debug inference, inference.local, local inference, ollama, vllm, sglang, trtllm, NIM, inference failing, model server unreachable, failed to verify inference endpoint, host.openshell.internal.
description: Debug why inference.local, direct external inference, or supervisor-only system inference is failing. Use when the user cannot reach a local model server, has provider base URL issues, sees inference verification failures, hits protocol mismatches, or needs to diagnose inference on local vs remote gateways. Trigger keywords - debug inference, inference.local, system inference, sandbox-system, local inference, ollama, vllm, sglang, trtllm, NIM, inference failing, model server unreachable, failed to verify inference endpoint, host.openshell.internal.
---
# Debug Inference
@@ -11,7 +11,7 @@ Use `openshell` CLI commands to inspect the active gateway, provider records, ma
## Overview
OpenShell supports two different inference paths. Diagnose the correct one first.
OpenShell supports three inference paths. Diagnose the correct one first.
1. **Managed inference** through `https://inference.local`
- Configured by `openshell inference set`
@@ -21,6 +21,10 @@ OpenShell supports two different inference paths. Diagnose the correct one first
- Controlled by `network_policies`
- Requires the application to call the external host directly
- Requires provider attachment and network access to be configured separately
3. **System inference** used by platform functions
- Configured by `openshell inference set --system`
- Uses the `sandbox-system` route
- Consumed in-process by the sandbox supervisor and not exposed to sandbox user code through `inference.local`
For local or self-hosted engines such as Ollama, vLLM, SGLang, TRT-LLM, and many NIM deployments, the most common managed inference pattern is an `openai` provider with `OPENAI_BASE_URL` pointing at a host the gateway can reach.
@@ -38,10 +42,13 @@ Use these commands first:
# Which gateway is active, and can the CLI reach it?
openshell status
# Show managed inference config for inference.local
# Show both the user-facing and system inference routes
openshell inference get
# Inspect the provider record referenced by inference.local
# Show only the supervisor-only system route
openshell inference get --system
# Inspect the provider record referenced by the relevant route
openshell provider get <provider-name>
# Inspect gateway topology details when remote/local confusion is suspected
@@ -59,9 +66,9 @@ When the user asks to debug inference, run diagnostics automatically in this ord
Establish these facts first:
1. Is the application calling `https://inference.local` or a direct external host?
1. Is sandbox code calling `https://inference.local`, is the application calling a direct external host, or is a platform function using system inference?
2. Which gateway is active, and is it local, remote, or cloud?
3. Which provider and model are configured for managed inference?
3. Which provider, model, and timeout are configured for the relevant route?
4. Is the upstream local to the gateway host, or somewhere else?
### Step 0: Check the Active Gateway
@@ -83,23 +90,31 @@ Common mistake:
- **Laptop-local model + remote gateway**: `host.openshell.internal` points to the remote gateway host, not your laptop. A laptop-local Ollama or vLLM server will not be reachable without a tunnel or shared reachable network path.
### Step 1: Check Whether Managed Inference Is Configured
### Step 1: Check Whether the Relevant Route Is Configured
Run:
```bash
openshell inference get
openshell inference get --system
```
Interpretation:
- **`Not configured`**: `inference.local` has no backend yet. Fix by configuring it:
- `openshell inference get` shows both the user-facing `inference.local` route and the system route. `--system` isolates the system route.
- **The `inference.local` route is `Not configured`**: managed inference has no backend. Configure it without `--system`:
```bash
openshell inference set --provider <name> --model <id>
```
- **Provider and model shown**: Continue to provider inspection.
- **System inference is `Not configured`**: platform functions have no system backend. Configure it separately:
```bash
openshell inference set --system --provider <name> --model <id>
```
- **Provider, model, and timeout shown**: Continue to provider inspection for the relevant route.
### Step 2: Inspect the Provider Record
@@ -111,10 +126,13 @@ openshell provider get <provider-name>
Check:
- Provider type matches the client API shape
- Provider type matches the client API shape and is supported for managed inference
- `openai` for OpenAI-compatible engines such as Ollama, vLLM, SGLang, TRT-LLM, and many NIM deployments
- `anthropic` for Anthropic Messages API
- `nvidia` for NVIDIA-hosted OpenAI-compatible endpoints
- `deepinfra` for DeepInfra's OpenAI-compatible endpoint
- `google-vertex-ai` for Vertex AI; Claude models use Anthropic Messages and other models use OpenAI Chat Completions
- `aws-bedrock` only through a configured Bedrock-compatible bridge today
- Required credential key exists
- `*_BASE_URL` override is correct when using a self-hosted endpoint
@@ -123,9 +141,11 @@ Fix examples:
```bash
openshell provider create --name ollama --type openai --credential OPENAI_API_KEY=empty --config OPENAI_BASE_URL=http://host.openshell.internal:11434/v1
openshell provider update ollama --type openai --credential OPENAI_API_KEY=empty --config OPENAI_BASE_URL=http://host.openshell.internal:11434/v1
openshell provider update ollama --credential OPENAI_API_KEY=empty --config OPENAI_BASE_URL=http://host.openshell.internal:11434/v1
```
`provider update` preserves the provider type and does not accept `--type`. Prefer bare credential keys, such as `--credential OPENAI_API_KEY`, when reading a real secret from the CLI environment.
### Step 3: Check Local Host Reachability
For host-backed local inference, confirm the upstream server:
@@ -142,21 +162,25 @@ Common mistakes:
### Step 4: Check Request Shape
Managed inference only works for `https://inference.local` and supported inference API paths.
User-facing managed inference only works for `https://inference.local` and supported inference API paths.
Supported patterns include:
- `POST /v1/chat/completions`
- `POST /v1/completions`
- `POST /v1/responses`
- `POST /v1/embeddings`
- `POST /v1/messages`
- `GET /v1/models`
- `GET /v1/models/*`
- `POST /model/{modelId}/invoke` for bridge-fronted `aws-bedrock`
Common mistakes:
- **Wrong scheme**: `http://inference.local` instead of `https://inference.local`
- **Unsupported path**: request does not match a known inference API
- **Protocol mismatch**: Anthropic client against an `openai` provider, or vice versa
- **Provider-specific mismatch**: Vertex Claude requests must use `/v1/messages`; other Vertex models currently use `/v1/chat/completions`; Bedrock uses its model-in-path invoke shape
Fix guidance:
@@ -166,6 +190,8 @@ Fix guidance:
### Step 5: Probe from a Sandbox
This probe validates the user-facing `inference.local` route. It does not exercise supervisor-only system inference.
Run a minimal request from inside a sandbox:
```bash
@@ -179,21 +205,28 @@ Interpretation:
- **`no compatible route`**: provider type and client API shape do not match
- **Connection refused / upstream unavailable / verification failures**: base URL, bind address, topology, or credentials are wrong
For system inference failures, inspect the platform function and sandbox supervisor/network logs after confirming `openshell inference get --system`. User code cannot call the `sandbox-system` route directly.
### Step 6: Reapply or Repair the Managed Route
After fixing the provider, repoint `inference.local`:
After fixing the provider, use `update` for a partial change or `set` to replace the route:
```bash
openshell inference set --provider <name> --model <id>
openshell inference update --provider <name>
openshell inference update --model <id>
openshell inference update --timeout 120
```
Add `--system` to target the system route. Without it, these commands target `inference.local`. A timeout of `0` uses the 60-second default; increase it for models with long reasoning or idle streaming phases.
If the endpoint is intentionally offline and you only want to save the config:
```bash
openshell inference set --provider <name> --model <id> --no-verify
```
Inference updates are hot-reloaded to all sandboxes on the active gateway within about 5 seconds by default.
Use `--no-verify` only when the endpoint is intentionally offline or the provider protocol cannot be verified, such as the current bridge-fronted Bedrock flow. Inference updates are hot-reloaded to running sandboxes within about 5 seconds by default.
### Step 7: Diagnose Direct External Inference
@@ -203,9 +236,11 @@ Check instead:
1. The application is configured to call the external hostname directly
2. A provider with the needed credentials exists
3. The sandbox is launched with that provider attached
3. The sandbox has that provider attached (`openshell sandbox provider list [name]`)
4. `network_policies` allow that host, port, and HTTP rules
Attach or detach a provider on an existing sandbox with `openshell sandbox provider attach <sandbox> <provider>` and `openshell sandbox provider detach <sandbox> <provider>`.
Use the `generate-sandbox-policy` skill when the user needs help authoring policy YAML.
## Fix: Local Host Inference Timeouts (Firewall)
@@ -305,11 +340,13 @@ Both commands should return the upstream model list.
| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| `openshell inference get` shows `Not configured` | No managed inference route configured | `openshell inference set --provider <name> --model <id>` |
| System inference is `Not configured` | Platform-only route has no backend | `openshell inference set --system --provider <name> --model <id>` |
| `failed to verify inference endpoint` | Bad base URL, wrong credentials, wrong provider type, or upstream not reachable | Fix provider config, then rerun `openshell inference set`; use `--no-verify` only when the endpoint is intentionally offline |
| Base URL uses `127.0.0.1` | Loopback points at the wrong runtime | Use `host.openshell.internal` or another gateway-reachable host |
| Local engine works only when gateway is local | Gateway moved to remote host | Run the engine on the gateway host, add a tunnel, or use direct external access |
| `connection not allowed by policy` on `inference.local` | Unsupported path or method | Use a supported inference API path |
| `no compatible route` | Provider type does not match request shape | Switch provider type or change the client API |
| `no compatible route` | Provider type does not match request shape | Create or select a provider of the matching type, or change the client API |
| `inference.local` works but a platform function fails | User route is configured but `sandbox-system` is missing or wrong | `openshell inference get --system`; configure or update with `--system`; inspect supervisor logs |
| Direct call to external host is denied | Missing policy or provider attachment | Update `network_policies` and launch sandbox with the right provider |
| SDK fails on empty auth token | Client requires a non-empty API key even though OpenShell injects the real one | Use any placeholder token such as `test` |
| Upstream timeout from container to host-local backend | Host firewall or network config blocks container-to-host traffic | Allow the Docker bridge subnet to reach the inference port on the host gateway IP (see firewall fix section above) |
@@ -328,6 +365,9 @@ openshell gateway info
echo "=== Managed Inference ==="
openshell inference get
echo "=== System Inference Only ==="
openshell inference get --system
echo "=== Providers ==="
openshell provider list
@@ -340,7 +380,7 @@ openshell sandbox create -- curl https://inference.local/v1/chat/completions --j
When you report back, state:
1. Which inference path is failing (`inference.local` vs direct external)
1. Which inference path is failing (`inference.local`, direct external, or system inference)
2. Whether gateway topology is part of the problem
3. The most likely root cause
4. The exact fix commands the user should run
+66 -10
View File
@@ -1,11 +1,11 @@
---
name: debug-openshell-cluster
description: Debug why an OpenShell gateway deployment is unhealthy, unreachable, or unable to create sandboxes. Use when the user has a gateway health failure, Docker/Podman runtime issue, Helm install failure, Kubernetes scheduling issue, TLS secret issue, VM driver issue, or sandbox startup problem. Trigger keywords - debug gateway, gateway failing, deployment failing, helm install failing, cluster health, gateway health, gateway not starting, health check failed, sandbox pending, docker driver, podman driver, vm driver.
description: Debug why an OpenShell gateway deployment is unhealthy, unreachable, or unable to create sandboxes. Use for gateway health failures, Docker/Podman runtime issues, Helm failures, Kubernetes scheduling, TLS or auth, gateway interceptors, supervisor middleware startup or runtime failures, external compute-driver sockets, VM drivers, or sandbox startup. Trigger keywords - debug gateway, gateway failing, deployment failing, helm install failing, cluster health, gateway health, gateway not starting, health check failed, sandbox pending, docker driver, podman driver, kubernetes driver, external driver, compute driver socket, gateway interceptor, supervisor middleware, middleware failed, vm driver.
---
# Debug OpenShell Gateway Deployment
Diagnose a gateway and its selected compute platform. Do not assume OpenShell provisions Kubernetes or runs a k3s container. OpenShell targets a reachable gateway endpoint backed by Docker, Podman, Kubernetes, or the experimental VM driver.
Diagnose a gateway and its selected compute platform. Do not assume OpenShell provisions Kubernetes or runs a k3s container. OpenShell targets a reachable gateway endpoint backed by Docker, Podman, Kubernetes, the experimental VM driver, or an operator-managed out-of-tree compute driver.
Use `openshell` first to identify the active endpoint. Then use the platform tools that match the gateway's compute driver: `docker`, `podman`, `kubectl`/`helm`, or VM driver logs.
@@ -15,7 +15,7 @@ The target deployment flow is:
1. Operator starts or deploys the gateway with system packages, systemd, Helm, or a development task. The CLI does not start, stop, or destroy gateway services.
2. Operator configures the compute driver.
3. Operator provides TLS and SSH relay material for the deployment mode.
3. Operator provides the CLI and supervisor authentication material required by the deployment mode: edge or OIDC user auth, optional CLI mTLS, and gateway-minted sandbox JWTs.
4. The CLI registers a reachable gateway endpoint with `openshell gateway add`.
5. The gateway creates sandboxes through the selected compute driver.
@@ -25,7 +25,7 @@ For local evaluation only, TLS may be disabled and the gateway can be reached th
- The `openshell` CLI must be available for endpoint checks.
- Know the active gateway name and endpoint, or be able to inspect local gateway metadata.
- Know the compute platform: Docker, Podman, Kubernetes, or VM.
- Know the compute platform: Docker, Podman, Kubernetes, VM, or an out-of-tree driver.
- For Kubernetes: `kubectl` must target the cluster that hosts OpenShell and Helm version 3 or later must be available.
- For Docker or Podman: the runtime socket must be reachable from the gateway host.
@@ -36,15 +36,24 @@ Run diagnostics in order and stop once the root cause is clear.
### Step 1: Check CLI Reachability
```bash
openshell gateway list --output json
openshell gateway info
openshell status
```
For a one-off endpoint check that bypasses stored gateway selection and metadata:
```bash
openshell --gateway-endpoint <url> status
```
Common findings:
- `No active gateway`: register one with `openshell gateway add <endpoint>`.
- Connection refused: gateway process is not running, service exposure is wrong, or a port-forward/proxy is not active.
- TLS/certificate errors: CLI mTLS bundle does not match the gateway CA, or the gateway is running with unexpected TLS settings.
- TLS/certificate errors: the endpoint scheme or trust chain is wrong, a local mTLS bundle does not match the gateway CA, or TLS termination does not match the gateway listener.
- `Unauthenticated` from an edge or OIDC gateway: refresh stored credentials with `openshell gateway login [name]`, then retry. Use `gateway logout` only when intentionally clearing local credentials.
- A direct development endpoint with a private or self-signed certificate can be isolated with `--gateway-endpoint <url> --gateway-insecure`; do not persist or recommend insecure verification for shared gateways.
### Step 2: Identify the Compute Platform
@@ -56,8 +65,48 @@ Use gateway metadata, deployment values, or the user's setup notes to identify t
| Podman | Podman socket, rootless networking, sandbox containers, image pulls. |
| Kubernetes | Helm release, gateway workload, service, secrets, sandbox pods, events. |
| VM | VM driver logs, rootfs availability, host virtualization support. |
| Extension | External driver process, Unix socket ownership/mode, configured driver name, capability handshake, gateway logs. |
### Step 3: Check Docker-Backed Gateways
### Step 3: Check Gateway Startup Dependencies
Before debugging the compute platform, inspect gateway logs for failures in dependencies initialized before the listener becomes ready.
For out-of-tree compute drivers, confirm the custom driver name and socket agree across CLI flags or `gateway.toml`, and that the operator-owned driver is running before the gateway starts:
```bash
rg -n 'compute_drivers|socket_path' /etc/openshell/gateway.toml
stat /run/openshell/<driver>.sock
journalctl -u <driver-service> --no-pager --lines=200
journalctl -u openshell-gateway --no-pager --lines=200
```
The custom driver name must not be a reserved built-in name (`docker`, `podman`, `kubernetes`, or `vm`). The socket must be accessible only to the intended gateway identity. Check gateway logs for connection errors, `GetCapabilities` failures, or an unexpected advertised driver name. The gateway does not create or supervise out-of-tree driver processes or sockets.
For configured gateway interceptors, inspect `[[openshell.gateway.interceptors]]`, their Unix or network endpoints, and gateway startup logs:
```bash
rg -n 'interceptors|provider_profile_sources|grpc_endpoint|binding_policy|failure_policy' /etc/openshell/gateway.toml
stat /run/openshell/interceptors/<name>.sock
journalctl -u <interceptor-service> --no-pager --lines=200
journalctl -u openshell-gateway --no-pager --lines=200
```
The gateway calls each interceptor's `Describe` RPC and validates its manifest at startup. Check for unreachable endpoints, invalid RPC/phase bindings, strict `allowlist` or `exact` mismatches, and `post_commit` bindings that resolve to `fail_closed`. If `provider_profile_sources` names an interceptor, that interceptor must advertise provider-profile capability and return a valid, duplicate-free catalog. A selected interceptor-only source is authoritative; include `builtin` or `user` sources explicitly when composition is intended.
For operator-run supervisor middleware, inspect `[[openshell.supervisor.middleware]]`, service reachability, and both gateway and supervisor logs:
```bash
rg -n 'supervisor|middleware|grpc_endpoint|max_body_bytes|timeout' /etc/openshell/gateway.toml
journalctl -u <middleware-service> --no-pager --lines=200
journalctl -u openshell-gateway --no-pager --lines=200
openshell logs <sandbox-name> --tail --source sandbox
```
The middleware service must start before the gateway and be reachable from both the gateway and sandbox supervisors. Gateway startup fails if `Describe` is unavailable, a manifest exposes duplicate `HttpRequest/pre_credentials` bindings, the registration claims the reserved `openshell/` namespace, or body and timeout limits are invalid. Changing a registration requires a gateway restart. A policy update can also fail before persistence if the selected implementation rejects its `network_middlewares` config.
At request time, distinguish an explicit `middleware_denied` result from `middleware_failed`. A denial is always enforced. A failure follows the policy-local `on_error`: `fail_closed` blocks the request, while `fail_open` bypasses only that stage and emits a detection finding. If a running supervisor cannot install a new registry, it preserves its last-known-good generation and emits a configuration failure event.
### Step 4: Check Docker-Backed Gateways
```bash
docker info
@@ -110,7 +159,7 @@ For source checkout development, restart the local gateway with:
mise run gateway:docker
```
### Step 4: Check Podman-Backed Gateways
### Step 5: Check Podman-Backed Gateways
```bash
podman info
@@ -126,7 +175,7 @@ Common findings:
- Sandbox image missing or pull denied: verify image reference and registry credentials.
- Supervisor cannot call back: check callback endpoint and gateway logs.
### Step 5: Check Kubernetes Helm Gateways
### Step 6: Check Kubernetes Helm Gateways
```bash
helm -n openshell status openshell
@@ -319,7 +368,7 @@ kubectl -n <sandbox-namespace> logs <sandbox-pod> -c openshell-supervisor-networ
kubectl -n <sandbox-namespace> logs <sandbox-pod> -c agent --tail=200
```
### Step 6: Check VM-Backed Gateways
### Step 7: Check VM-Backed Gateways
Use the VM driver logs and host diagnostics available in the user's environment. Verify:
@@ -346,6 +395,13 @@ openshell logs <sandbox-name>
| Kubernetes gateway pod pending | PVC unbound, taint, selector, or insufficient resources | `kubectl -n openshell describe pod <pod>` |
| Kubernetes gateway pod crash loops | Missing secret, bad DB URL, bad TLS config | `kubectl -n openshell logs deployment/openshell -c openshell-gateway` or `kubectl -n openshell logs statefulset/openshell -c openshell-gateway` |
| CLI TLS error | Local mTLS bundle does not match server cert/CA | Check `~/.config/openshell/gateways/<name>/mtls/` |
| Edge or OIDC gateway returns `Unauthenticated` | Stored login expired, audience/scopes mismatch, or gateway auth configuration changed | `openshell gateway info`, `openshell gateway login <name>`, gateway auth logs |
| Gateway fails before serving health after enabling an interceptor | Interceptor endpoint unavailable or manifest/binding validation failed | Gateway and interceptor logs; interceptor socket; `binding_policy`, phases, and failure policy |
| Provider profiles disappear after enabling an interceptor catalog | `provider_profile_sources` selected only an authoritative interceptor or returned invalid/duplicate IDs | Inspect source list and interceptor `Describe`/catalog logs; include `builtin` and `user` when intended |
| Gateway fails after registering supervisor middleware | Service unavailable, invalid manifest, duplicate binding, reserved name, or invalid body/timeout limit | Middleware service and gateway logs; `[[openshell.supervisor.middleware]]`; `Describe` response |
| Policy update rejects `network_middlewares` | Unknown middleware name, implementation-owned config invalid, duplicate order, broad/invalid host selector, or fail-closed coverage of `tls: skip` | Policy error, gateway logs, middleware `ValidateConfig`, selector and order fields |
| HTTP request returns `middleware_failed` or `middleware_denied` | Selected stage failed or explicitly denied the admitted request | Sandbox OCSF logs; policy-local middleware config; service availability; `on_error` |
| Custom compute driver is unavailable | Driver process/socket missing, inaccessible, or configured with a reserved/mismatched name | Socket ownership/mode, driver service logs, gateway `GetCapabilities` logs |
| Image pull failure | Gateway or sandbox image cannot be pulled | Runtime events and image pull credentials |
| `K8s namespace not ready` with `envoy-gateway-openshell.yaml: the server could not find the requested resource` | Optional Gateway API manifest was applied without Envoy Gateway CRDs, or k3s Helm controller startup exceeded the namespace wait | Apply `deploy/kube/manifests/envoy-gateway-openshell.yaml` manually only after Envoy Gateway is installed and `grpcRoute` is enabled |
| HTTPS ingress (`grpcRoute.gateway.listener.protocol=HTTPS`) connection resets or TLS handshake hangs | Envoy terminates TLS but the gateway pod still expects TLS, so the plaintext backend hop fails | Set `server.disableTls=true` so Envoy forwards plaintext to the pod; verify the listener `certificateRefs` Secret exists in the release namespace and `openshell status` over `https://<host>` |
@@ -359,7 +415,7 @@ When handing results back to the user, include:
- Compute platform and driver.
- Gateway process or workload status.
- Recent gateway log summary.
- Missing or malformed TLS or SSH relay material.
- Missing or malformed TLS, OIDC/mTLS, or sandbox JWT material.
- Service exposure status.
- Sandbox workload status.
- The exact command that failed and the shortest fix.
@@ -1,17 +1,17 @@
---
name: generate-sandbox-policy
description: Generate sandbox security policies from plain-language requirements and optional REST API documentation. At minimum, takes API host:port endpoints and intent to produce preset-based or L4 policies. With full API docs (OpenAPI, Swagger, markdown), generates fine-grained per-endpoint L7 rules. Trigger keywords - generate policy, create policy, update policy, change policy, sandbox policy, network policy, API policy, security policy, allow API, restrict API.
description: Generate sandbox security policies from plain-language requirements and optional REST API documentation. Produces L4 or fine-grained L7 network policies and ordered network middleware configuration. Use for API access rules, middleware host selection, failure behavior, or built-in and operator-run middleware attachment. Trigger keywords - generate policy, create policy, update policy, change policy, sandbox policy, network policy, API policy, security policy, allow API, restrict API, network middleware, supervisor middleware.
---
# Generate Sandbox Policy
Generate YAML sandbox network policies from REST API documentation and natural-language user requirements.
Generate YAML sandbox network policies and network middleware configuration from API documentation and natural-language user requirements.
## Overview
This skill translates a user's plain-language policy intent into a valid sandbox policy. The amount of detail the user provides determines the granularity of the generated policy — from broad L4 or preset-based policies (just a host:port) up to fine-grained per-endpoint L7 rules (full API docs).
The output is a `network_policies` YAML block (and optionally a full policy file) that conforms to the sandbox policy schema.
The output is a `network_policies` YAML block, an optional `network_middlewares` block, and optionally a full policy file that conforms to the sandbox policy schema.
## Step 1: Gather Inputs
@@ -79,6 +79,7 @@ Regardless of tier, extract (or infer) these from the user's description:
| **Paths** | Specific URL paths or patterns | Only for custom/fine-grained |
| **Enforcement** | `enforce` or `audit`? Default to `enforce`. | No — has a default |
| **Binary** | Which binary/process should have access | Yes — ask if not stated |
| **Middleware** | Whether admitted HTTP requests need an ordered built-in or operator-run processing stage | No |
If the host and access level are clear but binaries are not specified, ask the user which binary or process will be making the requests. Suggest common defaults like `/usr/bin/curl`, `/usr/local/bin/claude`, etc.
@@ -165,8 +166,15 @@ Key sections to reference:
- **`L7Rule` / `L7Allow`** — method + path matching
- **Access Presets** — `read-only`, `read-write`, `full`
- **Private IP Access via `allowed_ips`** — CIDR allowlist for private IP space
- **Network Middleware** - top-level middleware configs, ordering, host selection, and failure behavior
- **Validation Rules** — what combinations are valid/invalid
When middleware is requested, also read the full operational reference:
```
Read docs/extensibility/supervisor-middleware.mdx
```
Also read the architecture overview for enforcement context. The default policy is baked into the community base image (`ghcr.io/nvidia/openshell-community/sandboxes/base:latest`). For reference, consult:
```
@@ -206,6 +214,17 @@ Is L7 inspection needed?
**Critical**: `protocol: rest` on port 443 without `tls: terminate` will not work — the proxy cannot inspect encrypted traffic. Always set `tls: terminate` when combining port 443 with L7 rules.
### Middleware Decision
Add `network_middlewares` only when the user asks to inspect, transform, redact, or independently authorize admitted HTTP requests. Middleware runs after network and L7 policy admission and before provider credential injection.
- Use a built-in name such as `openshell/regex` without gateway registration.
- Use an operator-owned middleware name only when it is already registered under `[[openshell.supervisor.middleware]]` and reachable from both the gateway and sandbox supervisors.
- Default `on_error` to `fail_closed`. Use `fail_open` only when bypassing the stage preserves the user's stated security requirement.
- Assign unique `order` values across the complete policy. Lower values run first, and at most 10 configs may be selected.
- Match the narrowest destination hosts possible with `endpoints.include`; use `exclude` when a broad selector has trusted exceptions.
- Do not select fail-closed middleware for `tls: skip` endpoints because the supervisor cannot inspect that traffic.
### Mapping Paths to Glob Patterns (when building explicit rules)
Only needed for the **Moderate** and **Full** tiers. Translate API path parameters to glob patterns:
@@ -266,6 +285,23 @@ network_policies:
- { path: <binary_path> }
```
When middleware is requested, add it as a separate top-level map rather than nesting it under a network policy:
```yaml
network_middlewares:
<config_key>:
name: <human_readable_name>
middleware: <built_in_or_registered_name>
order: 10
config: {}
on_error: fail_closed
endpoints:
include: ["<api_host>"]
# exclude: ["<trusted_host>"]
```
The map key is the stable policy-local identity. Middleware selection is independent of the network policy entry that admitted the request.
### Deny Rules
Use `deny_rules` to block specific dangerous operations while allowing broad access. Deny rules are evaluated after allow rules and take precedence. This is the inverse of the `rules` approach — instead of enumerating every allowed operation, you grant broad access and block a small set of dangerous ones.
@@ -337,6 +373,9 @@ Before presenting the policy to the user, verify correctness **and** flag breadt
- [ ] If `tls: terminate` is set, `protocol` is also set
- [ ] `rules` list is not empty when present
- [ ] If `protocol: sql`, `enforcement` is not `enforce`
- [ ] Every middleware config has a non-empty `middleware` name and non-empty `endpoints.include`
- [ ] Middleware `order` values are unique and no selected chain exceeds 10 stages
- [ ] No fail-closed middleware selector can cover a `tls: skip` endpoint
### Schema Warnings (log-only, but should be fixed)
@@ -349,6 +388,7 @@ Before presenting the policy to the user, verify correctness **and** flag breadt
- [ ] Every endpoint has `host` and `port`
- [ ] Every binary has `path`
- [ ] Policy key matches `name` field
- [ ] Every middleware selector has at most 32 combined `include` and `exclude` patterns
### Breadth Warnings
@@ -365,6 +405,8 @@ Evaluate the generated policy for overly broad access and **include warnings in
| **Multiple broad endpoints** in one policy | "This policy grants the same broad access to N different hosts. If any of these hosts needs tighter restrictions later, you'll need to split the policy." |
| **Hostless `allowed_ips`** (no `host` field) | "This endpoint has no `host` — any domain resolving to the allowed IP range on this port will be permitted. Consider adding a `host` field to restrict which domains can use this allowlist." |
| **Broad CIDR** in `allowed_ips` (e.g., `10.0.0.0/8`) | "This `allowed_ips` entry covers a very broad range. Consider narrowing to a specific subnet (e.g., `10.0.5.0/24`) to minimize exposure." |
| **`on_error: fail_open`** | "This middleware can be bypassed when it is unavailable, rejects configuration, returns an invalid result, or exceeds its body limit. Use `fail_closed` unless availability is more important than this control." |
| **Broad middleware host selector** | "This middleware applies independently of the admitting network rule to every matching HTTP destination. Narrow `endpoints.include` or add exclusions if the stage is not required for every matching host." |
Format breadth warnings clearly in the output, e.g.:
+10 -10
View File
@@ -123,11 +123,11 @@ pr_number="<digits-only>"
[[ "$pr_number" =~ ^[0-9]+$ ]] || { echo "invalid PR number" >&2; exit 1; }
```
Use a restricted sandbox-name character set:
Use the portable Kubernetes DNS-1123 sandbox-name format even when the selected gateway currently uses another driver:
```bash
sandbox_name="gator-pr-${pr_number}-supervised"
[[ "$sandbox_name" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo "invalid sandbox name" >&2; exit 1; }
[[ "$sandbox_name" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || { echo "invalid sandbox name" >&2; exit 1; }
```
For local image contexts passed to `--from`, use an agent-created path such as `mktemp -d`; do not pass raw user-supplied paths without validating that they are expected local Dockerfile contexts.
@@ -144,7 +144,7 @@ pr_number="<digits-only>"
[[ "$gateway_name" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo "invalid gateway name" >&2; exit 1; }
[[ "$pr_number" =~ ^[0-9]+$ ]] || { echo "invalid PR number" >&2; exit 1; }
sandbox_name="gator-pr-${pr_number}-supervised"
[[ "$sandbox_name" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo "invalid sandbox name" >&2; exit 1; }
[[ "$sandbox_name" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || { echo "invalid sandbox name" >&2; exit 1; }
./scripts/agents/run.sh \
--agent gator \
@@ -165,7 +165,7 @@ issue_number="<digits-only>"
[[ "$gateway_name" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo "invalid gateway name" >&2; exit 1; }
[[ "$issue_number" =~ ^[0-9]+$ ]] || { echo "invalid issue number" >&2; exit 1; }
sandbox_name="gator-issue-${issue_number}-supervised"
[[ "$sandbox_name" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo "invalid sandbox name" >&2; exit 1; }
[[ "$sandbox_name" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || { echo "invalid sandbox name" >&2; exit 1; }
./scripts/agents/run.sh \
--agent gator \
@@ -186,7 +186,7 @@ issue_number="<digits-only>"
[[ "$pr_number" =~ ^[0-9]+$ ]] || { echo "invalid PR number" >&2; exit 1; }
[[ "$issue_number" =~ ^[0-9]+$ ]] || { echo "invalid issue number" >&2; exit 1; }
sandbox_name="gator-pr-${pr_number}-supervised"
[[ "$sandbox_name" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo "invalid sandbox name" >&2; exit 1; }
[[ "$sandbox_name" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || { echo "invalid sandbox name" >&2; exit 1; }
./scripts/agents/run.sh \
--agent gator \
@@ -207,7 +207,7 @@ pr_number="<digits-only>"
[[ "$gateway_name" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo "invalid gateway name" >&2; exit 1; }
[[ "$pr_number" =~ ^[0-9]+$ ]] || { echo "invalid PR number" >&2; exit 1; }
sandbox_name="gator-pr-${pr_number}-supervised"
[[ "$sandbox_name" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo "invalid sandbox name" >&2; exit 1; }
[[ "$sandbox_name" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || { echo "invalid sandbox name" >&2; exit 1; }
./scripts/agents/run.sh \
--agent gator \
@@ -228,7 +228,7 @@ pr_number="<digits-only>"
[[ "$gateway_name" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo "invalid gateway name" >&2; exit 1; }
[[ "$pr_number" =~ ^[0-9]+$ ]] || { echo "invalid PR number" >&2; exit 1; }
sandbox_name="gator-pr-${pr_number}-gpt56sol-supervised"
[[ "$sandbox_name" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo "invalid sandbox name" >&2; exit 1; }
[[ "$sandbox_name" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || { echo "invalid sandbox name" >&2; exit 1; }
CODEX_MODEL=gpt-5.6-sol \
./scripts/agents/run.sh \
@@ -250,7 +250,7 @@ pr_number="<digits-only>"
[[ "$gateway_name" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo "invalid gateway name" >&2; exit 1; }
[[ "$pr_number" =~ ^[0-9]+$ ]] || { echo "invalid PR number" >&2; exit 1; }
sandbox_name="gator-pr-${pr_number}-gpt56sol-supervised"
[[ "$sandbox_name" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo "invalid sandbox name" >&2; exit 1; }
[[ "$sandbox_name" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || { echo "invalid sandbox name" >&2; exit 1; }
tmp_context="$(mktemp -d "${TMPDIR:-/tmp}/gator-codex-XXXXXX")"
cp -R scripts/agents/gator/. "$tmp_context"/
@@ -290,7 +290,7 @@ Read that file directly. Important markers:
gateway_name="<selected-gateway-name>"
sandbox_name="<safe-sandbox-name>"
[[ "$gateway_name" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo "invalid gateway name" >&2; exit 1; }
[[ "$sandbox_name" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo "invalid sandbox name" >&2; exit 1; }
[[ "$sandbox_name" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || { echo "invalid sandbox name" >&2; exit 1; }
openshell --gateway "$gateway_name" sandbox list
openshell --gateway "$gateway_name" sandbox get "$sandbox_name"
@@ -318,7 +318,7 @@ Before deleting, check that the sandbox is truly stale or that the operator aske
gateway_name="<selected-gateway-name>"
sandbox_name="<safe-sandbox-name>"
[[ "$gateway_name" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo "invalid gateway name" >&2; exit 1; }
[[ "$sandbox_name" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo "invalid sandbox name" >&2; exit 1; }
[[ "$sandbox_name" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || { echo "invalid sandbox name" >&2; exit 1; }
openshell --gateway "$gateway_name" sandbox delete "$sandbox_name"
./scripts/agents/run.sh \
+198 -105
View File
@@ -1,6 +1,6 @@
---
name: openshell-cli
description: Guide agents through using the OpenShell CLI (openshell) for sandbox management, gateway registration, provider configuration, policy iteration, BYOC workflows, and inference routing. Covers basic through advanced multi-step workflows. Trigger keywords - openshell, sandbox create, sandbox connect, logs, provider create, policy set, policy get, image push, forward, port forward, BYOC, bring your own container, use openshell, run openshell, CLI usage, manage sandbox, manage provider, gateway add, gateway select.
description: Guide agents through using the OpenShell CLI (openshell) for sandbox management, gateway registration, provider configuration and refresh, policy iteration, settings, service exposure, BYOC workflows, and inference routing. Covers basic through advanced multi-step workflows. Trigger keywords - openshell, sandbox create, sandbox exec, sandbox connect, logs, provider create, provider profile, provider refresh, policy set, policy get, settings, service expose, forward, port forward, BYOC, bring your own container, inference, use openshell, run openshell, CLI usage, manage sandbox, manage provider, gateway add, gateway select.
---
# OpenShell CLI
@@ -9,7 +9,7 @@ Guide agents through using the `openshell` CLI for sandbox and platform manageme
## Overview
The OpenShell CLI (`openshell`) is the primary interface for managing sandboxes, providers, policies, inference routes, and gateway registrations. Gateway service lifecycle is handled outside the CLI by packages, systemd, Helm, or development tasks. This skill teaches agents how to orchestrate CLI commands for common and complex workflows.
The OpenShell CLI (`openshell`) is the primary interface for managing sandboxes, providers, policies, settings, exposed services, inference routes, and gateway registrations. Gateway service lifecycle is handled outside the CLI by packages, systemd, Helm, or development tasks. This skill teaches agents how to orchestrate CLI commands for common and complex workflows.
**Companion skill**: For creating or modifying sandbox policy YAML content (network rules, L7 inspection, access presets), use the `generate-sandbox-policy` skill. This skill covers the CLI *commands* for the policy lifecycle; `generate-sandbox-policy` covers policy *content authoring*.
@@ -32,7 +32,7 @@ This is your primary fallback. Use it freely -- the CLI's help output is authori
## Command Reference
See [cli-reference.md](cli-reference.md) for the full command tree with all flags and options. Use it as a quick-reference to avoid round-tripping through `--help` for common commands.
See [cli-reference.md](cli-reference.md) for the current command tree and commonly used flags. Use it as a quick-reference, then confirm uncommon or security-sensitive options with `--help`.
---
@@ -66,6 +66,8 @@ openshell sandbox create
This creates a sandbox with defaults and drops you into an interactive shell.
When supplying `--name`, use a portable DNS-1123 label: at most 63 lowercase alphanumeric or `-` characters, beginning and ending with an alphanumeric character. The Kubernetes driver rejects uppercase letters, underscores, dots, and other names that cannot become Kubernetes resource labels.
**Shortcut for known tools**: When the trailing command is a recognized tool, the CLI auto-creates the required provider from local credentials:
```bash
@@ -87,9 +89,12 @@ openshell sandbox delete <name>
## Workflow 2: Provider Management
Providers supply credentials to sandboxes (API keys, tokens, etc.). Manage them before creating sandboxes that need them.
Providers supply credentials and provider-specific configuration to sandboxes. Provider types come from built-in and custom profiles; do not rely on a hard-coded type list. Discover the profiles available on the selected gateway:
Supported types: `claude`, `opencode`, `codex`, `generic`, `nvidia`, `gitlab`, `github`, `outlook`.
```bash
openshell provider list-profiles
openshell provider list-profiles --output json
```
### Create a provider from local credentials
@@ -103,25 +108,57 @@ The `--from-existing` flag discovers credentials from local state (e.g., `gh aut
```bash
openshell provider create --name my-api --type generic \
--credential API_KEY=sk-abc123 \
--credential API_KEY \
--config base_url=https://api.example.com
```
Bare `KEY` (without `=VALUE`) reads the value from the environment variable of that name:
Bare `KEY` reads the value from the environment variable of that name and avoids placing the secret in shell history. Use `KEY=VALUE` only when the user explicitly accepts that exposure.
Other credential sources are `--from-gcloud-adc` for compatible profiles and `--runtime-credentials` when the gateway or sandbox resolves the required credentials at runtime.
Profile-backed provider policy and composition are controlled by the gateway-global `providers_v2_enabled` setting:
```bash
openshell provider create --name my-api --type generic --credential API_KEY
openshell settings get --global
openshell settings set --global --key providers_v2_enabled --value true
```
### Inspect and manage provider profiles
```bash
openshell provider profile export github --output yaml
openshell provider profile lint --file ./my-profile.yaml
openshell provider profile import --file ./my-profile.yaml
```
### List, inspect, update, delete
```bash
openshell provider list
openshell provider list --output json
openshell provider get my-github
openshell provider update my-github --type github --from-existing
openshell provider update my-github --from-existing
openshell provider delete my-github
```
`provider update` does not take `--type`. It updates credentials, config, or credential expiry on the existing provider.
### Configure credential refresh
Use refresh commands only when the provider profile and gateway support refreshable credentials:
```bash
openshell provider refresh status my-outlook
openshell provider refresh configure my-outlook \
--credential-key MS_GRAPH_ACCESS_TOKEN \
--strategy oauth2-refresh-token \
--secret-material-env REFRESH_TOKEN=MS_GRAPH_REFRESH_TOKEN \
--credential-expires-at 2026-07-16T00:00:00Z
openshell provider refresh rotate my-outlook --credential-key MS_GRAPH_ACCESS_TOKEN
```
Prefer `--secret-material-env KEY[=ENVVAR]` for secret refresh material. `--material KEY=VALUE` is for non-secret material; `--secret-material-key` marks supplied material keys as secret.
---
## Workflow 3: Sandbox Lifecycle
@@ -134,29 +171,41 @@ openshell sandbox create \
--provider my-github \
--provider my-claude \
--policy ./my-policy.yaml \
--upload .:/sandbox \
--upload .:/workspace \
--label team=agents \
-- claude
```
Key flags:
- `--provider`: Attach one or more providers (repeatable)
- `--policy`: Custom policy YAML (otherwise uses built-in default or `OPENSHELL_SANDBOX_POLICY` env var)
- `--gpu [COUNT]`: Request the driver's default GPU selection or a specific GPU count
- `--cpu`, `--memory`: Set per-sandbox compute sizing. Docker/Podman apply limits; Kubernetes applies matching requests and limits.
- `--upload <PATH>[:<DEST>]`: Upload local files into the sandbox (default dest: `/sandbox`)
- `--driver-config-json`: Pass experimental driver-specific sandbox configuration
- `--label KEY=VALUE`: Add labels for later selection (repeatable)
- `--env KEY=VALUE`: Inject sandbox environment variables (repeatable)
- `--approval-mode manual|auto`: Control handling of agent-authored policy proposals; `manual` is the default
- `--upload <PATH>[:<DEST>]`: Upload local files into the container working directory or an explicit destination
- `--no-git-ignore`: Disable `.gitignore` filtering for uploads
- `--no-keep`: Delete the sandbox after the initial command or shell exits
- `--forward <PORT>`: Forward a local port and keep the sandbox alive
- `--forward [BIND_ADDRESS:]PORT`: Forward a local port and keep the sandbox alive
- `--editor vscode|cursor`: Open a remote editor after creation and keep the sandbox alive
### List and inspect sandboxes
```bash
openshell sandbox list
openshell sandbox list --selector team=agents --output json
openshell sandbox get my-sandbox
```
Most commands with an optional sandbox name use the last-used sandbox. Pass an explicit name in automation.
### Connect to a running sandbox
```bash
openshell sandbox connect my-sandbox
openshell sandbox connect my-sandbox --editor vscode
```
Opens an interactive SSH shell. To configure VS Code Remote-SSH:
@@ -175,6 +224,27 @@ openshell sandbox upload my-sandbox ./src /sandbox/src
openshell sandbox download my-sandbox /sandbox/output ./local-output
```
Uploads honor `.gitignore` by default. Add `--no-git-ignore` only when ignored files are intentionally in scope.
Uploads preserve symlinks, including dangling symlinks, instead of dereferencing their targets. A symlink source bypasses Git-aware filtering so the link itself is archived.
### Execute a non-interactive command
```bash
openshell sandbox exec --name my-sandbox --workdir /workspace -- ls -la
openshell sandbox exec --name my-sandbox --env MODE=test -- cargo test
```
`sandbox exec` streams output and exits with the remote command's exit code. Use `sandbox connect` for an interactive shell.
### Change attached providers
```bash
openshell sandbox provider list my-sandbox
openshell sandbox provider attach my-sandbox my-github
openshell sandbox provider detach my-sandbox my-github
```
### View logs
```bash
@@ -196,6 +266,7 @@ openshell logs my-sandbox --since 5m
```bash
openshell sandbox delete my-sandbox
openshell sandbox delete sandbox-1 sandbox-2 sandbox-3 # Multiple at once
openshell sandbox delete --all
```
---
@@ -204,7 +275,7 @@ openshell sandbox delete sandbox-1 sandbox-2 sandbox-3 # Multiple at once
This is the most important multi-step workflow. It enables a tight feedback cycle where sandbox policy is refined based on observed activity.
**Key concept**: Policies have static fields (immutable after creation: `filesystem_policy`, `landlock`, `process`) and one dynamic field (`network_policies`). Only `network_policies` can be updated without recreating the sandbox.
**Key concept**: Policies have static fields (immutable after creation: `filesystem_policy`, `landlock`, `process`) and two dynamic fields: `network_policies` and `network_middlewares`. Both dynamic fields can be updated without recreating the sandbox.
```
Create sandbox with initial policy
@@ -256,19 +327,20 @@ Look for log lines with `action: deny` -- these indicate blocked network request
openshell policy get dev --full > current-policy.yaml
```
The `--full` flag outputs valid YAML that can be directly re-submitted. This is the round-trip format.
The `--full` flag includes the effective policy, including provider-composed entries. Use `--base` instead when the editable base policy is needed without provider-composed entries. Before resubmitting a `--full` result, review composed entries and prefer incremental updates or the base policy when appropriate.
### Step 4: Modify the policy
Edit `current-policy.yaml` to allow the blocked actions. **For policy content authoring, delegate to the `generate-sandbox-policy` skill.** That skill handles:
- Network endpoint rule structure
- L4 vs L7 policy decisions
- L4 vs REST, WebSocket, JSON-RPC, MCP, and SQL L7 policy decisions
- Access presets (`read-only`, `read-write`, `full`)
- TLS termination configuration
- Enforcement modes (`audit` vs `enforce`)
- Binary matching patterns
- Ordered `network_middlewares`, host selection, and `fail_open` or `fail_closed` behavior
Only `network_policies` can be modified at runtime. If `filesystem_policy`, `landlock`, or `process` need changes, the sandbox must be recreated.
`network_policies` and `network_middlewares` can be modified at runtime. If `filesystem_policy`, `landlock`, or `process` need changes, the sandbox must be recreated. Built-in middleware such as `openshell/regex` needs no gateway registration. An operator-run middleware must already be registered under `[[openshell.supervisor.middleware]]`; changing that static registration requires a gateway restart.
### Step 5: Push the updated policy
@@ -307,27 +379,47 @@ Fetch a specific historical revision:
openshell policy get dev --rev 3 --full
```
Gateway-global policy commands use `--global` and require confirmation unless `--yes` is supplied:
```bash
openshell policy get --global --full
openshell policy set --global --policy ./global-policy.yaml
openshell policy list --global
openshell policy delete --global
```
Avoid `--yes` during interactive work. A global policy locks policy control for all sandboxes on the gateway.
### Review agent-authored rule proposals
Sandboxes created with `--approval-mode manual` place every proposal in the review inbox. `auto` approves only proposals with an empty prover delta; findings still require review.
```bash
openshell rule get dev --status pending
openshell rule approve dev --chunk-id <chunk-id>
openshell rule reject dev --chunk-id <chunk-id> --reason "too broad"
openshell rule history dev
```
Review the proposed scope and prover findings before approval. Treat `rule approve-all --include-security-flagged` as a high-risk bulk action.
---
## Workflow 5: BYOC (Bring Your Own Container)
Build a custom container image and run it as a sandbox.
### Step 1: Create a sandbox from a Dockerfile
### Create a sandbox from a Dockerfile
```bash
openshell sandbox create --from ./Dockerfile --name my-app
```
The `--from` flag accepts a Dockerfile path, a directory containing a Dockerfile, a full image reference (e.g. `myregistry.com/img:tag`), or a community sandbox name (e.g. `openclaw`).
The `--from` flag accepts a Dockerfile path, a directory containing a Dockerfile, a full image reference such as `myregistry.com/img:tag`, or a community sandbox name such as `ollama`.
When given a Dockerfile or directory, the image is built locally via Docker and delivered through the selected compute driver. Docker and Podman-backed gateways can use local images directly. Kubernetes gateways usually need the image available to the cluster through a registry or driver-supported image push path.
Local Dockerfile and directory builds require a local gateway because the CLI builds through the local Docker daemon. Use a registry image reference for remote gateways. Bare community names resolve under `ghcr.io/nvidia/openshell-community/sandboxes` unless `OPENSHELL_COMMUNITY_REGISTRY` overrides the prefix.
When `--from` is specified, the CLI:
- Clears default `run_as_user`/`run_as_group` (custom images may not have the `sandbox` user)
- Uses a supervisor bootstrap pattern (init container copies the sandbox supervisor into a shared volume)
### Step 2: Forward ports (if the container runs a service)
### Forward ports
```bash
# Foreground (blocks)
@@ -339,161 +431,118 @@ openshell forward start 8080 my-app -d
The service is now reachable at `localhost:8080`.
### Step 3: Manage port forwards
Manage or iterate on the sandbox:
```bash
# List active forwards
openshell forward list
# Stop a forward
openshell forward stop 8080 my-app
```
### Step 4: Iterate
To update the container:
```bash
openshell sandbox delete my-app
openshell sandbox create --from ./Dockerfile --name my-app --forward 8080
```
### Shortcut: Create with port forward in one command
Create and forward in one command:
```bash
openshell sandbox create --from ./Dockerfile --forward 8080 -- ./start-server.sh
```
The `--forward` flag starts a background port forward before the command runs, so the service is reachable immediately.
### Limitations
- Distroless / `FROM scratch` images are not supported (the supervisor needs glibc, `/proc`, and a shell)
- Missing `iproute2` or required capabilities blocks startup in proxy mode
---
The `--forward` flag starts a background port forward before the command runs.
## Workflow 6: Agent-Assisted Sandbox Session
This workflow supports a human working in a sandbox while an agent monitors activity and refines the policy in parallel.
Support a human working in a sandbox while an agent monitors activity and refines the policy in parallel.
### Step 1: Create sandbox with providers and keep alive
Create the sandbox and keep it alive:
```bash
openshell sandbox create \
--name work-session \
--provider github \
--provider claude \
--policy ./dev-policy.yaml \
# sandbox create keeps the sandbox alive by default
--policy ./dev-policy.yaml
```
### Step 2: User connects in a separate shell
Tell the user to run:
Tell the user to connect in another shell:
```bash
openshell sandbox connect work-session
openshell sandbox connect work-session --editor vscode
```
Or for VS Code:
```bash
openshell sandbox ssh-config work-session >> ~/.ssh/config
# Then connect via VS Code Remote-SSH to the host "work-session"
```
### Step 3: Agent monitors logs
While the user works, monitor the sandbox logs:
Monitor denied activity:
```bash
openshell logs work-session --tail --source sandbox --level warn
```
Watch for `deny` actions that indicate the user's work is being blocked by policy.
### Step 4: Agent refines policy
When denied actions are observed:
When denied actions appear:
1. Prefer incremental updates for additive network changes:
`openshell policy update work-session --add-endpoint api.github.com:443:read-only:rest:enforce --binary /usr/bin/gh --wait`
`openshell policy update work-session --add-allow 'api.github.com:443:POST:/repos/*/issues' --wait`
2. Use full YAML replacement when the change is broad or touches non-network fields:
2. Use full YAML replacement for broad changes or non-network fields:
`openshell policy get work-session --full > policy.yaml`
Modify the policy to allow the blocked actions (use `generate-sandbox-policy` skill for content)
Modify the policy with the `generate-sandbox-policy` skill.
`openshell policy set work-session --policy policy.yaml --wait`
3. Verify: `openshell policy list work-session`
3. Verify with `openshell policy list work-session`.
The user does not need to disconnect -- policy updates are hot-reloaded within ~30 seconds (or immediately when using `--wait`, which polls for confirmation).
### Step 5: Clean up when done
The user does not need to disconnect. Policy updates are hot-reloaded; `--wait` blocks until the sandbox confirms the revision or the timeout expires. Delete the sandbox when the session ends:
```bash
openshell sandbox delete work-session
```
---
## Workflow 7: Managed Inference
## Workflow 7: Gateway Inference
Configure the user-facing `inference.local` route or the system inference route used by platform functions.
Configure the gateway's managed inference route for `inference.local`.
### Set gateway inference
First ensure the provider record exists:
Ensure the provider exists, then set the route:
```bash
openshell provider list
```
Then point gateway inference at that provider and model:
```bash
openshell inference set \
--provider nvidia \
--model nvidia/nemotron-3-nano-30b-a3b
```
This updates the gateway-managed `inference.local` route. There is no per-route create/list/update/delete workflow for sandbox inference.
This updates the managed `inference.local` route. Endpoint verification runs before the route is saved. Use `--no-verify` only when verification is intentionally impossible, and use `--timeout SECONDS` to configure the request timeout. Add `--system` to `set` or `update` for the platform-only system route.
### Inspect current inference config
Inspect both configurations:
```bash
openshell inference get
openshell inference get --system
```
### How sandboxes use it
- Agents send HTTPS requests to `inference.local`.
- The sandbox intercepts those requests locally and routes them through the gateway inference config.
- Sandbox policy is separate from gateway inference configuration.
---
Agents send HTTPS requests to `inference.local`; the sandbox intercepts them and routes them through the configured inference route. Sandbox policy remains separate from inference route configuration.
## Workflow 8: Gateway Management
### List and switch gateways
List, switch, and verify gateways:
```bash
openshell gateway select # See all gateways (no args shows list)
openshell gateway select production # Switch active gateway
openshell status # Verify connectivity
openshell gateway select
openshell gateway list --output json
openshell gateway select production
openshell gateway info --name production
openshell status
```
### Registration
Register or remove gateways:
```bash
openshell gateway add http://127.0.0.1:8080 --local --name local
openshell gateway add https://gateway.example.com --name production
openshell gateway remove local # Remove local registration
openshell gateway remove local
```
### Platform-specific deployment inspection
`https://` registrations default to edge authentication. Use `gateway login` and `gateway logout` to refresh or clear stored authentication. For an OIDC gateway, supply `--oidc-issuer` and, when needed, `--oidc-client-id`, `--oidc-audience`, and `--oidc-scopes`. For remote mTLS gateways, use `--remote USER@HOST` or an `ssh://` endpoint.
For one-off automation, `--gateway-endpoint URL` connects directly without stored metadata. Limit `--gateway-insecure` to explicitly trusted development endpoints.
Inspect a Kubernetes deployment:
```bash
# Inspect a Kubernetes Helm release and gateway pod
helm -n openshell status openshell
kubectl -n openshell get deployment,statefulset,pods,svc
kubectl -n openshell logs deployment/openshell -c openshell-gateway --tail=100
@@ -502,6 +551,41 @@ kubectl -n openshell logs statefulset/openshell -c openshell-gateway --tail=100
For Docker, Podman, and VM-backed gateways, inspect the gateway process or container logs and the selected runtime directly.
## Workflow 9: Settings Management
Manage sandbox-scoped or gateway-global settings:
```bash
openshell settings get work-session
openshell settings set work-session --key ocsf_json_enabled --value true
openshell settings delete work-session --key ocsf_json_enabled
openshell settings get --global --json
openshell settings set --global --key providers_v2_enabled --value true
```
Global mutations prompt for confirmation. Use `--yes` only in reviewed automation.
## Workflow 10: Service Access
Use `forward` for local access and `service` for a gateway-managed HTTP endpoint:
```bash
# SSH-based same-port forwarding; optional bind address is accepted.
openshell forward start 127.0.0.1:8080 my-app -d
# gRPC relay to a loopback TCP service, with an optional dynamic local port.
openshell forward service my-app --target-port 8000 --local 127.0.0.1:0
# Expose and manage an HTTP service through the gateway.
openshell service expose my-app 8080 web
openshell service list my-app
openshell service get my-app web
openshell service delete my-app web
```
Prefer loopback binds unless the user explicitly needs LAN-visible local access.
---
## Self-Teaching via `--help`
@@ -518,7 +602,7 @@ The CLI help is always authoritative. If the help output contradicts this skill,
```bash
$ openshell sandbox --help
# Shows: create, get, list, delete, connect, upload, download, ssh-config, image
# Shows: create, get, list, delete, exec, connect, upload, download, ssh-config, provider
$ openshell sandbox upload --help
# Shows: positional arguments (name, path, dest), usage examples
@@ -533,23 +617,32 @@ $ openshell sandbox upload --help
| Register local port-forwarded gateway | `openshell gateway add http://127.0.0.1:8080 --local --name local` |
| Check gateway health | `openshell status` |
| List/switch gateways | `openshell gateway select [name]` |
| Connect directly to a gateway | `openshell --gateway-endpoint <url> status` |
| Create sandbox (interactive) | `openshell sandbox create` |
| Create sandbox with tool | `openshell sandbox create -- claude` |
| Create sandbox with GPUs | `openshell sandbox create --gpu 1` |
| Create with custom policy | `openshell sandbox create --policy ./p.yaml` |
| Connect to sandbox | `openshell sandbox connect <name>` |
| Execute in sandbox | `openshell sandbox exec --name <name> -- <command>` |
| Stream live logs | `openshell logs <name> --tail` |
| Incremental policy update | `openshell policy update <name> --add-endpoint host:443:read-only:rest:enforce --binary /usr/bin/curl --wait` |
| Pull current policy | `openshell policy get <name> --full > p.yaml` |
| Push updated policy | `openshell policy set <name> --policy p.yaml --wait` |
| Policy revision history | `openshell policy list <name>` |
| View global policy | `openshell policy get --global --full` |
| Review proposed rules | `openshell rule get <name> --status pending` |
| Create sandbox from Dockerfile | `openshell sandbox create --from ./Dockerfile` |
| Forward a port | `openshell forward start <port> <name> -d` |
| Expose an HTTP service | `openshell service expose <name> <port> [service]` |
| Upload files to sandbox | `openshell sandbox upload <name> <path>` |
| Download files from sandbox | `openshell sandbox download <name> <path>` |
| Create provider | `openshell provider create --name N --type T --from-existing` |
| List providers | `openshell provider list` |
| Configure gateway inference | `openshell inference set --provider P --model M` |
| View gateway inference | `openshell inference get` |
| Discover provider profiles | `openshell provider list-profiles` |
| List attached providers | `openshell sandbox provider list <name>` |
| View settings | `openshell settings get [name]` |
| Configure managed inference | `openshell inference set --provider P --model M` |
| View managed inference | `openshell inference get` |
| Delete sandbox | `openshell sandbox delete <name>` |
| Remove gateway registration | `openshell gateway remove <name>` |
| Self-teach any command | `openshell <group> <cmd> --help` |
@@ -558,7 +651,7 @@ $ openshell sandbox upload --help
| Skill | When to use |
|-------|------------|
| `generate-sandbox-policy` | Creating or modifying policy YAML content (network rules, L7 inspection, access presets, endpoint configuration) |
| `generate-sandbox-policy` | Creating or modifying policy YAML content (network rules, L7 inspection, access presets, endpoint configuration, and network middleware) |
| `debug-openshell-cluster` | Diagnosing gateway deployment, runtime, or health failures |
| `debug-inference` | Diagnosing `inference.local`, host-backed local inference, and provider base URL issues |
| `tui-development` | Developing features for the OpenShell TUI (`openshell term`) |
+266 -81
View File
@@ -10,13 +10,19 @@ Quick-reference for the `openshell` command-line interface. For workflow guidanc
|------|-------------|
| `-v`, `--verbose` | Increase verbosity (`-v` = info, `-vv` = debug, `-vvv` = trace) |
| `-g`, `--gateway <NAME>` | Gateway to operate on. Also settable via `OPENSHELL_GATEWAY` env var. Falls back to active gateway in `~/.config/openshell/active_gateway`. |
| `--gateway-endpoint <URL>` | Connect directly to a gateway endpoint without looking up stored metadata. Also settable via `OPENSHELL_GATEWAY_ENDPOINT`. |
| `--gateway-insecure` | Skip TLS certificate verification. Also settable via `OPENSHELL_GATEWAY_INSECURE`; use only for trusted development endpoints. |
## Environment Variables
| Variable | Description |
|----------|-------------|
| `OPENSHELL_GATEWAY` | Override active gateway name (same as `--gateway`) |
| `OPENSHELL_GATEWAY_ENDPOINT` | Connect directly to a gateway endpoint (same as `--gateway-endpoint`) |
| `OPENSHELL_GATEWAY_INSECURE` | Skip TLS verification when set (same as `--gateway-insecure`) |
| `OPENSHELL_SANDBOX_POLICY` | Path to default sandbox policy YAML (fallback when `--policy` is not provided) |
| `OPENSHELL_COMMUNITY_REGISTRY` | Override the community sandbox image registry prefix used by `sandbox create --from <name>` |
| `OPENSHELL_THEME` | TUI theme: `auto`, `dark`, or `light` |
---
@@ -39,29 +45,64 @@ openshell
│ └── get
├── sandbox
│ ├── create [opts] [-- CMD...]
│ ├── get <name>
│ ├── get [name]
│ ├── list [opts]
│ ├── delete <name>...
│ ├── connect <name>
│ ├── delete [name]... [--all]
│ ├── exec [--name <name>] [opts] -- CMD...
│ ├── connect [name] [--editor <editor>]
│ ├── upload <name> <path> [dest]
│ ├── download <name> <path> [dest]
│ ├── ssh-config <name>
│ └── image
│ └── push [opts]
│ ├── ssh-config [name]
│ └── provider
│ ├── list [name]
│ ├── attach <name> <provider>
│ └── detach <name> <provider>
├── forward
│ ├── start <port> <name> [-d]
│ ├── stop <port> <name>
│ └── list
├── logs <name> [opts]
│ ├── start <port> [name] [-d]
│ ├── stop <port> [name]
│ ├── list
│ └── service [name] --target-port <port> [opts]
├── service
│ ├── expose <sandbox> <target-port> [service]
│ ├── list [sandbox]
│ ├── get <sandbox> [service]
│ └── delete <sandbox> [service]
├── logs [name] [opts]
├── policy
│ ├── set <name> --policy <path> [--wait]
│ ├── get <name> [--full]
│ └── list <name>
│ ├── set [name] --policy <path> [--global] [--wait]
│ ├── update [name] [opts]
│ ├── get [name] [--full|--base] [--global]
│ ├── list [name] [--global]
│ ├── delete --global
│ └── prove --policy <path> --credentials <path> [opts]
├── settings
│ ├── get [name] [--global]
│ ├── set [name] --key <key> --value <value> [--global]
│ └── delete [name] --key <key> [--global]
├── rule (advanced; hidden from top-level help)
│ ├── get [name] [--status <status>]
│ ├── approve [name] --chunk-id <id>
│ ├── reject [name] --chunk-id <id> [--reason <reason>]
│ ├── approve-all [name] [--include-security-flagged]
│ ├── clear [name]
│ └── history [name]
├── provider
│ ├── create --name --type [opts]
│ ├── refresh
│ │ ├── status <name> [opts]
│ │ ├── configure <name> [opts]
│ │ ├── rotate <name> --credential-key <key>
│ │ └── delete <name> --credential-key <key>
│ ├── get <name>
│ ├── list [opts]
│ ├── update <name> --type [opts]
│ ├── list-profiles [opts]
│ ├── profile
│ │ ├── export <id> [opts]
│ │ ├── import (--file <path>|--from <dir>)
│ │ ├── update <id> --file <path>
│ │ ├── lint (--file <path>|--from <dir>)
│ │ └── delete <id>
│ ├── update <name> [opts]
│ └── delete <name>...
├── doctor
│ └── check
@@ -81,13 +122,20 @@ Register an existing gateway endpoint.
| Flag | Description |
|------|-------------|
| `--name <NAME>` | Gateway name |
| `--local` | Register a local endpoint, commonly a trusted port-forward |
| `--remote <USER@HOST>` | Register a remote gateway associated with an SSH destination |
| `--local` | Register a local mTLS gateway; with HTTP, store a local plaintext registration |
| `--remote <USER@HOST>` | Register a remote mTLS gateway over SSH; with HTTP, store a remote plaintext registration |
| `--oidc-issuer <URL>` | Register an OIDC-authenticated gateway |
| `--oidc-client-id <ID>` | OIDC client ID (default: `openshell-cli`; requires `--oidc-issuer`) |
| `--oidc-audience <AUDIENCE>` | OIDC API audience (requires `--oidc-issuer`) |
| `--oidc-scopes <SCOPES>` | Space-separated OAuth2 scopes (requires `--oidc-issuer`) |
Examples:
- `openshell gateway add http://127.0.0.1:8080 --local --name local`
- `openshell gateway add https://gateway.example.com --name production`
- `openshell gateway add ssh://user@gateway.example.com:8080 --name remote`
An `http://` endpoint is direct plaintext. A plain `https://` endpoint uses edge authentication. `--local` and `--remote` select mTLS registration modes when used with HTTPS; required certificates must already exist. An `ssh://` endpoint is shorthand for a remote gateway.
### `openshell gateway remove [name]`
@@ -95,7 +143,11 @@ Remove a local gateway registration. This removes CLI metadata and stored auth t
### `openshell gateway login [name]`
Refresh browser-based authentication for a gateway behind an edge proxy.
Refresh browser-based authentication for an edge-authenticated or OIDC gateway.
### `openshell gateway logout [name]`
Clear locally stored OIDC or edge credentials for a gateway.
### `openshell gateway info`
@@ -107,7 +159,11 @@ Show gateway details: endpoint, auth mode, and remote host metadata when present
### `openshell gateway select [name]`
Set the active gateway. Writes to `~/.config/openshell/active_gateway`. When called without arguments, lists all registered gateways with the active one marked with `*`.
Set the active gateway. Writes to `~/.config/openshell/active_gateway`. Without a name, opens an interactive chooser on a TTY or lists gateways in non-interactive mode.
### `openshell gateway list`
List registered gateways and mark the active one. `--output table|yaml|json` selects the format.
---
@@ -133,103 +189,138 @@ Show server connectivity and version for the active gateway.
### `openshell sandbox create [OPTIONS] [-- COMMAND...]`
Create a sandbox through the active gateway, wait for readiness, then connect or execute the trailing command.
Create a sandbox through the selected gateway, wait for readiness, then connect, open an editor, or execute the trailing command.
| Flag | Description |
|------|-------------|
| `--name <NAME>` | Sandbox name (auto-generated if omitted) |
| `--from <SOURCE>` | Sandbox source: community name, Dockerfile path, directory, or image reference (BYOC) |
| `--upload <PATH>[:<DEST>]` | Upload local files into sandbox (default dest: `/sandbox`) |
| `--no-keep` | Delete sandbox after the initial command or shell exits |
| `--from <SOURCE>` | Community name, Dockerfile path, directory, or image reference (BYOC) |
| `--no-keep` | Delete the sandbox after the initial command or shell exits |
| `--editor vscode|cursor` | Launch a remote editor and keep the sandbox alive |
| `--gpu [COUNT]` | Request the driver's default GPU selection or a specific count |
| `--cpu <QUANTITY>` | CPU limit (for example: `500m`, `1`, `2.5`) |
| `--memory <QUANTITY>` | Memory limit (for example: `512Mi`, `4Gi`, `8G`) |
| `--driver-config-json <JSON>` | Experimental driver-keyed configuration object |
| `--provider <NAME>` | Provider to attach (repeatable) |
| `--policy <PATH>` | Path to custom policy YAML |
| `--cpu <QUANTITY>` | CPU amount for the sandbox (for example: `500m`, `1`, `2.5`) |
| `--memory <QUANTITY>` | Memory amount for the sandbox (for example: `512Mi`, `4Gi`, `8G`) |
| `--forward <PORT>` | Forward local port to sandbox (keeps the sandbox alive) |
| `--tty` | Force pseudo-terminal allocation |
| `--no-tty` | Disable pseudo-terminal allocation |
| `--auto-providers` | Auto-create missing providers from local credentials (skips interactive prompt) |
| `--no-auto-providers` | Never auto-create providers; skip missing providers silently |
| `[-- COMMAND...]` | Command to execute (defaults to interactive shell) |
| `--policy <PATH>` | Custom policy YAML; overrides the built-in default and `OPENSHELL_SANDBOX_POLICY` |
| `--forward <[BIND:]PORT>` | Start a local port forward and keep the sandbox alive |
| `--tty`, `--no-tty` | Force or disable pseudo-terminal allocation |
| `--auto-providers` | Auto-create missing providers from local credentials |
| `--no-auto-providers` | Never auto-create providers; error if a required provider is missing |
| `--label <KEY=VALUE>` | Attach a label (repeatable) |
| `--env <KEY=VALUE>` | Inject an environment variable (repeatable) |
| `--approval-mode manual|auto` | Handle agent-authored policy proposals; default: `manual` |
| `--upload <PATH>[:<DEST>]` | Upload local files to the working directory or an explicit destination (repeatable) |
| `--no-git-ignore` | Disable `.gitignore` filtering for `--upload` |
| `[-- COMMAND...]` | Initial command (defaults to an interactive shell) |
### `openshell sandbox get <name>`
### `openshell sandbox get [name]`
Show sandbox details (id, name, namespace, phase) and the **active** policy from the gateway (same source whether policy is sandbox-scoped or global). Metadata includes **Policy source** (`sandbox` or `global`) and **Revision** (global policy row when source is global, otherwise sandbox policy row).
Show sandbox details and the active policy. Metadata identifies sandbox or global policy source and the corresponding revision. The name defaults to the last-used sandbox.
| Flag | Description |
|------|-------------|
| `--policy-only` | Print only the active policy YAML to stdout (same policy as above; use for scripts and piping) |
| `--policy-only` | Print only the active policy YAML to stdout |
### `openshell sandbox list`
List sandboxes in a table.
| Flag | Default | Description |
|------|---------|-------------|
| `--limit <N>` | 100 | Max sandboxes to return |
| `--limit <N>` | 100 | Maximum sandboxes |
| `--offset <N>` | 0 | Pagination offset |
| `--ids` | false | Print only sandbox IDs |
| `--names` | false | Print only sandbox names |
| `--selector <SELECTOR>` | none | Filter by `key1=value1,key2=value2` |
| `--output table|yaml|json` | `table` | Output format |
### `openshell sandbox delete <NAME>...`
### `openshell sandbox delete [NAME]...`
Delete one or more sandboxes by name. Stops any background port forwards.
Delete one or more named sandboxes, or use `--all`. Deletion stops background port forwards.
### `openshell sandbox connect <name>`
### `openshell sandbox exec [OPTIONS] -- COMMAND...`
Open an interactive SSH shell to a sandbox.
Execute a command through the gRPC exec endpoint, stream its output, and exit with the remote command's exit code.
| Flag | Default | Description |
|------|---------|-------------|
| `-n`, `--name <NAME>` | last-used | Sandbox name |
| `--workdir <PATH>` | none | Working directory in the sandbox |
| `--timeout <SECONDS>` | 0 | Command timeout; `0` disables it |
| `--tty`, `--no-tty` | auto | Force or disable a pseudo-terminal |
| `--env <KEY=VALUE>` | none | Command environment variable (repeatable) |
### `openshell sandbox connect [name]`
Open an interactive SSH shell. The name defaults to the last-used sandbox. `--editor vscode|cursor` launches a supported remote editor instead.
### `openshell sandbox upload <name> <path> [dest]`
Upload local files to a sandbox using tar-over-SSH.
| Argument | Default | Description |
|----------|---------|-------------|
| `<name>` | -- | Sandbox name (required) |
| `<path>` | -- | Local path to upload (required) |
| `[dest]` | `/sandbox` | Destination path in sandbox |
Upload files using tar-over-SSH. The destination defaults to the container working directory. `.gitignore` filtering is enabled unless `--no-git-ignore` is passed.
### `openshell sandbox download <name> <path> [dest]`
Download files from a sandbox using tar-over-SSH.
Download files using tar-over-SSH. The local destination defaults to `.`.
| Argument | Default | Description |
|----------|---------|-------------|
| `<name>` | -- | Sandbox name (required) |
| `<path>` | -- | Sandbox path to download (required) |
| `[dest]` | `.` | Local destination path |
### `openshell sandbox ssh-config [name]`
### `openshell sandbox ssh-config <name>`
Print an SSH config `Host` block. The name defaults to the last-used sandbox.
Print an SSH config `Host` block for a sandbox. Useful for VS Code Remote-SSH.
### `openshell sandbox provider`
Manage providers on an existing sandbox:
- `openshell sandbox provider list [name]`
- `openshell sandbox provider attach <name> <provider>`
- `openshell sandbox provider detach <name> <provider>`
---
## Port Forwarding Commands
### `openshell forward start <port> <name>`
### `openshell forward start <port> [name]`
Start forwarding a local port to a sandbox.
| Flag | Description |
|------|-------------|
| `<port>` | Port number (used as both local and remote) |
| `<name>` | Sandbox name |
| `<port>` | `[bind_address:]port`; the port is used locally and remotely |
| `[name]` | Sandbox name (defaults to last-used) |
| `-d`, `--background` | Run in background |
### `openshell forward stop <port> <name>`
### `openshell forward stop <port> [name]`
Stop a background port forward.
Stop a background port forward. When the sandbox name is omitted, it is inferred from active forwards.
### `openshell forward list`
List all active port forwards (sandbox, port, PID, status).
### `openshell forward service [name] --target-port <port>`
Forward a local TCP port to a loopback service inside a sandbox over the gRPC relay.
| Flag | Default | Description |
|------|---------|-------------|
| `--target-port <PORT>` | required | Service port inside the sandbox |
| `--target-host <HOST>` | `127.0.0.1` | Loopback service host |
| `--local <[BIND:]PORT>` | target port | Local bind; port `0` requests dynamic assignment |
---
## Service Commands
Gateway-managed HTTP service endpoints:
- `openshell service expose <sandbox> <target-port> [service]`
- `openshell service list [sandbox] [--limit N] [--offset N]`
- `openshell service get <sandbox> [service]`
- `openshell service delete <sandbox> [service]`
---
## Logs Command
### `openshell logs <name>`
### `openshell logs [name]`
View sandbox logs. Supports one-shot and streaming.
@@ -241,20 +332,22 @@ View sandbox logs. Supports one-shot and streaming.
| `--source <SOURCE>` | `all` | Filter: `gateway`, `sandbox`, or `all` (repeatable) |
| `--level <LEVEL>` | none | Minimum level: `error`, `warn`, `info`, `debug`, `trace` |
The sandbox name defaults to the last-used sandbox.
---
## Policy Commands
### `openshell policy update <name>`
### `openshell policy update [name]`
Incrementally merge live network policy changes into the current sandbox policy. Multiple flags in one invocation are applied as one atomic batch and create at most one new revision.
| Flag | Default | Description |
|------|---------|-------------|
| `--add-endpoint <SPEC>` | repeatable | `host:port[:access[:protocol[:enforcement]]]`. Adds or merges an endpoint. `access`: `read-only`, `read-write`, `full`. `protocol`: `rest`, `sql`. `enforcement`: `enforce`, `audit`. |
| `--add-endpoint <SPEC>` | repeatable | `host:port[:access[:protocol[:enforcement[:options]]]]`. Adds or merges an endpoint. |
| `--remove-endpoint <SPEC>` | repeatable | `host:port`. Removes the endpoint or just the requested port from a multi-port endpoint. |
| `--add-allow <SPEC>` | repeatable | `host:port:METHOD:path_glob`. Adds REST allow rules to an existing `protocol: rest` endpoint. |
| `--add-deny <SPEC>` | repeatable | `host:port:METHOD:path_glob`. Adds REST deny rules to an existing `protocol: rest` endpoint that already has an allow base. |
| `--add-allow <SPEC>` | repeatable | `host:port:METHOD:path_glob`. Adds REST or WebSocket allow rules. |
| `--add-deny <SPEC>` | repeatable | `host:port:METHOD:path_glob`. Adds REST or WebSocket deny rules. |
| `--remove-rule <NAME>` | repeatable | Deletes a named network rule. |
| `--binary <PATH>` | repeatable | Adds binaries to each `--add-endpoint` rule. Valid only with `--add-endpoint`. |
| `--rule-name <NAME>` | none | Overrides the generated rule name. Valid only when exactly one `--add-endpoint` is provided. |
@@ -264,44 +357,92 @@ Incrementally merge live network policy changes into the current sandbox policy.
Notes:
- `--add-allow` and `--add-deny` currently operate only on `protocol: rest` endpoints.
- The sandbox name defaults to the last-used sandbox.
- `--add-allow` and `--add-deny` operate on REST and WebSocket endpoints. Use full YAML for JSON-RPC, MCP, SQL, or other policy structure.
- `--wait` cannot be combined with `--dry-run`.
- Use `policy set` when replacing the full policy or changing static sections.
### `openshell policy set <name> --policy <PATH>`
### `openshell policy set [name] --policy <PATH>`
Replace the full policy on a live sandbox. Only the dynamic `network_policies` field can be changed at runtime.
| Flag | Default | Description |
|------|---------|-------------|
| `--policy <PATH>` | -- | Path to policy YAML (required) |
| `--global` | false | Apply as the gateway-global policy |
| `--yes` | false | Skip confirmation for a global update |
| `--wait` | false | Wait for sandbox to confirm policy is loaded |
| `--timeout <SECS>` | 60 | Timeout for `--wait` |
Exit codes with `--wait`: 0 = loaded, 1 = failed, 124 = timeout.
### `openshell policy get <name>`
### `openshell policy get [name]`
Show current active policy for a sandbox.
Show the current effective sandbox policy or stored global policy.
| Flag | Default | Description |
|------|---------|-------------|
| `--rev <VERSION>` | 0 (latest) | Show a specific revision |
| `--full` | false | Print the full policy as YAML (round-trips with `--policy` input) |
| `--rev <VERSION>` | 0 | Show a stored revision; `0` shows the current effective policy |
| `--full` | false | Include the effective policy payload and provider-composed entries |
| `--base` | false | Include the base policy payload without provider-composed entries |
| `--output table|json` | `table` | Output format |
| `--global` | false | Show the global policy revision |
### `openshell policy list <name>`
### `openshell policy list [name]`
List policy revision history (version, hash, status, created, error).
| Flag | Default | Description |
|------|---------|-------------|
| `--limit <N>` | 20 | Max revisions to return |
| `--global` | false | List global policy revisions |
### `openshell policy delete --global`
Delete the global policy lock and restore sandbox-level policy control. `--yes` skips confirmation.
### `openshell policy prove`
Prove policy properties or find counterexamples.
| Flag | Description |
|------|-------------|
| `--policy <PATH>` | Policy YAML (required) |
| `--credentials <PATH>` | Credential descriptor YAML (required) |
| `--registry <DIR>` | Capability registry directory (defaults to bundled) |
| `--accepted-risks <PATH>` | Accepted-risks YAML |
| `--compact` | One-line-per-finding output |
### `openshell rule` (advanced)
Review agent-authored network rule proposals. This command group is intentionally hidden from top-level help but is part of the policy-advisor workflow.
- `openshell rule get [name] [--status pending|approved|rejected]`
- `openshell rule approve [name] --chunk-id <id>`
- `openshell rule reject [name] --chunk-id <id> [--reason <reason>]`
- `openshell rule approve-all [name] [--include-security-flagged]`
- `openshell rule clear [name]`
- `openshell rule history [name]`
Sandbox names default to the last-used sandbox. Bulk approval of security-flagged proposals requires explicit `--include-security-flagged`.
---
## Settings Commands
Settings support sandbox and gateway-global scopes:
- `openshell settings get [name] [--global] [--json]`
- `openshell settings set [name] --key <key> --value <value> [--global] [--yes]`
- `openshell settings delete [name] --key <key> [--global] [--yes]`
Sandbox names default to the last-used sandbox. Global mutations prompt unless `--yes` is passed.
---
## Provider Commands
Supported provider types: `claude`, `opencode`, `codex`, `generic`, `nvidia`, `gitlab`, `github`, `outlook`.
Provider types are defined by built-in and custom provider profiles. Use `openshell provider list-profiles` to discover the selected gateway's current inventory.
### `openshell provider create --name <NAME> --type <TYPE>`
@@ -311,10 +452,14 @@ Create a provider configuration.
|------|-------------|
| `--name <NAME>` | Provider name (required) |
| `--type <TYPE>` | Provider type (required) |
| `--from-existing` | Load credentials from local state (mutually exclusive with `--credential`) |
| `--from-existing` | Load credentials and config from local state |
| `--credential KEY[=VALUE]` | Credential pair. Bare `KEY` reads from env var. Repeatable. |
| `--from-gcloud-adc` | Load a compatible credential from gcloud Application Default Credentials |
| `--runtime-credentials` | Resolve required credentials at runtime in the gateway or sandbox |
| `--config KEY=VALUE` | Config key/value pair. Repeatable. |
Exactly one credential source is required. Credential-source flags conflict with one another.
### `openshell provider get <name>`
Show provider details (id, name, type, credential keys, config keys).
@@ -328,40 +473,80 @@ List providers in a table.
| `--limit <N>` | 100 | Max providers |
| `--offset <N>` | 0 | Pagination offset |
| `--names` | false | Print only names |
| `--output table|yaml|json` | `table` | Output format |
### `openshell provider update <name> --type <TYPE>`
### `openshell provider update <name>`
Update an existing provider. Same flags as `create`.
Update an existing provider without changing its type.
| Flag | Description |
|------|-------------|
| `--from-existing` | Rediscover local credentials and config |
| `--credential KEY[=VALUE]` | Update a credential (repeatable) |
| `--config KEY=VALUE` | Update config (repeatable) |
| `--credential-expires-at KEY=TIMESTAMP` | Set or clear credential expiry; accepts epoch milliseconds or RFC3339, and `0` clears |
### `openshell provider delete <NAME>...`
Delete one or more providers by name.
### Provider profiles
- `openshell provider list-profiles [--output table|yaml|json]`
- `openshell provider profile export <id> [--output table|yaml|json]`
- `openshell provider profile import (--file <path>|--from <dir>)`
- `openshell provider profile update <id> --file <path>`
- `openshell provider profile lint (--file <path>|--from <dir>)`
- `openshell provider profile delete <id>`
### Provider credential refresh
- `openshell provider refresh status <name> [--credential-key <key>]`
- `openshell provider refresh rotate <name> --credential-key <key>`
- `openshell provider refresh delete <name> --credential-key <key>`
`provider refresh configure <name>` accepts:
| Flag | Description |
|------|-------------|
| `--credential-key <KEY>` | Injectable credential key (required) |
| `--strategy <STRATEGY>` | `oauth2-refresh-token`, `oauth2-client-credentials`, or `google-service-account-jwt` |
| `--material KEY=VALUE` | Non-secret refresh material (repeatable) |
| `--secret-material-env KEY[=ENVVAR]` | Secret refresh material read from the CLI environment (repeatable) |
| `--secret-material-key KEY` | Mark a supplied material key secret (repeatable) |
| `--credential-expires-at TIMESTAMP` | Current credential expiry in epoch milliseconds or RFC3339 |
---
## Inference Commands
### `openshell inference set`
Configure the managed gateway inference route used by `inference.local`. Both flags are required.
Configure the gateway's user-facing `inference.local` route or the platform-only system route. Provider and model are required.
| Flag | Default | Description |
|------|---------|-------------|
| `--provider <NAME>` | -- | Provider record name (required) |
| `--model <ID>` | -- | Model identifier to use for generation requests (required) |
| `--system` | false | Configure the system inference route |
| `--no-verify` | false | Skip endpoint verification before saving |
| `--timeout <SECONDS>` | 0 | Request timeout; `0` uses the 60-second default |
### `openshell inference update`
Partially update the gateway inference configuration. Fetches the current config and applies only the provided overrides. At least one flag is required.
Partially update the selected inference route.
| Flag | Default | Description |
|------|---------|-------------|
| `--provider <NAME>` | unchanged | Provider record name |
| `--model <ID>` | unchanged | Model identifier |
| `--system` | false | Target the system inference route |
| `--no-verify` | false | Skip endpoint verification before saving |
| `--timeout <SECONDS>` | unchanged | Request timeout; `0` uses the 60-second default |
### `openshell inference get`
Show the current gateway inference configuration.
Show both inference routes. `--system` shows only the system route.
---
@@ -369,7 +554,7 @@ Show the current gateway inference configuration.
### `openshell term`
Launch the OpenShell interactive TUI.
Launch the OpenShell interactive TUI. `--theme auto|dark|light` overrides `OPENSHELL_THEME`.
### `openshell completions <shell>`
+1
View File
@@ -111,6 +111,7 @@ Read through the full diff (and the PR description if available). Produce a summ
- **Key Design Decisions**: Focus on _why_ something was done a particular way, not _what_ changed. Include `file_path:line_number` references. Examples: choice of algorithm, new abstraction introduced, API contract change, migration strategy.
- **Notable Code**: Include only the most instructive or surprising snippets. Keep each snippet under 15 lines. Always include the file path above the code block.
- **Potential Concerns**: Only include if there are genuine risks — missing error handling, breaking changes, performance implications, security issues. Do not fabricate concerns.
- **Agent infrastructure**: When the PR changes behavior, commands, or development workflows, use the `sync-agent-infra` maintenance map to check that related skills were updated. When it adds, removes, or renames skills or crates; changes workflow relationships or skill coverage; modifies issue or PR templates; or changes agent cross-references, apply the full consistency checklist. Report missing companion updates or drift under **Potential Concerns**.
## Step 5: Output
+33 -2
View File
@@ -9,7 +9,7 @@ Detect and fix drift across the agent-first infrastructure files. These files re
| File | What it tracks |
|------|---------------|
| `AGENTS.md` | Project identity, workflow chains, architecture overview, issue/PR conventions |
| `AGENTS.md` | Project identity, workflow chains, architecture overview, issue/PR conventions, skill maintenance pointer |
| `CONTRIBUTING.md` | Skills table, workflow chains, "When to Open an Issue" guidance, skill references |
| `README.md` | "Built With Agents" section, "Explore with your agent" skill references |
| `.github/ISSUE_TEMPLATE/bug_report.yml` | Skill name references in diagnostic guidance |
@@ -18,16 +18,43 @@ Detect and fix drift across the agent-first infrastructure files. These files re
| `.github/workflows/issue-triage.yml` | Comment text referencing skills |
| `.agents/skills/triage-issue/SKILL.md` | Skill name references in gate check and diagnosis steps |
| `.agents/skills/openshell-cli/SKILL.md` | Companion skills table |
| `.agents/skills/build-from-issue/SKILL.md` | `state:triage-needed` label awareness |
| `.agents/skills/create-github-pr/SKILL.md` | Pre-PR agent infrastructure check |
| `.agents/skills/review-github-pr/SKILL.md` | Review-time agent infrastructure check |
| `.agents/skills/build-from-issue/SKILL.md` | Label awareness and pre-commit agent infrastructure check |
| `.claude/agents/principal-engineer-reviewer.md` | Shared review-time agent infrastructure check |
## When to Run
- After adding, removing, or renaming a skill in `.agents/skills/`
- After adding, removing, or renaming a crate in `crates/`
- After changing workflow chain relationships between skills
- After changing which product or development areas a skill covers
- After modifying issue or PR templates
- Before opening a PR that touches any of the above
## Skill Maintenance Map
Use this map when product behavior, commands, or development workflows change. It is a routing aid, not an exhaustive dependency list. Search `.agents/skills/` for the changed command, field, component, or workflow before concluding that no other skill needs an update.
| Change area | Skills to review |
|---|---|
| CLI commands, flags, defaults, or workflows | `openshell-cli` |
| Sandbox policy schema, presets, or enforcement behavior | `generate-sandbox-policy`, `openshell-cli` |
| Supervisor middleware policy, registrations, runtime, or failure behavior | `generate-sandbox-policy`, `openshell-cli`, `debug-openshell-cluster` |
| Gateway deployment, Helm, runtime drivers, or health checks | `debug-openshell-cluster`, `helm-dev-environment` |
| Inference routing, providers, or `inference.local` behavior | `debug-inference`, `openshell-cli` |
| TUI architecture, navigation, data fetching, or UX | `tui-development` |
| Release artifacts or post-publish smoke coverage | `test-release-canary` |
| GitHub Actions workflows, required checks, or CI diagnostics | `watch-github-actions`; also `test-release-canary` for release smoke coverage |
| Gator harness, sandbox image, supervision, or model overrides | `launch-openshell-gator` |
| SBOM generation, dependency metadata, or license workflows | `sbom` |
| Issue templates, labels, contribution gates, or spike/build workflow | `triage-issue`, `create-spike`, `build-from-issue`, `create-github-issue` |
| PR template, review conventions, or vouch behavior | `create-github-pr`, `review-github-pr`, `build-from-issue` |
| Security review or remediation workflow | `review-security-issue`, `fix-security-issue` |
| RFC template, numbering, or lifecycle | `create-rfc` |
| Documentation structure, navigation, or doc-update workflow | `update-docs` |
| Skills, crates, workflow chains, issue/PR templates, or agent cross-references | `sync-agent-infra` |
## Prerequisites
You must be in the OpenShell repository root.
@@ -77,6 +104,7 @@ For each file in the table above, check for the following inconsistencies:
1. **Architecture overview** — Every crate in `crates/` must appear in the architecture table. The `python/`, `proto/`, `deploy/`, `.agents/` rows must also be present.
2. **Workflow chains** — Verify each skill named in a chain exists in `.agents/skills/`.
3. **Issue/PR conventions** — Verify referenced skills (`create-github-issue`, `create-github-pr`, `build-from-issue`) exist.
4. **Skill maintenance pointer** — Verify it still points to `sync-agent-infra` and does not duplicate the maintenance map from this skill.
### `README.md`
@@ -100,6 +128,7 @@ For each file in the table above, check for the following inconsistencies:
3. **`build-from-issue`** — Label names must match the project's label taxonomy.
4. **`create-spike`** — Reference to `build-from-issue` as next step must be accurate.
5. **`review-security-issue`** / **`fix-security-issue`** — Cross-references between the two must be accurate.
6. **PR creation and review checks** — The `create-github-pr`, `review-github-pr`, `build-from-issue`, and `principal-engineer-reviewer` references to `sync-agent-infra` must exist and use trigger conditions aligned with this skill.
## Step 3: Report Drift
@@ -125,6 +154,7 @@ If any inconsistencies are found, report them in a structured format:
### Cross-References
- <file>:<line> references non-existent skill <skill>
- <file>:<line> references non-existent label <label>
- The skill maintenance map has a stale or missing change-area mapping: <details>
- OK: <count> references consistent
```
@@ -140,6 +170,7 @@ If drift is found, fix it by updating the affected files:
4. **Added crate** — Add a row to the AGENTS.md architecture table.
5. **Removed crate** — Remove the row from the AGENTS.md architecture table.
6. **Changed workflow chain** — Update chains in both `AGENTS.md` and `CONTRIBUTING.md`. Update the "Built With Agents" section in `README.md` if the change is user-visible.
7. **Changed skill coverage** — Update the skill maintenance map in this file and any affected cross-references or companion-skill tables.
After fixing, re-run Step 2 to verify consistency.
@@ -58,6 +58,7 @@ When reviewing code or diffs:
5. Reference specific files and line numbers (`file_path:line_number`).
6. When suggesting a change, show the concrete fix — don't just describe it.
7. If something is good, say so briefly. Positive signal is useful too.
8. When behavior, commands, or development workflows change, consult the `sync-agent-infra` maintenance map and verify that related skills were updated. Apply its full consistency checklist when the changes add, remove, or rename skills or crates; change workflow relationships or skill coverage; modify issue or PR templates; or change agent cross-references. Report missing companion updates or drift as a warning.
When reviewing plans or architecture documents:
+1 -1
View File
@@ -26,7 +26,7 @@ runs:
- id: get_pr_info
if: github.event_name == 'push'
continue-on-error: true
uses: nv-gha-runners/get-pr-info@main
uses: nv-gha-runners/get-pr-info@090577647b8ddc4e06e809e264f7881650ecdccf # main
- id: gate
shell: bash
+1 -1
View File
@@ -34,7 +34,7 @@ runs:
using: composite
steps:
- name: Log in to GHCR
uses: docker/login-action@v3
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
+1 -1
View File
@@ -23,7 +23,7 @@ runs:
using: composite
steps:
- name: Set up Docker Buildx (local)
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
with:
name: ${{ inputs.name }}
driver: docker-container
+29
View File
@@ -0,0 +1,29 @@
name: Setup E2E CLI
description: Download an architecture-matched prebuilt OpenShell CLI for E2E tests
inputs:
artifact-prefix:
description: Artifact name prefix; linux-<arch> is appended automatically
required: true
runs:
using: composite
steps:
- name: Download prebuilt CLI
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ format('{0}-linux-{1}', inputs.artifact-prefix, runner.arch == 'X64' && 'amd64' || 'arm64') }}
path: .e2e/prebuilt-cli
- name: Configure prebuilt CLI
shell: bash
run: |
set -euo pipefail
cli="$GITHUB_WORKSPACE/.e2e/prebuilt-cli/openshell"
if [[ ! -f "$cli" ]]; then
echo "downloaded artifact is missing $cli" >&2
exit 1
fi
chmod +x "$cli"
"$cli" --version
echo "OPENSHELL_BIN=$cli" >> "$GITHUB_ENV"
@@ -0,0 +1,29 @@
name: Setup E2E Gateway
description: Download an architecture-matched prebuilt OpenShell gateway for E2E tests
inputs:
artifact-prefix:
description: Artifact name prefix; linux-<arch> is appended automatically
required: true
runs:
using: composite
steps:
- name: Download prebuilt gateway
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ format('{0}-linux-{1}', inputs.artifact-prefix, runner.arch == 'X64' && 'amd64' || 'arm64') }}
path: .e2e/prebuilt-gateway
- name: Configure prebuilt gateway
shell: bash
run: |
set -euo pipefail
gateway="$GITHUB_WORKSPACE/.e2e/prebuilt-gateway/openshell-gateway"
if [[ ! -f "$gateway" ]]; then
echo "downloaded artifact is missing $gateway" >&2
exit 1
fi
chmod +x "$gateway"
"$gateway" --version
echo "OPENSHELL_GATEWAY_BIN=$gateway" >> "$GITHUB_ENV"
@@ -0,0 +1,38 @@
name: Setup E2E VM Driver
description: Download a prebuilt OpenShell VM driver for E2E tests
inputs:
artifact-name:
description: Artifact name to download, such as driver-vm-linux-amd64
required: true
runs:
using: composite
steps:
- name: Download prebuilt VM driver
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ inputs.artifact-name }}
path: .e2e/prebuilt-vm-driver
- name: Configure prebuilt VM driver
shell: bash
run: |
set -euo pipefail
extract_dir="$GITHUB_WORKSPACE/.e2e/prebuilt-vm-driver/bin"
mkdir -p "$extract_dir"
archive="$(find "$GITHUB_WORKSPACE/.e2e/prebuilt-vm-driver" -maxdepth 1 -type f -name 'openshell-driver-vm-*.tar.gz' -print -quit)"
if [[ -z "$archive" ]]; then
echo "downloaded artifact is missing openshell-driver-vm-*.tar.gz" >&2
find "$GITHUB_WORKSPACE/.e2e/prebuilt-vm-driver" -maxdepth 2 -type f -print >&2 || true
exit 1
fi
tar -xzf "$archive" -C "$extract_dir"
driver="$extract_dir/openshell-driver-vm"
if [[ ! -f "$driver" ]]; then
echo "downloaded artifact is missing $driver" >&2
exit 1
fi
chmod +x "$driver"
"$driver" --version
echo "OPENSHELL_VM_DRIVER_BIN=$driver" >> "$GITHUB_ENV"
+6 -6
View File
@@ -31,7 +31,7 @@ jobs:
outputs:
should_run: ${{ steps.gate.outputs.should_run }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: gate
uses: ./.github/actions/pr-gate
@@ -47,7 +47,7 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Mark workspace as safe for git
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
@@ -71,7 +71,7 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: mise install --locked
@@ -97,7 +97,7 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Configure GHA sccache backend
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
@@ -154,7 +154,7 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: mise install --locked
@@ -185,7 +185,7 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: mise install --locked
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check Fern preview availability
id: fern-preview
+47 -12
View File
@@ -28,7 +28,7 @@ jobs:
run_kubernetes_ha_e2e: ${{ steps.labels.outputs.run_kubernetes_ha_e2e }}
run_any_e2e: ${{ steps.labels.outputs.run_any_e2e }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: gate
uses: ./.github/actions/pr-gate
- id: labels
@@ -73,7 +73,7 @@ jobs:
build-gateway:
needs: [pr_metadata]
if: needs.pr_metadata.outputs.should_run == 'true' && (needs.pr_metadata.outputs.run_core_e2e == 'true' || needs.pr_metadata.outputs.run_kubernetes_ha_e2e == 'true')
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_any_e2e == 'true'
permissions:
contents: read
packages: write
@@ -95,38 +95,59 @@ jobs:
build-cli:
needs: [pr_metadata]
if: needs.pr_metadata.outputs.should_run == 'true'
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_any_e2e == 'true'
permissions:
contents: read
packages: write
uses: ./.github/workflows/docker-build.yml
with:
component: cli
platform: linux/amd64
platform: linux/amd64,linux/arm64
e2e:
needs: [pr_metadata, build-gateway, build-supervisor]
build-driver-vm-linux:
needs: [pr_metadata]
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true'
permissions:
contents: read
packages: read
uses: ./.github/workflows/driver-vm-linux.yml
with:
image-tag: ${{ github.sha }}
checkout-ref: ${{ github.sha }}
runtime-platforms: linux-x86_64
driver-targets: >-
[{"arch":"amd64","runner":"linux-amd64-cpu8","target":"x86_64-unknown-linux-gnu","zig_target":"x86_64-unknown-linux-gnu.2.28","platform":"linux-x86_64","guest_arch":"x86_64"}]
e2e:
needs: [pr_metadata, build-gateway, build-supervisor, build-cli, build-driver-vm-linux]
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true'
permissions:
actions: read
contents: read
packages: read
uses: ./.github/workflows/e2e-test.yml
with:
image-tag: ${{ github.sha }}
runner: linux-arm64-cpu8
cli-artifact-prefix: rust-binary-cli
gateway-artifact-prefix: rust-binary-gateway
vm-driver-artifact-name: driver-vm-linux-amd64
gpu-e2e:
needs: [pr_metadata, build-supervisor]
needs: [pr_metadata, build-gateway, build-supervisor, build-cli]
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_gpu_e2e == 'true'
permissions:
actions: read
contents: read
packages: read
uses: ./.github/workflows/e2e-gpu-test.yaml
with:
image-tag: ${{ github.sha }}
cli-artifact-prefix: rust-binary-cli
gateway-artifact-prefix: rust-binary-gateway
kubernetes-e2e:
needs: [pr_metadata, build-gateway, build-supervisor]
needs: [pr_metadata, build-gateway, build-supervisor, build-cli]
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true'
strategy:
fail-fast: false
@@ -145,6 +166,7 @@ jobs:
topology: sidecar
extra_helm_values: deploy/helm/openshell/ci/values-sidecar.yaml
permissions:
actions: read
contents: read
packages: read
uses: ./.github/workflows/e2e-kubernetes-test.yml
@@ -153,11 +175,13 @@ jobs:
job-name: Kubernetes E2E (Rust smoke, ${{ matrix.topology }}, Agent Sandbox ${{ matrix.agent_sandbox_api }})
agent-sandbox-version: ${{ matrix.agent_sandbox_version }}
extra-helm-values: ${{ matrix.extra_helm_values }}
cli-artifact-prefix: rust-binary-cli
kubernetes-ha-e2e:
needs: [pr_metadata, build-gateway, build-supervisor]
needs: [pr_metadata, build-gateway, build-supervisor, build-cli]
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_kubernetes_ha_e2e == 'true'
permissions:
actions: read
contents: read
packages: read
uses: ./.github/workflows/e2e-kubernetes-test.yml
@@ -166,10 +190,11 @@ jobs:
job-name: Kubernetes HA E2E (Rust smoke)
extra-helm-values: deploy/helm/openshell/ci/values-high-availability.yaml
external-postgres-secret: openshell-ha-pg
cli-artifact-prefix: rust-binary-cli
core-e2e-result:
name: Core E2E result
needs: [pr_metadata, build-gateway, build-supervisor, e2e, kubernetes-e2e]
needs: [pr_metadata, build-gateway, build-supervisor, build-cli, build-driver-vm-linux, e2e, kubernetes-e2e]
if: always() && needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true'
runs-on: ubuntu-latest
steps:
@@ -177,6 +202,8 @@ jobs:
env:
BUILD_GATEWAY_RESULT: ${{ needs.build-gateway.result }}
BUILD_SUPERVISOR_RESULT: ${{ needs.build-supervisor.result }}
BUILD_CLI_RESULT: ${{ needs.build-cli.result }}
BUILD_DRIVER_VM_RESULT: ${{ needs.build-driver-vm-linux.result }}
E2E_RESULT: ${{ needs.e2e.result }}
KUBERNETES_E2E_RESULT: ${{ needs.kubernetes-e2e.result }}
run: |
@@ -185,6 +212,8 @@ jobs:
for item in \
"build-gateway:$BUILD_GATEWAY_RESULT" \
"build-supervisor:$BUILD_SUPERVISOR_RESULT" \
"build-cli:$BUILD_CLI_RESULT" \
"build-driver-vm-linux:$BUILD_DRIVER_VM_RESULT" \
"e2e:$E2E_RESULT" \
"kubernetes-e2e:$KUBERNETES_E2E_RESULT"; do
name="${item%%:*}"
@@ -198,19 +227,23 @@ jobs:
gpu-e2e-result:
name: GPU E2E result
needs: [pr_metadata, build-supervisor, gpu-e2e]
needs: [pr_metadata, build-gateway, build-supervisor, build-cli, gpu-e2e]
if: always() && needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_gpu_e2e == 'true'
runs-on: ubuntu-latest
steps:
- name: Verify GPU E2E jobs
env:
BUILD_GATEWAY_RESULT: ${{ needs.build-gateway.result }}
BUILD_SUPERVISOR_RESULT: ${{ needs.build-supervisor.result }}
BUILD_CLI_RESULT: ${{ needs.build-cli.result }}
GPU_E2E_RESULT: ${{ needs.gpu-e2e.result }}
run: |
set -euo pipefail
failed=0
for item in \
"build-gateway:$BUILD_GATEWAY_RESULT" \
"build-supervisor:$BUILD_SUPERVISOR_RESULT" \
"build-cli:$BUILD_CLI_RESULT" \
"gpu-e2e:$GPU_E2E_RESULT"; do
name="${item%%:*}"
result="${item#*:}"
@@ -223,7 +256,7 @@ jobs:
kubernetes-ha-e2e-result:
name: Kubernetes HA E2E result
needs: [pr_metadata, build-gateway, build-supervisor, kubernetes-ha-e2e]
needs: [pr_metadata, build-gateway, build-supervisor, build-cli, kubernetes-ha-e2e]
if: always() && needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_kubernetes_ha_e2e == 'true'
runs-on: ubuntu-latest
steps:
@@ -231,6 +264,7 @@ jobs:
env:
BUILD_GATEWAY_RESULT: ${{ needs.build-gateway.result }}
BUILD_SUPERVISOR_RESULT: ${{ needs.build-supervisor.result }}
BUILD_CLI_RESULT: ${{ needs.build-cli.result }}
KUBERNETES_HA_E2E_RESULT: ${{ needs.kubernetes-ha-e2e.result }}
run: |
set -euo pipefail
@@ -238,6 +272,7 @@ jobs:
for item in \
"build-gateway:$BUILD_GATEWAY_RESULT" \
"build-supervisor:$BUILD_SUPERVISOR_RESULT" \
"build-cli:$BUILD_CLI_RESULT" \
"kubernetes-ha-e2e:$KUBERNETES_HA_E2E_RESULT"; do
name="${item%%:*}"
result="${item#*:}"
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Log in to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
+5 -2
View File
@@ -6,6 +6,9 @@ on:
pull_request_target:
types: [opened, closed, synchronize]
env:
DCO_SIGN_COMMENT: "I have read the DCO document and I hereby sign the DCO."
permissions:
actions: write
checks: none
@@ -29,7 +32,7 @@ jobs:
- name: "DCO Assistant"
if: |
(github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read the Contributor Agreement including DCO and I hereby sign the Contributor Agreement and DCO') ||
github.event.comment.body == env.DCO_SIGN_COMMENT) ||
github.event_name == 'pull_request_target'
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08
env:
@@ -46,6 +49,6 @@ jobs:
[Developer Certificate of Origin](https://github.com/NVIDIA/OpenShell/blob/main/DCO)
before we can accept your contribution. You can sign the DCO by
adding a comment below using this text:
custom-pr-sign-comment: "I have read the DCO document and I hereby sign the DCO."
custom-pr-sign-comment: ${{ env.DCO_SIGN_COMMENT }}
lock-pullrequest-aftermerge: false
use-dco-flag: true
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs['checkout-ref'] }}
+2 -2
View File
@@ -154,7 +154,7 @@ jobs:
echo "arches=${arches[*]}"
echo "binary_component=$binary_component"
echo "binary_name=$binary_name"
echo "artifact_prefix=rust-binary-${component}-${binary_component}"
echo "artifact_prefix=rust-binary-${component}"
echo "image_tag_base=$image_tag_base"
echo "features=$features"
echo "has_image=$has_image"
@@ -206,7 +206,7 @@ jobs:
DOCKER_PUSH: ${{ inputs.push && '1' || '0' }}
DOCKER_PLATFORM: ${{ matrix.platform }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs['checkout-ref'] || github.sha }}
fetch-depth: 0
+37 -19
View File
@@ -4,14 +4,26 @@ on:
workflow_call:
inputs:
cargo-version:
required: true
required: false
type: string
default: ""
image-tag:
required: true
type: string
checkout-ref:
required: true
type: string
runtime-platforms:
description: "Comma-separated VM runtime platforms to download: linux-aarch64,linux-x86_64"
required: false
type: string
default: "linux-aarch64,linux-x86_64"
driver-targets:
description: "JSON matrix entries for Linux VM driver targets"
required: false
type: string
default: >-
[{"arch":"arm64","runner":"linux-arm64-cpu8","target":"aarch64-unknown-linux-gnu","zig_target":"aarch64-unknown-linux-gnu.2.28","platform":"linux-aarch64","guest_arch":"aarch64"},{"arch":"amd64","runner":"linux-amd64-cpu8","target":"x86_64-unknown-linux-gnu","zig_target":"x86_64-unknown-linux-gnu.2.28","platform":"linux-x86_64","guest_arch":"x86_64"}]
permissions:
contents: read
@@ -32,18 +44,36 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs['checkout-ref'] }}
- name: Download Linux runtime tarballs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RUNTIME_PLATFORMS: ${{ inputs['runtime-platforms'] }}
run: |
set -euo pipefail
mkdir -p runtime-artifacts
for platform in linux-aarch64 linux-x86_64; do
runtime_platforms="${RUNTIME_PLATFORMS//[[:space:]]/}"
IFS=',' read -r -a platforms <<< "$runtime_platforms"
if [ "${#platforms[@]}" -eq 0 ]; then
echo "::error::No VM runtime platforms requested"
exit 1
fi
for platform in "${platforms[@]}"; do
case "$platform" in
linux-aarch64 | linux-x86_64) ;;
*)
echo "::error::Unsupported VM runtime platform '$platform' in runtime-platforms"
exit 1
;;
esac
done
printf '%s\n' "${platforms[@]}" > runtime-artifacts/platforms.txt
for platform in "${platforms[@]}"; do
asset="vm-runtime-${platform}.tar.zst"
echo "Downloading ${asset}..."
if ! gh release download vm-runtime \
@@ -61,9 +91,9 @@ jobs:
- name: Verify downloads
run: |
set -euo pipefail
for platform in linux-aarch64 linux-x86_64; do
while IFS= read -r platform; do
test -f "runtime-artifacts/vm-runtime-${platform}.tar.zst"
done
done < runtime-artifacts/platforms.txt
- name: Upload runtime artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
@@ -77,19 +107,7 @@ jobs:
needs: [download-kernel-runtime]
strategy:
matrix:
include:
- arch: arm64
runner: linux-arm64-cpu8
target: aarch64-unknown-linux-gnu
zig_target: aarch64-unknown-linux-gnu.2.28
platform: linux-aarch64
guest_arch: aarch64
- arch: amd64
runner: linux-amd64-cpu8
target: x86_64-unknown-linux-gnu
zig_target: x86_64-unknown-linux-gnu.2.28
platform: linux-x86_64
guest_arch: x86_64
include: ${{ fromJSON(inputs['driver-targets']) }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
container:
@@ -102,7 +120,7 @@ jobs:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENSHELL_IMAGE_TAG: ${{ inputs['image-tag'] }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs['checkout-ref'] }}
fetch-depth: 0
+3 -3
View File
@@ -32,7 +32,7 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs['checkout-ref'] }}
@@ -79,7 +79,7 @@ jobs:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENSHELL_IMAGE_TAG: ${{ inputs['image-tag'] }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs['checkout-ref'] }}
fetch-depth: 0
@@ -135,7 +135,7 @@ jobs:
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs['checkout-ref'] }}
fetch-depth: 0
+24 -1
View File
@@ -7,8 +7,19 @@ on:
description: "Image tag to test (typically the commit SHA)"
required: true
type: string
cli-artifact-prefix:
description: "Optional prebuilt CLI artifact prefix (artifact suffix is linux-<arch>)"
required: false
type: string
default: ""
gateway-artifact-prefix:
description: "Optional prebuilt gateway artifact prefix (artifact suffix is linux-<arch>)"
required: false
type: string
default: ""
permissions:
actions: read
contents: read
packages: read
@@ -56,7 +67,19 @@ jobs:
# probe below and by the e2e tests in e2e/rust/tests/gpu/device_selection.rs.
OPENSHELL_E2E_GPU_PROBE_IMAGE: "nvcr.io/nvidia/base/ubuntu:noble-20251013"
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Use prebuilt OpenShell CLI
if: inputs.cli-artifact-prefix != ''
uses: ./.github/actions/setup-e2e-cli
with:
artifact-prefix: ${{ inputs.cli-artifact-prefix }}
- name: Use prebuilt OpenShell gateway
if: inputs.gateway-artifact-prefix != ''
uses: ./.github/actions/setup-e2e-gateway
with:
artifact-prefix: ${{ inputs.gateway-artifact-prefix }}
- name: Log in to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
+14 -1
View File
@@ -42,8 +42,14 @@ on:
required: false
type: string
default: "v2026.4.25"
cli-artifact-prefix:
description: "Optional prebuilt CLI artifact prefix (artifact suffix is linux-<arch>)"
required: false
type: string
default: ""
permissions:
actions: read
contents: read
packages: read
@@ -56,6 +62,7 @@ jobs:
runs-on: ${{ inputs.runner }}
timeout-minutes: 60
permissions:
actions: read
contents: read
packages: read
env:
@@ -64,10 +71,16 @@ jobs:
MISE_VERSION: ${{ inputs.mise-version }}
KIND_CLUSTER_NAME: kube-e2e-${{ github.run_id }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs['checkout-ref'] || github.sha }}
- name: Use prebuilt OpenShell CLI
if: inputs.cli-artifact-prefix != ''
uses: ./.github/actions/setup-e2e-cli
with:
artifact-prefix: ${{ inputs.cli-artifact-prefix }}
- name: Install mise
run: |
curl https://mise.run | MISE_VERSION=v2026.4.25 sh
+2 -1
View File
@@ -59,7 +59,8 @@ jobs:
esac
mirror_ref="pull-request/$PR_NUMBER"
mirror_sha=$(gh api "repos/$GH_REPO/branches/$mirror_ref" --jq '.commit.sha' 2>/dev/null || echo "")
mirror_sha=$(gh api "repos/$GH_REPO/git/ref/heads/$mirror_ref" --jq '.object.sha' 2>/dev/null || true)
if [[ ! "$mirror_sha" =~ ^[0-9a-f]{40}$ ]]; then mirror_sha=""; fi
short_pr=${PR_HEAD_SHA:0:7}
if [ -z "$mirror_sha" ]; then
+68 -4
View File
@@ -17,8 +17,24 @@ on:
required: false
type: string
default: ""
cli-artifact-prefix:
description: "Optional prebuilt CLI artifact prefix (artifact suffix is linux-<arch>)"
required: false
type: string
default: ""
gateway-artifact-prefix:
description: "Optional prebuilt gateway artifact prefix (artifact suffix is linux-<arch>)"
required: false
type: string
default: ""
vm-driver-artifact-name:
description: "Optional prebuilt VM driver artifact name"
required: false
type: string
default: ""
permissions:
actions: read
contents: read
packages: read
@@ -58,14 +74,26 @@ jobs:
OPENSHELL_REGISTRY_USERNAME: ${{ github.actor }}
OPENSHELL_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs['checkout-ref'] || github.sha }}
persist-credentials: false
- name: Use prebuilt OpenShell CLI
if: inputs.cli-artifact-prefix != ''
uses: ./.github/actions/setup-e2e-cli
with:
artifact-prefix: ${{ inputs.cli-artifact-prefix }}
- name: Use prebuilt OpenShell gateway
if: inputs.gateway-artifact-prefix != ''
uses: ./.github/actions/setup-e2e-gateway
with:
artifact-prefix: ${{ inputs.gateway-artifact-prefix }}
- name: Check out MCP conformance tests
if: matrix.suite == 'mcp'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: modelcontextprotocol/conformance
# Pin after v0.1.16 to include the tools_call client scenario fix.
@@ -121,11 +149,23 @@ jobs:
OPENSHELL_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
OPENSHELL_SUPERVISOR_IMAGE: ${{ format('ghcr.io/nvidia/openshell/supervisor:{0}', inputs.image-tag) }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs['checkout-ref'] || github.sha }}
persist-credentials: false
- name: Use prebuilt OpenShell CLI
if: inputs.cli-artifact-prefix != ''
uses: ./.github/actions/setup-e2e-cli
with:
artifact-prefix: ${{ inputs.cli-artifact-prefix }}
- name: Use prebuilt OpenShell gateway
if: inputs.gateway-artifact-prefix != ''
uses: ./.github/actions/setup-e2e-gateway
with:
artifact-prefix: ${{ inputs.gateway-artifact-prefix }}
- name: Install mise
run: |
curl https://mise.run | MISE_VERSION=v2026.4.25 sh
@@ -217,11 +257,29 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs['checkout-ref'] || github.sha }}
persist-credentials: false
- name: Use prebuilt OpenShell CLI
if: inputs.cli-artifact-prefix != ''
uses: ./.github/actions/setup-e2e-cli
with:
artifact-prefix: ${{ inputs.cli-artifact-prefix }}
- name: Use prebuilt OpenShell gateway
if: inputs.gateway-artifact-prefix != ''
uses: ./.github/actions/setup-e2e-gateway
with:
artifact-prefix: ${{ inputs.gateway-artifact-prefix }}
- name: Use prebuilt OpenShell VM driver
if: inputs.vm-driver-artifact-name != ''
uses: ./.github/actions/setup-e2e-vm-driver
with:
artifact-name: ${{ inputs.vm-driver-artifact-name }}
- name: Enable KVM access
run: |
set -euo pipefail
@@ -252,6 +310,12 @@ jobs:
socat \
zstd
- name: Validate VM host tools
run: |
command -v mke2fs
command -v mkfs.ext4
command -v debugfs
- name: Install mise
run: |
curl https://mise.run | MISE_VERSION=v2026.4.25 sh
+3 -3
View File
@@ -32,7 +32,7 @@ jobs:
outputs:
should_run: ${{ steps.gate.outputs.should_run }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: gate
uses: ./.github/actions/pr-gate
@@ -53,7 +53,7 @@ jobs:
shell: bash
run: echo "should_run=true" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
if: github.event_name == 'push'
- id: merge-base
@@ -86,7 +86,7 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: mise install --locked
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
timeout-minutes: 15
steps:
- name: Checkout docs website branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: docs-website
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
create-tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
+15 -11
View File
@@ -33,7 +33,7 @@ jobs:
rpm_version: ${{ steps.v.outputs.rpm_version }}
rpm_release: ${{ steps.v.outputs.rpm_release }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
@@ -69,6 +69,10 @@ jobs:
e2e:
needs: [build-gateway, build-supervisor]
permissions:
actions: read
contents: read
packages: read
uses: ./.github/workflows/e2e-test.yml
with:
image-tag: ${{ github.sha }}
@@ -123,7 +127,7 @@ jobs:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENSHELL_IMAGE_TAG: dev
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
@@ -170,7 +174,7 @@ jobs:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENSHELL_IMAGE_TAG: dev
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
@@ -231,7 +235,7 @@ jobs:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENSHELL_IMAGE_TAG: dev
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
@@ -332,7 +336,7 @@ jobs:
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
@@ -403,7 +407,7 @@ jobs:
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
@@ -495,7 +499,7 @@ jobs:
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
@@ -569,7 +573,7 @@ jobs:
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
@@ -790,7 +794,7 @@ jobs:
outputs:
wheel_filenames: ${{ steps.wheel_filenames.outputs.wheel_filenames }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download all CLI artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -916,7 +920,7 @@ jobs:
cat release/openshell.rb
- name: Attest VM driver artifacts
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with:
subject-path: |
release/openshell-driver-vm-x86_64-unknown-linux-gnu.tar.gz
@@ -1027,7 +1031,7 @@ jobs:
permissions:
packages: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ./.github/actions/release-helm-oci
with:
+16 -12
View File
@@ -48,7 +48,7 @@ jobs:
# Commit resolved from RELEASE_TAG, used for image tags and downstream metadata
source_sha: ${{ steps.v.outputs.source_sha }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
@@ -91,6 +91,10 @@ jobs:
e2e:
needs: [compute-versions, build-gateway, build-supervisor]
permissions:
actions: read
contents: read
packages: read
uses: ./.github/workflows/e2e-test.yml
with:
image-tag: ${{ needs.compute-versions.outputs.source_sha }}
@@ -152,7 +156,7 @@ jobs:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENSHELL_IMAGE_TAG: ${{ needs.compute-versions.outputs.semver }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
@@ -200,7 +204,7 @@ jobs:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENSHELL_IMAGE_TAG: ${{ needs.compute-versions.outputs.semver }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
@@ -262,7 +266,7 @@ jobs:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENSHELL_IMAGE_TAG: ${{ needs.compute-versions.outputs.semver }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
@@ -364,7 +368,7 @@ jobs:
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
@@ -436,7 +440,7 @@ jobs:
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
@@ -536,7 +540,7 @@ jobs:
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
@@ -613,7 +617,7 @@ jobs:
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
@@ -871,7 +875,7 @@ jobs:
outputs:
wheel_filenames: ${{ steps.wheel_filenames.outputs.wheel_filenames }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag || github.ref }}
@@ -974,7 +978,7 @@ jobs:
cat release/openshell.rb
- name: Attest VM driver artifacts
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with:
subject-path: |
release/*.tar.gz
@@ -1047,7 +1051,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag || github.ref }}
@@ -1075,7 +1079,7 @@ jobs:
permissions:
packages: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag || github.ref }}
+5 -5
View File
@@ -47,7 +47,7 @@ jobs:
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Mark workspace safe for git
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
@@ -97,7 +97,7 @@ jobs:
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Mark workspace safe for git
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
@@ -130,7 +130,7 @@ jobs:
env:
RUSTC_WRAPPER: ""
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install dependencies
run: |
@@ -176,7 +176,7 @@ jobs:
attestations: write
artifact-metadata: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download all runtime artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -186,7 +186,7 @@ jobs:
merge-multiple: true
- name: Attest VM runtime artifacts
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with:
subject-path: |
release/vm-runtime-linux-aarch64.tar.zst
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
pandoc python3-devel git-core \
cargo-rpm-macros
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.checkout-ref }}
fetch-depth: 0
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: mise install --locked
+1 -1
View File
@@ -86,7 +86,7 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs['checkout-ref'] || github.sha }}
fetch-depth: 0
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
timeout-minutes: 60
environment: ${{ inputs.github-environment }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.checkout-ref }}
fetch-depth: 0
+3 -3
View File
@@ -52,7 +52,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Checkout automation
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: automation
@@ -79,14 +79,14 @@ jobs:
- name: Checkout source docs
if: ${{ inputs.operation == 'sync' }}
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.source_ref }}
fetch-depth: 0
path: source
- name: Checkout docs website branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: docs-website
fetch-depth: 0
+5
View File
@@ -35,6 +35,7 @@ These pipelines connect skills into end-to-end workflows. Individual skill files
| `crates/openshell-policy/` | Policy engine | Filesystem, network, process, and inference constraints |
| `crates/openshell-router/` | Privacy router | Privacy-aware LLM routing |
| `crates/openshell-bootstrap/` | Gateway metadata | Gateway registration metadata, auth token storage, mTLS bundle storage |
| `crates/openshell-gateway-interceptors/` | Gateway interceptors | Intercepts and transforms configured gRPC requests at the gateway routing boundary |
| `crates/openshell-ocsf/` | OCSF logging | OCSF v1.7.0 event types, builders, shorthand/JSONL formatters, tracing layers |
| `crates/openshell-core/` | Shared core | Common types, configuration, error handling |
| `crates/openshell-sdk/` | Shared client SDK | Async Rust gateway client (gRPC transport, TLS, OIDC refresh, edge tunnel); consumed by CLI, TUI, and `@openshell/sdk` |
@@ -197,6 +198,10 @@ ocsf_emit!(event);
- If you change gateway deployment infrastructure (e.g., Helm values/templates, gateway image packaging, or deploy logic in `openshell-cli`), update the `debug-openshell-cluster` skill in `.agents/skills/debug-openshell-cluster/SKILL.md` to reflect those changes.
## Skill Maintenance
When behavior, commands, or development workflows change, review the related agent skills in the same branch. Use the `sync-agent-infra` skill for the maintenance map and consistency checks.
## Documentation
- When making changes, update the relevant documentation in the `architecture/` directory.
+1 -1
View File
@@ -20,7 +20,7 @@ Three opt-in labels enable the long-running E2E suites:
- `test:e2e-kubernetes` runs Kubernetes E2E with the HA Helm overlay
(`replicaCount: 2` and bundled PostgreSQL) in `Branch E2E Checks`
When multiple labels are present, `Branch E2E Checks` builds the shared gateway and supervisor images once and fans out all enabled suites in parallel.
When multiple labels are present, `Branch E2E Checks` builds the shared gateway and supervisor images once, builds one CLI artifact per runner architecture, builds the Linux VM driver artifact once, and fans out all enabled suites in parallel. Docker, Podman, GPU, Rust, Python, MCP, and VM E2E jobs reuse the matching prebuilt gateway and CLI binaries instead of compiling additional debug binaries in each job; Kubernetes E2E consumes the gateway image directly and reuses the prebuilt CLI. VM E2E also reuses the prebuilt VM driver artifact and falls back to local VM-driver/runtime preparation for local runs or workflow invocations that omit the artifact.
The `OpenShell / E2E` and `OpenShell / GPU E2E` required statuses are evaluated from separate suite result jobs inside that workflow. `test:e2e-kubernetes` is optional while HA behavior is under active iteration: failures are visible in the workflow run but do not publish a required CI gate status.
The GitHub ruleset should require the `OpenShell / ...` statuses published by `Required CI Gates`, not the push-triggered workflow jobs directly.
+23
View File
@@ -74,6 +74,7 @@ Skills live in `.agents/skills/`. Your agent's harness can discover and load the
| Contributing | `create-github-issue` | Create well-structured GitHub issues |
| Contributing | `create-github-pr` | Create pull requests with proper conventions |
| Reviewing | `review-github-pr` | Summarize PR diffs and key design decisions |
| Reviewing | `review-security-changes` | Review code changes for security vulnerabilities and boundary regressions |
| Reviewing | `review-security-issue` | Assess security issues for severity and remediation |
| Reviewing | `fix-security-issue` | Implement an approved security remediation plan |
| Reviewing | `watch-github-actions` | Monitor CI pipeline status and logs |
@@ -195,6 +196,28 @@ openshell --help
openshell sandbox create -- codex
```
### Rust build cache
Mise preserves an existing `SCCACHE_DIR` so each environment can choose where
to store compiler cache entries. When `SCCACHE_DIR` is unset, OpenShell uses
the worktree-local `.cache/sccache` directory. To make cache entries available
to multiple worktrees on a workstation, set the variable to a user-level
directory before activating mise. For example:
```shell
export SCCACHE_DIR="$HOME/.cache/openshell/sccache"
```
CI can select a different directory or configure a remote sccache backend
without changing the workstation setting. Cargo output remains in each
worktree's `target/` directory.
OpenShell does not set `SCCACHE_BASEDIRS`. Sccache loads base directories when
its machine-local daemon starts, but the correct workspace root differs for
each worktree. Cache reuse therefore depends on the compiler inputs: outputs
that embed absolute paths, including Rust dependencies in some builds, can
still miss across worktrees.
## Main Tasks
These are the primary `mise` tasks for day-to-day development:
Generated
+1
View File
@@ -4356,6 +4356,7 @@ version = "0.0.0"
dependencies = [
"base64 0.22.1",
"crossterm 0.28.1",
"indexmap",
"miette",
"openshell-bootstrap",
"openshell-core",
+1
View File
@@ -110,6 +110,7 @@ pin-project-lite = "0.2"
tokio-stream = "0.1"
protobuf-src = "1.1.0"
url = "2"
indexmap = "2"
# Database
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "sqlite", "migrate"] }
+4 -4
View File
@@ -1,4 +1,4 @@
# NVIDIA OpenShell
# ![OpenShell](docs/brand/assets/openshell-lockup-horizontal.svg)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue)](https://github.com/NVIDIA/OpenShell/blob/main/LICENSE)
[![PyPI](https://img.shields.io/badge/PyPI-openshell-orange?logo=pypi)](https://pypi.org/project/openshell/)
@@ -16,7 +16,7 @@ OpenShell is built agent-first. The project ships with agent skills for everythi
### Prerequisites
- **A supported host** — macOS, Windows with WSL 2, or Linux.
- **A supported host** — Linux, macOS (Apple Silicon), or Windows with WSL 2 (experimental).
- **A local runtime** — Docker, Podman, or host virtualization enabled for MicroVM-backed sandboxes.
### Install
@@ -198,7 +198,7 @@ openshell sandbox create --from ./my-sandbox-dir # local Dockerfile
openshell sandbox create --from registry.io/img:v1 # container image
```
See the [community sandboxes](https://docs.nvidia.com/openshell/latest/sandboxes/community-sandboxes) catalog and the [BYOC example](https://github.com/NVIDIA/OpenShell/tree/main/examples/bring-your-own-container) for details.
See the [OpenShell Community](https://github.com/NVIDIA/OpenShell-Community) catalog and the [BYOC example](https://github.com/NVIDIA/OpenShell/tree/main/examples/bring-your-own-container) for details.
## Explore with Your Agent
@@ -234,7 +234,7 @@ All implementation work is human-gated — agents propose plans, humans approve,
- [Full Documentation](https://docs.nvidia.com/openshell/latest/index.html) — overview, architecture, tutorials, and reference
- [Quickstart](https://docs.nvidia.com/openshell/latest/get-started/quickstart) — detailed install and first sandbox walkthrough
- [GitHub Sandbox Tutorial](https://docs.nvidia.com/openshell/latest/tutorials/github-sandbox) — end-to-end scoped GitHub repo access
- [GitHub Sandbox Tutorial](https://docs.nvidia.com/openshell/latest/get-started/tutorials/github-sandbox) — end-to-end scoped GitHub repo access
- [Architecture](https://github.com/NVIDIA/OpenShell/tree/main/architecture) — detailed architecture docs and design decisions
- [Roadmap](https://github.com/orgs/NVIDIA/projects/233) — planned work and project priorities
- [RFC Board](https://github.com/orgs/NVIDIA/projects/233/views/6) — RFC proposals tracked on the OpenShell Roadmap with the `rfc` label
+1 -1
View File
@@ -13,7 +13,7 @@ To report a potential security vulnerability in any NVIDIA product:
- We encourage you to use the following PGP key for secure email communication: [NVIDIA public PGP Key for communication](https://www.nvidia.com/en-us/security/pgp-key)
- Please include the following information:
- Product/Driver name and version/branch that contains the vulnerability
- Type of vulnerability (code execution, denial of service, buffer overflow, etc.)
- Type of vulnerability (code execution, denial of service, buffer overflow, etc.)
- Instructions to reproduce the vulnerability
- Proof-of-concept or exploit code
- Potential impact of the vulnerability, including how an attacker could exploit the vulnerability
+1
View File
@@ -166,6 +166,7 @@ that crate's `README.md`.
| [Security Policy](security-policy.md) | Policy model, enforcement layers, policy updates, policy advisor, and security logging. |
| [Compute Runtimes](compute-runtimes.md) | Docker, Podman, Kubernetes, VM, sandbox images, and runtime-specific responsibilities. |
| [Build](build.md) | Build artifacts, CI/E2E, docs site validation, and release packaging. |
| [Google Vertex AI Provider](google-vertex-ai-provider.md) | Implementation reference for the `google-vertex-ai` provider, from CLI through gateway to sandbox. |
## `rfc/` vs `architecture/`
+11 -4
View File
@@ -59,7 +59,12 @@ and the supervisor image from `deploy/docker/Dockerfile.supervisor`. Neither
Dockerfile compiles Rust — both copy a staged binary out of
`deploy/docker/.build/prebuilt-binaries/<arch>/` into the final image.
Binary staging is driven by `tasks/scripts/stage-prebuilt-binaries.sh`. Gateway
Binary staging is driven by `tasks/scripts/stage-prebuilt-binaries.sh`. Because
staging cross-compiles on the host, it sources `tasks/scripts/build-env.sh` and
raises the per-process open-file limit before invoking `cargo zigbuild` on
macOS — the static musl link opens hundreds of `.rlib` files at once and would
otherwise fail with `ProcessFdQuotaExceeded` under macOS's default soft limit of
256. The guard is a no-op on Linux and when `cargo-zigbuild` is absent. Gateway
binaries use `cargo zigbuild` with GNU targets pinned to glibc 2.28, including
native-architecture builds, so the gateway image, standalone tarballs, and Linux
packages share the same host portability floor. The gateway build enables
@@ -133,9 +138,11 @@ do not infer from kube context.
## Python Wheel Packaging
The generated protobuf/gRPC stubs under `python/openshell/_proto/` are gitignored
build outputs of `mise run python:proto`. maturin honors `.gitignore` when
collecting `python-source` files, so native builds (Linux CI, local
`pip install .`) would drop them and ship an unimportable wheel. `pyproject.toml`
build outputs of `mise run python:proto`. The task uses `uv run --frozen` to
synchronize the current worktree's `.venv` from `uv.lock` before generation.
maturin honors `.gitignore` when collecting `python-source` files, so native
builds (Linux CI, local `pip install .`) would drop them and ship an unimportable
wheel. `pyproject.toml`
pins them back in with `[tool.maturin].include` globs. The release workflows
install each Linux wheel in a clean image and import `openshell.sandbox` as a
smoke check.
+28
View File
@@ -30,6 +30,34 @@ The gateway records driver identity and version from the startup capability
response. Elevated gateway info reports that initialized driver snapshot instead
of re-querying drivers on each request.
## Deletion Lifecycle
Delete requests use per-sandbox gates to serialize delete attempts. A request
resolves the name once and remains bound to that stable ID. The only
combined lock order is delete gate, then the gateway-wide state guard; external
driver calls run without the global guard.
Delete gates are process-local and do not coordinate gateway replicas. They
serialize attempts rather than share results: if one attempt fails and recovery
restores a deletable state, a request waiting on the gate may retry the driver.
Persisted resource-version checks remain the cross-replica safety boundary.
Watcher events do not acquire delete gates. Exact resource-version checks allow
them to interleave safely: status snapshots are no-ops for `Deleting` rows,
deleted events are idempotent, and snapshots for absent rows are ignored.
An accepted delete (`deleted = true`) is finalized by the watcher. If the
backend is already absent (`deleted = false`), the request removes gateway state
synchronously. Sandbox row removal remains bound to the stable ID and resource
version. Settings retain their existing best-effort name-based cleanup; SSH
sessions, indexes, and watch/log buses are cleaned after confirmed removal.
The request acquires both locks before starting owned work, so cancellation
while queued does not leave a delete armed. After that commitment point, the
owned task prevents cancellation from stranding a mutation. A gateway restart
does not resume a persisted `Deleting` operation. If the backend completed the
delete, reconciliation removes the row; otherwise it can remain `Deleting`.
## Runtime Summary
| Runtime | Best fit | Sandbox boundary | Notes |
+50 -11
View File
@@ -28,6 +28,13 @@ enrichment adds existing GPU device nodes as read-write paths and promotes
`/proc` to read-write because CUDA workloads write thread metadata under
`/proc/<pid>/task/<tid>/comm`.
Landlock rules are tailored to the inode type reported by the already-opened
path descriptor. Directories retain the requested directory and file rights;
regular files, device nodes, sockets, and other non-directories retain only
file-compatible rights. This avoids rejecting valid mixed-path policies without
weakening `hard_requirement`: genuine unsupported ABI capabilities and
preparation failures still fail sandbox startup.
## Network Decisions
Ordinary network traffic follows this order:
@@ -115,17 +122,25 @@ through the proposal loop instead of treating the denial as terminal.
policy plus the sandbox's attached-provider credential set, then computes
the delta of findings between the current baseline and the merged policy.
3. **Auto-approval gate (proposer-agnostic, opt-in).** Auto-approval fires
when *both* (a) the prover delta is empty (`prover: no new findings`) AND
(b) the `proposal_approval_mode` setting resolves to `"auto"` — gateway
scope wins, sandbox scope is the per-sandbox override, default is
`"manual"`. When both hold, the gateway internally invokes the approve
path with actor identity `system:auto`. The audit event uses
`CONFIG:APPROVED` and carries `auto=true`, `source=<mode>`,
`prover_delta=empty`, and `resolved_from=<gateway|sandbox>` as unmapped
fields, with message text `"auto-approved: no new prover findings"` —
never `safe`. The opt-in gate preserves OpenShell's default-deny
posture: with no setting at either scope, every proposal lands in
`pending` for human review, even when the prover sees no findings.
only when *all three* conditions hold: (a) `proposal_approval_mode`
resolves to `"auto"` — gateway scope wins, sandbox scope is the
per-sandbox override, default is `"manual"`; (b) the prover delta is empty
(`prover: no new findings`); and (c) the security notes recomputed from
the chunk's current proposed rule are empty (see
[Security-notes gate](#security-notes-gate)). Before merging, the gateway
reloads the stored chunk and reruns both checks on its current rule. This is
important after edits and mechanistic deduplication: the stored rule, not a
duplicate incoming payload or stale persisted analysis, controls the
decision. The recalculated prover verdict is decision-local rather than
persisted, so `validation_result` reads can still show the submit-time
verdict after an edit or deduplication. Decode, prover, or merge failures
leave the chunk pending. The audit event uses `CONFIG:APPROVED` and carries
`auto=true`, `source=<mode>`, `prover_delta=empty`, and
`resolved_from=<gateway|sandbox>` as unmapped fields, with message text
`"auto-approved: no new prover findings"` — never `safe`. The opt-in gate
preserves OpenShell's default-deny posture: with no setting at either
scope, every proposal lands in `pending` for human review, even
when the prover sees no findings.
4. **Implicit supersede.** On any successful submission, the gateway scans
the sandbox's pending chunks for matches on `(host, port, binary)` and
auto-rejects the older ones with reason `"superseded by chunk X"`. This
@@ -145,6 +160,30 @@ through the proposal loop instead of treating the denial as terminal.
policy.
6. **Escalation.** Anything else lands in `pending` for human review.
### Security-notes gate
Separately from the prover, each chunk carries advisory `security_notes`.
Reads, bulk approval, and auto-approval regenerate them from the current
stored proposed rule instead of trusting a persisted value that may be stale
after an edit. Non-empty notes block auto-approval and make
`ApproveAllDraftChunks` skip the chunk unless `include_security_flagged` is
set. The chunk stays `pending`; an explicit human approval can still merge a
flagged chunk.
Private/internal destinations are advisory, not blocking. A literal endpoint
IP, `allowed_ips` entry, or CIDR intersection in RFC 1918, CGNAT
`100.64.0.0/10`, IPv6 ULA `fc00::/7`, or another special-use range covered by
`openshell-core` `net::is_internal_net` produces a note. A hostless rule
carrying `allowed_ips` earns an extra note because it can match any hostname
resolving into the range.
Always-blocked destinations are separate from this advisory classification.
Loopback, link-local, and unspecified IPs/CIDRs, plus `localhost` and known
metadata endpoint hostnames, are excluded from security notes. Submit and edit
may store such a draft, but existing merge validation rejects it when an
approval attempts to add it to policy; runtime SSRF protections remain the
final enforcement boundary.
## What the prover decides
The prover answers four formal questions about each proposed policy
+69 -34
View File
@@ -275,10 +275,14 @@ pub fn load_active_gateway() -> Option<String> {
}
/// Save the last-used sandbox name for a gateway to persistent storage.
pub fn save_last_sandbox(gateway: &str, sandbox: &str) -> Result<()> {
///
/// The workspace is stored alongside the name so that `load_last_sandbox`
/// only returns a sandbox that belongs to the requested workspace.
pub fn save_last_sandbox(gateway: &str, workspace: &str, sandbox: &str) -> Result<()> {
let path = last_sandbox_path(gateway)?;
ensure_parent_dir_restricted(&path)?;
std::fs::write(&path, sandbox)
let content = format!("{workspace}\n{sandbox}");
std::fs::write(&path, content)
.into_diagnostic()
.wrap_err_with(|| format!("failed to write last sandbox to {}", path.display()))?;
Ok(())
@@ -286,20 +290,31 @@ pub fn save_last_sandbox(gateway: &str, sandbox: &str) -> Result<()> {
/// Load the last-used sandbox name for a gateway from persistent storage.
///
/// Returns `None` if no last sandbox has been set.
pub fn load_last_sandbox(gateway: &str) -> Option<String> {
last_sandbox_path(gateway)
/// Returns the sandbox name only if the stored workspace matches the
/// requested workspace. Returns `None` if no last sandbox has been set
/// or the workspace does not match.
pub fn load_last_sandbox(gateway: &str, workspace: &str) -> Option<String> {
let content = last_sandbox_path(gateway)
.ok()
.as_deref()
.and_then(read_nonempty_trimmed)
.and_then(read_nonempty_trimmed)?;
let mut lines = content.lines();
let stored_workspace = lines.next()?.trim();
let sandbox = lines.next().map(str::trim).filter(|s| !s.is_empty())?;
if stored_workspace == workspace {
Some(sandbox.to_string())
} else {
None
}
}
/// Clear the last-used sandbox record for a gateway if it matches the given name.
/// Clear the last-used sandbox record for a gateway if it matches the given
/// workspace and name.
///
/// This should be called after a sandbox is deleted so that subsequent commands
/// don't try to connect to a sandbox that no longer exists.
pub fn clear_last_sandbox_if_matches(gateway: &str, sandbox: &str) {
if let Some(current) = load_last_sandbox(gateway)
pub fn clear_last_sandbox_if_matches(gateway: &str, workspace: &str, sandbox: &str) {
if let Some(current) = load_last_sandbox(gateway, workspace)
&& current == sandbox
&& let Ok(path) = last_sandbox_path(gateway)
{
@@ -507,8 +522,11 @@ mod tests {
fn save_and_load_last_sandbox_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
with_tmp_xdg(tmp.path(), || {
save_last_sandbox("mygateway", "dev-box").unwrap();
assert_eq!(load_last_sandbox("mygateway"), Some("dev-box".to_string()));
save_last_sandbox("mygateway", "default", "dev-box").unwrap();
assert_eq!(
load_last_sandbox("mygateway", "default"),
Some("dev-box".to_string())
);
});
}
@@ -516,7 +534,7 @@ mod tests {
fn load_last_sandbox_returns_none_when_not_set() {
let tmp = tempfile::tempdir().unwrap();
with_tmp_xdg(tmp.path(), || {
assert_eq!(load_last_sandbox("no-such-gateway"), None);
assert_eq!(load_last_sandbox("no-such-gateway", "default"), None);
});
}
@@ -524,9 +542,12 @@ mod tests {
fn save_last_sandbox_overwrites_previous() {
let tmp = tempfile::tempdir().unwrap();
with_tmp_xdg(tmp.path(), || {
save_last_sandbox("g1", "first").unwrap();
save_last_sandbox("g1", "second").unwrap();
assert_eq!(load_last_sandbox("g1"), Some("second".to_string()));
save_last_sandbox("g1", "default", "first").unwrap();
save_last_sandbox("g1", "default", "second").unwrap();
assert_eq!(
load_last_sandbox("g1", "default"),
Some("second".to_string())
);
});
}
@@ -534,24 +555,22 @@ mod tests {
fn save_last_sandbox_creates_parent_dirs() {
let tmp = tempfile::tempdir().unwrap();
with_tmp_xdg(tmp.path(), || {
// The gateway subdir doesn't exist yet — save should create it.
save_last_sandbox("brand-new-gateway", "sb1").unwrap();
save_last_sandbox("brand-new-gateway", "default", "sb1").unwrap();
assert_eq!(
load_last_sandbox("brand-new-gateway"),
load_last_sandbox("brand-new-gateway", "default"),
Some("sb1".to_string())
);
});
}
#[test]
fn load_last_sandbox_ignores_whitespace() {
fn load_last_sandbox_returns_none_for_legacy_single_line_file() {
let tmp = tempfile::tempdir().unwrap();
with_tmp_xdg(tmp.path(), || {
// Write the file manually with surrounding whitespace.
let path = last_sandbox_path("ws-gateway").unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, " my-sb \n").unwrap();
assert_eq!(load_last_sandbox("ws-gateway"), Some("my-sb".to_string()));
assert_eq!(load_last_sandbox("ws-gateway", "default"), None);
});
}
@@ -562,7 +581,7 @@ mod tests {
let path = last_sandbox_path("empty-gateway").unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, " \n").unwrap();
assert_eq!(load_last_sandbox("empty-gateway"), None);
assert_eq!(load_last_sandbox("empty-gateway", "default"), None);
});
}
@@ -570,19 +589,32 @@ mod tests {
fn last_sandbox_is_per_gateway() {
let tmp = tempfile::tempdir().unwrap();
with_tmp_xdg(tmp.path(), || {
save_last_sandbox("gateway-a", "sandbox-a").unwrap();
save_last_sandbox("gateway-b", "sandbox-b").unwrap();
save_last_sandbox("gateway-a", "default", "sandbox-a").unwrap();
save_last_sandbox("gateway-b", "default", "sandbox-b").unwrap();
assert_eq!(
load_last_sandbox("gateway-a"),
load_last_sandbox("gateway-a", "default"),
Some("sandbox-a".to_string())
);
assert_eq!(
load_last_sandbox("gateway-b"),
load_last_sandbox("gateway-b", "default"),
Some("sandbox-b".to_string())
);
});
}
#[test]
fn last_sandbox_is_workspace_scoped() {
let tmp = tempfile::tempdir().unwrap();
with_tmp_xdg(tmp.path(), || {
save_last_sandbox("gw", "alpha", "sb-alpha").unwrap();
assert_eq!(
load_last_sandbox("gw", "alpha"),
Some("sb-alpha".to_string())
);
assert_eq!(load_last_sandbox("gw", "beta"), None);
});
}
// ── system gateway dir fallback ───────────────────────────────────
/// Helper: hold the shared XDG test lock, point `XDG_CONFIG_HOME` at
@@ -635,9 +667,12 @@ mod tests {
with_tmp_xdg_and_system(user.path(), system.path(), || {
write_system_metadata(&system.path().join("gateways"), "shared", "https://system");
save_last_sandbox("shared", "sb-123").unwrap();
save_last_sandbox("shared", "default", "sb-123").unwrap();
assert_eq!(load_last_sandbox("shared"), Some("sb-123".to_string()));
assert_eq!(
load_last_sandbox("shared", "default"),
Some("sb-123".to_string())
);
assert_eq!(
gateway_metadata_source("shared").unwrap(),
Some(GatewayMetadataSource::System)
@@ -671,12 +706,12 @@ mod tests {
.to_path_buf();
assert!(!user_gateway_dir.exists());
save_last_sandbox("shared", "sb-123").unwrap();
save_last_sandbox("shared", "default", "sb-123").unwrap();
assert!(user_gateway_dir.is_dir());
assert_eq!(
std::fs::read_to_string(user_gateway_dir.join("last_sandbox")).unwrap(),
"sb-123"
"default\nsb-123"
);
assert!(!user_gateway_dir.join("metadata.json").exists());
assert_eq!(
@@ -692,11 +727,11 @@ mod tests {
let system = tempfile::tempdir().unwrap();
with_tmp_xdg_and_system(user.path(), system.path(), || {
write_system_metadata(&system.path().join("gateways"), "shared", "https://system");
save_last_sandbox("shared", "sb-123").unwrap();
save_last_sandbox("shared", "default", "sb-123").unwrap();
clear_last_sandbox_if_matches("shared", "sb-123");
clear_last_sandbox_if_matches("shared", "default", "sb-123");
assert_eq!(load_last_sandbox("shared"), None);
assert_eq!(load_last_sandbox("shared", "default"), None);
let gateways = list_gateways_with_source().unwrap();
assert_eq!(gateways.len(), 1);
assert_eq!(gateways[0].metadata.name, "shared");
@@ -924,7 +959,7 @@ mod tests {
};
assert!(store_gateway_metadata("../escape", &meta).is_err());
assert!(load_gateway_metadata("../escape").is_err());
assert!(save_last_sandbox("../escape", "sb-123").is_err());
assert!(save_last_sandbox("../escape", "default", "sb-123").is_err());
assert!(save_active_gateway("../escape").is_err());
});
}
+950
View File
@@ -0,0 +1,950 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Shared helpers, types, and parsing utilities used across CLI command groups.
use chrono::DateTime;
use dialoguer::{Confirm, theme::ColorfulTheme};
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use miette::{IntoDiagnostic, Result};
use openshell_core::progress::{
PROGRESS_ACTIVE_DETAIL_KEY, PROGRESS_ACTIVE_STEP_KEY, PROGRESS_COMPLETE_LABEL_KEY,
PROGRESS_COMPLETE_STEP_KEY, PROGRESS_STEP_PULLING_IMAGE, PROGRESS_STEP_REQUESTING_SANDBOX,
PROGRESS_STEP_STARTING_SANDBOX,
};
use openshell_core::proto::{
PlatformEvent, SandboxPhase, SandboxPolicy, SettingValue, setting_value,
};
use openshell_core::settings::{self, SettingValueKind};
use owo_colors::OwoColorize;
use std::collections::HashMap;
use std::io::IsTerminal;
use std::process::Command;
use std::time::{Duration, Instant};
// ---------------------------------------------------------------------------
// View types
// ---------------------------------------------------------------------------
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PolicyGetView {
Metadata,
Base,
Full,
}
impl PolicyGetView {
pub fn from_flags(base: bool, full: bool) -> Self {
match (base, full) {
(true, _) => Self::Base,
(false, true) => Self::Full,
(false, false) => Self::Metadata,
}
}
pub(crate) fn includes_policy(self) -> bool {
matches!(self, Self::Base | Self::Full)
}
}
// ---------------------------------------------------------------------------
// Formatting / display helpers
// ---------------------------------------------------------------------------
/// Convert a sandbox phase integer to a human-readable string.
pub fn phase_name(phase: i32) -> &'static str {
match SandboxPhase::try_from(phase) {
Ok(SandboxPhase::Unspecified) => "Unspecified",
Ok(SandboxPhase::Provisioning) => "Provisioning",
Ok(SandboxPhase::Ready) => "Ready",
Ok(SandboxPhase::Error) => "Error",
Ok(SandboxPhase::Deleting) => "Deleting",
Ok(SandboxPhase::Unknown) | Err(_) => "Unknown",
}
}
/// Format milliseconds since Unix epoch as a `YYYY-MM-DD HH:MM:SS` UTC string.
pub fn format_epoch_ms(ms: i64) -> String {
use std::time::UNIX_EPOCH;
let Ok(ms_u64) = u64::try_from(ms) else {
return "-".to_string();
};
let Ok(time) = UNIX_EPOCH
.checked_add(Duration::from_millis(ms_u64))
.ok_or(())
else {
return "-".to_string();
};
let Ok(dur) = time.duration_since(UNIX_EPOCH) else {
return "-".to_string();
};
let secs = dur.as_secs();
let days = secs / 86400;
let time_of_day = secs % 86400;
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let seconds = time_of_day % 60;
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02} {hours:02}:{minutes:02}:{seconds:02}")
}
/// Convert days since 1970-01-01 to (year, month, day).
/// Algorithm from Howard Hinnant's `chrono`-compatible date library.
fn civil_from_days(days: u64) -> (i64, u64, u64) {
let z = days.cast_signed() + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097).cast_unsigned();
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe.cast_signed() + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
pub fn format_optional_epoch_ms(ms: i64) -> String {
if ms > 0 {
format_epoch_ms(ms)
} else {
"-".to_string()
}
}
/// Format a duration as a compact elapsed time string, e.g. `(3s)` or `(1m 12s)`.
pub fn format_elapsed(d: Duration) -> String {
let secs = d.as_secs();
if secs < 60 {
format!("({secs}s)")
} else {
let mins = secs / 60;
let rem = secs % 60;
format!("({mins}m {rem}s)")
}
}
/// Format a total elapsed time for non-interactive mode timestamps.
pub fn format_timestamp(d: Duration) -> String {
let secs = d.as_secs_f64();
format!("[{secs:.1}s]")
}
/// Format a millisecond timestamp into a readable string.
pub fn format_timestamp_ms(ms: i64) -> String {
if ms <= 0 {
return "-".to_string();
}
let secs = ms / 1000;
let mins = (secs / 60) % 60;
let hours = (secs / 3600) % 24;
let days = secs / 86400;
if days > 0 {
format!("{days}d {hours:02}:{mins:02}")
} else {
format!("{hours:02}:{mins:02}")
}
}
pub fn truncate_status_field(value: &str, max_chars: usize) -> String {
if value.is_empty() {
return "-".to_string();
}
let mut chars = value.chars();
let truncated = chars.by_ref().take(max_chars).collect::<String>();
if chars.next().is_some() {
format!("{truncated}...")
} else {
truncated
}
}
pub fn truncate_display(value: &str, max_width: usize) -> String {
if value.chars().count() <= max_width {
return value.to_string();
}
let keep = max_width.saturating_sub(3);
let mut truncated = value.chars().take(keep).collect::<String>();
truncated.push_str("...");
truncated
}
pub fn short_hash(hash: &str) -> &str {
if hash.len() >= 12 { &hash[..12] } else { hash }
}
pub fn non_empty_or<'a>(value: &'a str, fallback: &'a str) -> &'a str {
if value.is_empty() { fallback } else { value }
}
pub fn format_setting_value(value: Option<&SettingValue>) -> String {
let Some(value) = value.and_then(|v| v.value.as_ref()) else {
return "<unset>".to_string();
};
match value {
setting_value::Value::StringValue(v) => v.clone(),
setting_value::Value::BoolValue(v) => v.to_string(),
setting_value::Value::IntValue(v) => v.to_string(),
setting_value::Value::BytesValue(v) => format!("<bytes:{}>", v.len()),
}
}
// ---------------------------------------------------------------------------
// YAML / policy display
// ---------------------------------------------------------------------------
pub fn print_yaml_line(line: &str) {
let trimmed = line.trim_start();
let indent = &line[..line.len() - trimmed.len()];
if let Some(rest) = trimmed.strip_prefix("- ") {
print!("{indent}");
print!("{}", "- ".dimmed());
print!("{rest}");
println!();
return;
}
if let Some(colon_pos) = trimmed.find(':') {
let key = &trimmed[..colon_pos];
let after_colon = &trimmed[colon_pos + 1..];
print!("{indent}");
print!("{}", key.dimmed());
print!("{}", ":".dimmed());
if after_colon.is_empty() {
// Key with nested content (no value on this line)
} else if let Some(value) = after_colon.strip_prefix(' ') {
print!(" {value}");
} else {
print!("{after_colon}");
}
println!();
return;
}
println!("{line}");
}
/// Print sandbox policy as YAML with dimmed keys.
pub fn print_sandbox_policy(policy: &SandboxPolicy) {
println!("{}", "Policy:".cyan().bold());
println!();
if let Ok(yaml_str) = openshell_policy::serialize_sandbox_policy(policy) {
for line in yaml_str.lines() {
if line == "---" {
continue;
}
print!(" ");
print_yaml_line(line);
}
}
}
pub fn print_policy_merge_warnings(warnings: &[openshell_policy::PolicyMergeWarning]) {
for warning in warnings {
eprintln!("{} {}", "!".yellow().bold(), warning);
}
}
// ---------------------------------------------------------------------------
// Provisioning display
// ---------------------------------------------------------------------------
/// Known provisioning steps derived from Kubernetes events and sandbox lifecycle.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ProvisioningStep {
/// Sandbox CRD created, waiting for pod to be scheduled.
RequestingSandbox,
/// Pulling the sandbox container image.
PullingSandboxImage,
/// Container is starting up.
StartingSandbox,
}
impl ProvisioningStep {
/// Human-readable label for a completed step.
pub fn completed_label(self) -> &'static str {
match self {
Self::RequestingSandbox => "Sandbox allocated",
Self::PullingSandboxImage => "Image pulled",
Self::StartingSandbox => "Sandbox ready",
}
}
/// Human-readable label for an in-progress step (shown on the spinner).
pub fn active_label(self) -> &'static str {
match self {
Self::RequestingSandbox => "Requesting sandbox...",
Self::PullingSandboxImage => "Pulling image...",
Self::StartingSandbox => "Starting sandbox...",
}
}
}
/// Live-updating display showing a provisioning step checklist with spinner.
///
/// Completed steps are printed as static `✓ Step` lines. The current
/// in-progress step is shown on a spinner with elapsed time.
pub struct ProvisioningDisplay {
mp: MultiProgress,
spinner: ProgressBar,
/// Blank line below the spinner so progress doesn't sit flush against
/// the bottom of the terminal.
spacer: ProgressBar,
/// Steps that have been completed, in order.
completed_steps: Vec<ProvisioningStep>,
/// Progress bars for completed steps (so they can be cleared).
completed_bars: Vec<ProgressBar>,
/// The currently active step label (shown on the spinner).
active_label: String,
/// Detail text shown next to the active step (e.g. image name).
active_detail: String,
/// When the current active step started (for elapsed time).
step_start: Instant,
}
impl ProvisioningDisplay {
pub fn new() -> Self {
let mp = MultiProgress::new();
let spinner = mp.add(ProgressBar::new_spinner());
spinner.set_style(
ProgressStyle::with_template("{spinner:.cyan} {msg} ({elapsed})")
.unwrap_or_else(|_| ProgressStyle::default_spinner()),
);
spinner.enable_steady_tick(Duration::from_millis(120));
let spacer = mp.add(ProgressBar::new(0));
spacer.set_style(
ProgressStyle::with_template("{msg}").unwrap_or_else(|_| ProgressStyle::default_bar()),
);
spacer.set_message("");
let now = Instant::now();
Self {
mp,
spinner,
spacer,
completed_steps: Vec::new(),
completed_bars: Vec::new(),
active_label: ProvisioningStep::RequestingSandbox
.active_label()
.to_string(),
active_detail: String::new(),
step_start: now,
}
}
/// Record a completed provisioning step with a custom label.
pub fn complete_step_with_label(&mut self, step: ProvisioningStep, label: &str) {
if self.completed_steps.contains(&step) {
return;
}
self.completed_steps.push(step);
let elapsed = self.step_start.elapsed();
let elapsed_str = format_elapsed(elapsed);
let bar = self.mp.insert_before(&self.spinner, ProgressBar::new(0));
bar.set_style(
ProgressStyle::with_template("{msg}").unwrap_or_else(|_| ProgressStyle::default_bar()),
);
bar.set_message(format!(
"{} {} {}",
"\u{2713}".green().bold(),
label,
elapsed_str.dimmed()
));
bar.finish();
self.completed_bars.push(bar);
self.step_start = Instant::now();
self.spinner.reset_elapsed();
self.active_detail.clear();
}
/// Set the active (in-progress) step shown on the spinner.
pub fn set_active(&mut self, label: &str) {
self.active_label = label.to_string();
self.active_detail.clear();
self.spinner.reset_elapsed();
self.step_start = Instant::now();
self.update_spinner();
}
/// Set the active step from a known provisioning step enum.
pub fn set_active_step(&mut self, step: ProvisioningStep) {
self.set_active(step.active_label());
}
/// Set detail text shown alongside the active step (e.g. image name).
pub fn set_active_detail(&mut self, detail: &str) {
self.active_detail = detail.to_string();
self.update_spinner();
}
fn update_spinner(&self) {
let msg = if self.active_detail.is_empty() {
self.active_label.clone()
} else {
format!("{} {}", self.active_label, self.active_detail.dimmed())
};
self.spinner.set_message(msg);
}
/// Finish with an error message shown on the last step line.
pub fn finish_error(&self, msg: &str) {
let _ = self
.mp
.println(format!("{} {}", "\u{2717}".red().bold(), msg.red()));
self.spinner.finish_and_clear();
}
/// Print a line above the progress bars (for static header content).
pub fn println(&self, msg: &str) {
let _ = self.mp.println(msg);
}
/// Clear all progress output (spinner, spacer, and completed step lines).
pub fn clear(&self) {
self.spacer.finish_and_clear();
self.spinner.finish_and_clear();
for bar in &self.completed_bars {
bar.finish_and_clear();
}
}
}
// ---------------------------------------------------------------------------
// Provisioning progress event handling
// ---------------------------------------------------------------------------
pub fn progress_step_from_metadata(value: &str) -> Option<ProvisioningStep> {
match value {
PROGRESS_STEP_REQUESTING_SANDBOX => Some(ProvisioningStep::RequestingSandbox),
PROGRESS_STEP_PULLING_IMAGE => Some(ProvisioningStep::PullingSandboxImage),
PROGRESS_STEP_STARTING_SANDBOX => Some(ProvisioningStep::StartingSandbox),
_ => None,
}
}
pub fn noninteractive_active_label(step: ProvisioningStep) -> String {
step.active_label().trim_end_matches('.').to_string()
}
pub fn handle_platform_progress_event(
event: &PlatformEvent,
display: &mut Option<ProvisioningDisplay>,
provision_start: Instant,
) -> bool {
let completed_step = event
.metadata
.get(PROGRESS_COMPLETE_STEP_KEY)
.and_then(|step| progress_step_from_metadata(step));
let active_step = event
.metadata
.get(PROGRESS_ACTIVE_STEP_KEY)
.and_then(|step| progress_step_from_metadata(step));
let active_detail = event
.metadata
.get(PROGRESS_ACTIVE_DETAIL_KEY)
.filter(|detail| !detail.is_empty());
let handled = completed_step.is_some() || active_step.is_some() || active_detail.is_some();
if !handled {
return false;
}
if let Some(step) = completed_step {
let label = event
.metadata
.get(PROGRESS_COMPLETE_LABEL_KEY)
.map_or_else(|| step.completed_label(), String::as_str);
if let Some(d) = display.as_mut() {
d.complete_step_with_label(step, label);
} else {
let ts = format_timestamp(provision_start.elapsed());
println!("{} {}", ts.dimmed(), label);
}
}
if let Some(step) = active_step
&& let Some(d) = display.as_mut()
{
d.set_active_step(step);
}
if let Some(detail) = active_detail {
if let Some(d) = display.as_mut() {
d.set_active_detail(detail);
} else {
let ts = format_timestamp(provision_start.elapsed());
if let Some(step) = active_step {
println!(
"{} {} {}",
ts.dimmed(),
noninteractive_active_label(step),
detail
);
} else {
println!("{} {}", ts.dimmed(), detail);
}
}
}
true
}
pub fn is_provisioning_progress_event(event: &PlatformEvent) -> bool {
if event.metadata.contains_key(PROGRESS_COMPLETE_STEP_KEY)
|| event.metadata.contains_key(PROGRESS_ACTIVE_STEP_KEY)
|| event.metadata.contains_key(PROGRESS_ACTIVE_DETAIL_KEY)
{
return true;
}
event.source == "vm"
&& matches!(
event.reason.as_str(),
"PullingLayer"
| "ResolvingImage"
| "AuthenticatingRegistry"
| "FetchingManifest"
| "CacheHit"
| "CacheMiss"
| "WaitingForImageCacheLock"
| "ExportingRootfs"
| "PreparingRootfs"
| "CreatingRootDisk"
| "PreparingOverlay"
| "Started"
)
}
// ---------------------------------------------------------------------------
// Sandbox header
// ---------------------------------------------------------------------------
pub fn print_sandbox_header(
sandbox: &openshell_core::proto::Sandbox,
display: Option<&ProvisioningDisplay>,
) {
use openshell_core::ObjectName;
let lines = [
String::new(),
format!(
"{} {}",
"Created sandbox:".cyan().bold(),
sandbox.object_name().bold()
),
String::new(),
];
match display {
Some(d) => {
for line in lines {
d.println(&line);
}
}
None => {
for line in lines {
println!("{line}");
}
}
}
}
// ---------------------------------------------------------------------------
// Sandbox provisioning helpers
// ---------------------------------------------------------------------------
pub fn ready_false_condition_message(
status: Option<&openshell_core::proto::SandboxStatus>,
) -> Option<String> {
let condition = status?.conditions.iter().find(|condition| {
condition.r#type == "Ready" && condition.status.eq_ignore_ascii_case("false")
})?;
if condition.message.is_empty() {
if condition.reason.is_empty() {
None
} else {
Some(condition.reason.clone())
}
} else if condition.reason.is_empty() {
Some(condition.message.clone())
} else {
Some(format!("{}: {}", condition.reason, condition.message))
}
}
pub fn provisioning_timeout_message(
timeout_secs: u64,
resource_requirements: Option<&openshell_core::proto::ResourceRequirements>,
condition_message: Option<&str>,
) -> String {
let mut message = format!("sandbox provisioning timed out after {timeout_secs}s");
if let Some(condition_message) = condition_message.filter(|msg| !msg.is_empty()) {
message.push_str(". Last reported status: ");
message.push_str(condition_message);
}
if resource_requirements.is_some_and(|requirements| requirements.gpu.is_some()) {
message.push_str(
". Hint: this may be because the available GPU is already in use by another sandbox.",
);
}
message
}
// ---------------------------------------------------------------------------
// Settings helpers
// ---------------------------------------------------------------------------
pub fn parse_cli_setting_value(key: &str, raw_value: &str) -> Result<SettingValue> {
let setting = settings::setting_for_key(key).ok_or_else(|| {
miette::miette!(
"unknown setting key '{}'. Allowed keys: {}",
key,
settings::registered_keys_csv()
)
})?;
let value = match setting.kind {
SettingValueKind::String => {
// Reject typos client-side so `openshell settings set ...
// proposal_approval_mode autom` errors immediately instead of
// round-tripping through the server. The server enforces the
// same check independently for non-CLI callers.
setting
.validate_string_value(raw_value)
.map_err(|allowed| {
miette::miette!(
"invalid value '{}' for key '{}'; expected one of: {}",
raw_value,
key,
allowed.join(", ")
)
})?;
setting_value::Value::StringValue(raw_value.to_string())
}
SettingValueKind::Int => {
let parsed = raw_value.trim().parse::<i64>().map_err(|_| {
miette::miette!(
"invalid int value '{}' for key '{}'; expected base-10 integer",
raw_value,
key
)
})?;
setting_value::Value::IntValue(parsed)
}
SettingValueKind::Bool => {
let parsed = settings::parse_bool_like(raw_value).ok_or_else(|| {
miette::miette!(
"invalid bool value '{}' for key '{}'; expected one of: true,false,yes,no,1,0",
raw_value,
key
)
})?;
setting_value::Value::BoolValue(parsed)
}
};
Ok(SettingValue { value: Some(value) })
}
pub fn confirm_global_setting_takeover(key: &str, yes: bool) -> Result<()> {
if yes {
return Ok(());
}
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
return Err(miette::miette!(
"global setting updates require confirmation; pass --yes in non-interactive mode"
));
}
let proceed = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!(
"Setting '{key}' globally will disable sandbox-level management for this key. Continue?"
))
.default(false)
.interact()
.into_diagnostic()?;
if !proceed {
return Err(miette::miette!("aborted by user"));
}
Ok(())
}
pub fn confirm_global_setting_delete(key: &str, yes: bool) -> Result<()> {
if yes {
return Ok(());
}
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
return Err(miette::miette!(
"global setting deletes require confirmation; pass --yes in non-interactive mode"
));
}
let proceed = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!(
"Deleting global setting '{key}' re-enables sandbox-level management for this key. Continue?"
))
.default(false)
.interact()
.into_diagnostic()?;
if !proceed {
return Err(miette::miette!("aborted by user"));
}
Ok(())
}
/// Parse a duration string like "5m", "1h", "30s" into milliseconds.
pub fn parse_duration_to_ms(s: &str) -> Result<i64> {
let s = s.trim();
if s.is_empty() {
return Err(miette::miette!("empty duration string"));
}
let (num_str, unit) = s.split_at(s.len() - 1);
let num: i64 = num_str
.parse()
.map_err(|_| miette::miette!("invalid duration: {s} (expected e.g. 5m, 1h, 30s)"))?;
let multiplier = match unit {
"s" => 1_000,
"m" => 60_000,
"h" => 3_600_000,
_ => {
return Err(miette::miette!(
"unknown duration unit: {unit} (use s, m, or h)"
));
}
};
Ok(num * multiplier)
}
// ---------------------------------------------------------------------------
// Parsing utilities
// ---------------------------------------------------------------------------
pub fn parse_key_value_pairs(items: &[String], flag: &str) -> Result<HashMap<String, String>> {
let mut map = HashMap::new();
for item in items {
let Some((key, value)) = item.split_once('=') else {
return Err(miette::miette!("{flag} expects KEY=VALUE, got '{item}'"));
};
let key = key.trim();
if key.is_empty() {
return Err(miette::miette!("{flag} key cannot be empty"));
}
map.insert(key.to_string(), value.to_string());
}
Ok(map)
}
pub fn parse_env_pairs(items: &[String]) -> Result<HashMap<String, String>> {
let map = parse_key_value_pairs(items, "--env")?;
for key in map.keys() {
if !is_valid_env_name(key) {
return Err(miette::miette!(
"--env key must match [A-Za-z_][A-Za-z0-9_]*; got '{key}'"
));
}
if key.starts_with("OPENSHELL_") {
return Err(miette::miette!(
"--env keys starting with OPENSHELL_ are reserved; got '{key}'"
));
}
}
Ok(map)
}
/// Resolve `--secret-material-env KEY[=ENVVAR]` values from the CLI process
/// environment (`ENVVAR` defaults to `KEY`) so secrets never transit argv.
pub fn parse_secret_material_env_pairs(items: &[String]) -> Result<HashMap<String, String>> {
let mut map = HashMap::new();
for item in items {
let (key, env_name) = match item.split_once('=') {
Some((key, env_name)) => (key.trim(), env_name.trim()),
None => (item.trim(), item.trim()),
};
if key.is_empty() {
return Err(miette::miette!("--secret-material-env key cannot be empty"));
}
if env_name.is_empty() {
return Err(miette::miette!(
"--secret-material-env {key} names an empty environment variable"
));
}
let value = std::env::var(env_name).map_err(|_| {
miette::miette!(
"--secret-material-env {key} requires local env var '{env_name}' to be set to a non-empty value"
)
})?;
if value.trim().is_empty() {
return Err(miette::miette!(
"--secret-material-env {key} requires local env var '{env_name}' to be set to a non-empty value"
));
}
if map.contains_key(key) {
return Err(miette::miette!(
"--secret-material-env key '{key}' supplied more than once"
));
}
map.insert(key.to_string(), value);
}
Ok(map)
}
pub fn is_valid_env_name(key: &str) -> bool {
let mut bytes = key.bytes();
let Some(first) = bytes.next() else {
return false;
};
if !(first == b'_' || first.is_ascii_alphabetic()) {
return false;
}
bytes.all(|b| b == b'_' || b.is_ascii_alphanumeric())
}
pub fn parse_credential_pairs(items: &[String]) -> Result<HashMap<String, String>> {
let mut map = HashMap::new();
for item in items {
if let Some((key, value)) = item.split_once('=') {
let key = key.trim();
if key.is_empty() {
return Err(miette::miette!("--credential key cannot be empty"));
}
map.insert(key.to_string(), value.to_string());
continue;
}
let key = item.trim();
if key.is_empty() {
return Err(miette::miette!("--credential key cannot be empty"));
}
let value = std::env::var(key).map_err(|_| {
miette::miette!(
"--credential {key} requires local env var '{key}' to be set to a non-empty value"
)
})?;
if value.trim().is_empty() {
return Err(miette::miette!(
"--credential {key} requires local env var '{key}' to be set to a non-empty value"
));
}
map.insert(key.to_string(), value);
}
Ok(map)
}
pub fn parse_credential_expiry_cli_value(value: &str) -> std::result::Result<i64, String> {
parse_credential_expiry_value(value, None).map_err(|err| err.to_string())
}
fn credential_expiry_value_error(key: Option<&str>, detail: &str) -> miette::Report {
key.map_or_else(
|| miette::miette!("--credential-expires-at value {detail}"),
|key| miette::miette!("--credential-expires-at value for '{key}' {detail}"),
)
}
pub fn parse_credential_expiry_value(value: &str, key: Option<&str>) -> Result<i64> {
let value = value.trim();
if value.is_empty() {
return Err(credential_expiry_value_error(key, "cannot be empty"));
}
if let Ok(value_ms) = value.parse::<i64>() {
if value_ms < 0 {
return Err(credential_expiry_value_error(
key,
"must be greater than or equal to 0",
));
}
return Ok(value_ms);
}
let parsed = DateTime::parse_from_rfc3339(value).map_err(|_| {
credential_expiry_value_error(
key,
"must be a Unix epoch millisecond timestamp or RFC3339 timestamp",
)
})?;
let value_ms = parsed.timestamp_millis();
if value_ms < 0 {
return Err(credential_expiry_value_error(
key,
"must be greater than or equal to 0",
));
}
Ok(value_ms)
}
pub fn parse_credential_expiry_pairs(items: &[String]) -> Result<HashMap<String, i64>> {
let mut map = HashMap::new();
for item in items {
let Some((key, value)) = item.split_once('=') else {
return Err(miette::miette!(
"--credential-expires-at expects KEY=TIMESTAMP, got '{item}'"
));
};
let key = key.trim();
if key.is_empty() {
return Err(miette::miette!(
"--credential-expires-at key cannot be empty"
));
}
let value = parse_credential_expiry_value(value, Some(key))?;
map.insert(key.to_string(), value);
}
Ok(map)
}
// ---------------------------------------------------------------------------
// Git environment helpers
// ---------------------------------------------------------------------------
pub fn scrub_git_env(command: &mut Command) -> &mut Command {
for key in [
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_PREFIX",
"GIT_COMMON_DIR",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
] {
command.env_remove(key);
}
command
}
+4
View File
@@ -0,0 +1,4 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
pub mod common;
+43 -1
View File
@@ -11,7 +11,7 @@ use openshell_bootstrap::{list_gateways, load_active_gateway, load_gateway_metad
use openshell_core::ObjectName;
use openshell_core::auth::EdgeAuthInterceptor;
use openshell_core::proto::open_shell_client::OpenShellClient;
use openshell_core::proto::{ListProvidersRequest, ListSandboxesRequest};
use openshell_core::proto::{ListProvidersRequest, ListSandboxesRequest, ListWorkspacesRequest};
use tonic::service::interceptor::InterceptedService;
use tonic::transport::Channel;
@@ -39,6 +39,8 @@ pub fn complete_sandbox_names(_prefix: &OsStr) -> Vec<CompletionCandidate> {
limit: 200,
offset: 0,
label_selector: String::new(),
workspace: workspace_from_args(),
all_workspaces: false,
})
.await
.ok()?;
@@ -62,6 +64,8 @@ pub fn complete_provider_names(_prefix: &OsStr) -> Vec<CompletionCandidate> {
.list_providers(ListProvidersRequest {
limit: 200,
offset: 0,
workspace: workspace_from_args(),
all_workspaces: false,
})
.await
.ok()?;
@@ -76,6 +80,44 @@ pub fn complete_provider_names(_prefix: &OsStr) -> Vec<CompletionCandidate> {
})
}
/// Complete workspace names by querying the active gateway.
pub fn complete_workspace_names(_prefix: &OsStr) -> Vec<CompletionCandidate> {
blocking_complete(async {
let (endpoint, gateway_name) = resolve_active_gateway()?;
let mut client = completion_grpc_client(&endpoint, &gateway_name).await?;
let response = client
.list_workspaces(ListWorkspacesRequest {
limit: 200,
offset: 0,
label_selector: String::new(),
})
.await
.ok()?;
Some(
response
.into_inner()
.workspaces
.into_iter()
.map(|w| CompletionCandidate::new(w.object_name()))
.collect(),
)
})
}
fn workspace_from_args() -> String {
let args: Vec<String> = std::env::args().collect();
for (i, arg) in args.iter().enumerate() {
if arg == "--workspace" {
if let Some(val) = args.get(i + 1) {
return val.clone();
}
} else if let Some(val) = arg.strip_prefix("--workspace=") {
return val.to_string();
}
}
std::env::var("OPENSHELL_WORKSPACE").unwrap_or_else(|_| "default".to_string())
}
fn resolve_active_gateway() -> Option<(String, String)> {
let name = std::env::var("OPENSHELL_GATEWAY")
.ok()
+1
View File
@@ -9,6 +9,7 @@
pub(crate) static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
pub mod auth;
pub(crate) mod commands;
pub mod completers;
pub mod edge_tunnel;
pub mod oidc_auth;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+80 -27
View File
@@ -76,6 +76,7 @@ async fn ssh_session_config(
server: &str,
name: &str,
tls: &TlsOptions,
workspace: &str,
) -> Result<SshSessionConfig> {
let mut client = grpc_client(server, tls).await?;
@@ -83,6 +84,7 @@ async fn ssh_session_config(
let sandbox = client
.get_sandbox(GetSandboxRequest {
name: name.to_string(),
workspace: workspace.to_string(),
})
.await
.into_diagnostic()?
@@ -255,8 +257,9 @@ async fn sandbox_connect_with_mode(
name: &str,
tls: &TlsOptions,
replace_process: bool,
workspace: &str,
) -> Result<()> {
let session = ssh_session_config(server, name, tls).await?;
let session = ssh_session_config(server, name, tls, workspace).await?;
let mut command = ssh_base_command(&session.proxy_command);
command
@@ -278,16 +281,22 @@ async fn sandbox_connect_with_mode(
}
/// Connect to a sandbox via SSH.
pub async fn sandbox_connect(server: &str, name: &str, tls: &TlsOptions) -> Result<()> {
sandbox_connect_with_mode(server, name, tls, true).await
pub async fn sandbox_connect(
server: &str,
name: &str,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
sandbox_connect_with_mode(server, name, tls, true, workspace).await
}
pub(crate) async fn sandbox_connect_without_exec(
server: &str,
name: &str,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
sandbox_connect_with_mode(server, name, tls, false).await
sandbox_connect_with_mode(server, name, tls, false, workspace).await
}
pub async fn sandbox_connect_editor(
@@ -296,12 +305,14 @@ pub async fn sandbox_connect_editor(
name: &str,
editor: Editor,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
// Verify the sandbox exists before writing SSH config / launching the editor.
let mut client = grpc_client(server, tls).await?;
client
.get_sandbox(GetSandboxRequest {
name: name.to_string(),
workspace: workspace.to_string(),
})
.await
.into_diagnostic()?
@@ -309,8 +320,8 @@ pub async fn sandbox_connect_editor(
.sandbox
.ok_or_else(|| miette::miette!("sandbox not found: {name}"))?;
let host_alias = host_alias(name);
install_ssh_config(gateway, name)?;
let host_alias = host_alias(name, workspace);
install_ssh_config(gateway, name, workspace)?;
launch_editor(editor, &host_alias)?;
eprintln!(
"{} Opened {} for sandbox {}",
@@ -331,10 +342,11 @@ pub async fn sandbox_forward(
spec: &ForwardSpec,
background: bool,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
openshell_core::forward::check_port_available(spec)?;
let session = ssh_session_config(server, name, tls).await?;
let session = ssh_session_config(server, name, tls, workspace).await?;
let mut command = TokioCommand::from(ssh_base_command(&session.proxy_command));
command
@@ -528,12 +540,13 @@ async fn sandbox_exec_with_mode(
tty: bool,
tls: &TlsOptions,
replace_process: bool,
workspace: &str,
) -> Result<()> {
if command.is_empty() {
return Err(miette::miette!("no command provided"));
}
let session = ssh_session_config(server, name, tls).await?;
let session = ssh_session_config(server, name, tls, workspace).await?;
let mut ssh = ssh_base_command(&session.proxy_command);
if tty {
@@ -572,8 +585,9 @@ pub async fn sandbox_exec(
command: &[String],
tty: bool,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
sandbox_exec_with_mode(server, name, command, tty, tls, true).await
sandbox_exec_with_mode(server, name, command, tty, tls, true, workspace).await
}
pub(crate) async fn sandbox_exec_without_exec(
@@ -582,8 +596,9 @@ pub(crate) async fn sandbox_exec_without_exec(
command: &[String],
tty: bool,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
sandbox_exec_with_mode(server, name, command, tty, tls, false).await
sandbox_exec_with_mode(server, name, command, tty, tls, false, workspace).await
}
/// What to pack into the tar archive streamed to the sandbox.
@@ -754,8 +769,9 @@ async fn ssh_tar_upload(
dest_dir: Option<&str>,
source: UploadSource,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
let session = ssh_session_config(server, name, tls).await?;
let session = ssh_session_config(server, name, tls, workspace).await?;
// When no explicit destination is given, use the unescaped `$HOME` shell
// variable so the remote shell resolves it at runtime.
@@ -940,6 +956,7 @@ fn resolve_file_download_target(
/// Files are streamed as a tar archive to `ssh ... tar xf - -C <dest>` on
/// the sandbox side. When `dest` is `None`, files are uploaded to the
/// sandbox user's home directory.
#[allow(clippy::too_many_arguments)]
pub async fn sandbox_sync_up_files(
server: &str,
name: &str,
@@ -948,6 +965,7 @@ pub async fn sandbox_sync_up_files(
local_path: &Path,
dest: Option<&str>,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
if files.is_empty() {
return Ok(());
@@ -962,6 +980,7 @@ pub async fn sandbox_sync_up_files(
archive_prefix: file_list_archive_prefix(local_path),
},
tls,
workspace,
)
.await
}
@@ -979,6 +998,7 @@ pub async fn sandbox_sync_up(
local_path: &Path,
sandbox_path: Option<&str>,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
// When an explicit destination is given and looks like a file path (does
// not end with '/'), split into parent directory + target basename so that
@@ -1005,6 +1025,7 @@ pub async fn sandbox_sync_up(
tar_name: target_name.into(),
},
tls,
workspace,
)
.await;
}
@@ -1032,6 +1053,7 @@ pub async fn sandbox_sync_up(
tar_name,
},
tls,
workspace,
)
.await
}
@@ -1139,9 +1161,10 @@ pub async fn sandbox_sync_down(
sandbox_path: &str,
dest: &str,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
let sandbox_path = validate_sandbox_source_path(sandbox_path)?;
let session = ssh_session_config(server, name, tls).await?;
let session = ssh_session_config(server, name, tls, workspace).await?;
let sandbox_path = resolve_sandbox_source_path(&session, &sandbox_path).await?;
let kind = probe_sandbox_source_kind(&session, &sandbox_path).await?;
@@ -1417,8 +1440,13 @@ fn grpc_server_from_ssh_gateway_url(gateway_url: &str) -> Result<String> {
/// and sandbox name instead of pre-created gateway/token credentials. It is
/// suitable for use as an SSH `ProxyCommand` in `~/.ssh/config` because it
/// creates a fresh session on every invocation.
pub async fn sandbox_ssh_proxy_by_name(server: &str, name: &str, tls: &TlsOptions) -> Result<()> {
let session = ssh_session_config(server, name, tls).await?;
pub async fn sandbox_ssh_proxy_by_name(
server: &str,
name: &str,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
let session = ssh_session_config(server, name, tls, workspace).await?;
sandbox_ssh_proxy(
&session.gateway_url,
&session.sandbox_id,
@@ -1428,20 +1456,21 @@ pub async fn sandbox_ssh_proxy_by_name(server: &str, name: &str, tls: &TlsOption
.await
}
fn host_alias(name: &str) -> String {
format!("openshell-{name}")
fn host_alias(name: &str, workspace: &str) -> String {
format!("openshell-{name}.{workspace}")
}
fn render_ssh_config(gateway: &str, name: &str) -> String {
fn render_ssh_config(gateway: &str, name: &str, workspace: &str) -> String {
let exe = std::env::current_exe().expect("failed to resolve OpenShell executable");
let exe = shell_escape(&exe.to_string_lossy());
let proxy_cmd = format!(
"{exe} ssh-proxy --gateway-name {} --name {}",
"{exe} ssh-proxy --gateway-name {} --name {} --workspace {}",
shell_escape(gateway),
shell_escape(name),
shell_escape(workspace),
);
let host_alias = host_alias(name);
let host_alias = host_alias(name, workspace);
format!(
"Host {host_alias}\n User sandbox\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null\n GlobalKnownHostsFile /dev/null\n LogLevel ERROR\n ServerAliveInterval 15\n ServerAliveCountMax 3\n ProxyCommand {proxy_cmd}\n"
)
@@ -1567,7 +1596,7 @@ fn upsert_host_block(contents: &str, alias: &str, block: &str) -> String {
rendered
}
pub fn install_ssh_config(gateway: &str, name: &str) -> Result<PathBuf> {
pub fn install_ssh_config(gateway: &str, name: &str, workspace: &str) -> Result<PathBuf> {
let managed_config = openshell_ssh_config_path()?;
let main_config = user_ssh_config_path()?;
ensure_openshell_include(&main_config, &managed_config)?;
@@ -1576,8 +1605,8 @@ pub fn install_ssh_config(gateway: &str, name: &str) -> Result<PathBuf> {
openshell_core::paths::create_dir_restricted(parent)?;
}
let alias = host_alias(name);
let block = render_ssh_config(gateway, name);
let alias = host_alias(name, workspace);
let block = render_ssh_config(gateway, name, workspace);
let contents = fs::read_to_string(&managed_config).unwrap_or_default();
let updated = upsert_host_block(&contents, &alias, &block);
fs::write(&managed_config, updated)
@@ -1624,8 +1653,8 @@ fn launch_editor_command(binary: &str, label: &str, remote_target: &str) -> Resu
/// The `ProxyCommand` uses `--gateway-name` so that `ssh-proxy` resolves the
/// gateway endpoint and TLS certificates from the gateway metadata directory
/// (`~/.config/openshell/gateways/<name>/mtls/`).
pub fn print_ssh_config(gateway: &str, name: &str) {
print!("{}", render_ssh_config(gateway, name));
pub fn print_ssh_config(gateway: &str, name: &str, workspace: &str) {
print!("{}", render_ssh_config(gateway, name, workspace));
}
#[cfg(test)]
@@ -1674,8 +1703,8 @@ mod tests {
let user_config = ssh_dir.join("config");
fs::write(&user_config, "Host personal\n HostName example.com\n").unwrap();
let managed_path = install_ssh_config("openshell", "demo").unwrap();
install_ssh_config("openshell", "demo").unwrap();
let managed_path = install_ssh_config("openshell", "demo", "default").unwrap();
install_ssh_config("openshell", "demo", "default").unwrap();
let main_contents = fs::read_to_string(&user_config).unwrap();
assert!(main_contents.contains("Host personal"));
@@ -1686,7 +1715,12 @@ mod tests {
assert!(include_idx < host_idx);
let managed_contents = fs::read_to_string(&managed_path).unwrap();
assert_eq!(managed_contents.matches("Host openshell-demo").count(), 1);
assert_eq!(
managed_contents
.matches("Host openshell-demo.default")
.count(),
1
);
assert!(managed_contents.contains("ProxyCommand"));
unsafe {
@@ -1701,6 +1735,25 @@ mod tests {
}
}
#[test]
fn render_ssh_config_includes_workspace_in_proxy_command() {
let config = render_ssh_config("my-gw", "demo", "beta");
assert!(
config.contains("Host openshell-demo.beta"),
"host alias should be workspace-qualified: {config}"
);
assert!(
config.contains("--workspace beta"),
"ProxyCommand should include --workspace: {config}"
);
}
#[test]
fn host_alias_includes_workspace() {
assert_eq!(host_alias("demo", "default"), "openshell-demo.default");
assert_eq!(host_alias("demo", "beta"), "openshell-demo.beta");
}
#[test]
fn launch_editor_returns_friendly_error_when_binary_missing() {
let err = launch_editor_command(
@@ -64,11 +64,14 @@ impl TestOpenShell {
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: String::new(),
deletion_timestamp_ms: 0,
}),
r#type: provider_type.to_string(),
credentials: HashMap::new(),
config: HashMap::new(),
credential_expires_at_ms: HashMap::new(),
profile_workspace: "default".to_string(),
},
);
}
@@ -358,6 +361,8 @@ impl OpenShell for TestOpenShell {
labels: existing_metadata.labels,
resource_version: 0,
annotations: HashMap::new(),
workspace: String::new(),
deletion_timestamp_ms: 0,
}),
r#type: existing.r#type,
credentials: merge(existing.credentials, provider.credentials),
@@ -366,6 +371,7 @@ impl OpenShell for TestOpenShell {
existing.credential_expires_at_ms,
provider.credential_expires_at_ms,
),
profile_workspace: existing.profile_workspace,
};
let updated_name = updated.object_name().to_string();
providers.insert(updated_name, updated.clone());
@@ -592,6 +598,55 @@ impl OpenShell for TestOpenShell {
) -> Result<Response<Self::ForwardTcpStream>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn create_workspace(
&self,
_request: tonic::Request<openshell_core::proto::CreateWorkspaceRequest>,
) -> Result<Response<openshell_core::proto::CreateWorkspaceResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn get_workspace(
&self,
_request: tonic::Request<openshell_core::proto::GetWorkspaceRequest>,
) -> Result<Response<openshell_core::proto::GetWorkspaceResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn list_workspaces(
&self,
_request: tonic::Request<openshell_core::proto::ListWorkspacesRequest>,
) -> Result<Response<openshell_core::proto::ListWorkspacesResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn delete_workspace(
&self,
_request: tonic::Request<openshell_core::proto::DeleteWorkspaceRequest>,
) -> Result<Response<openshell_core::proto::DeleteWorkspaceResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn add_workspace_member(
&self,
_request: tonic::Request<openshell_core::proto::AddWorkspaceMemberRequest>,
) -> Result<Response<openshell_core::proto::AddWorkspaceMemberResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn remove_workspace_member(
&self,
_request: tonic::Request<openshell_core::proto::RemoveWorkspaceMemberRequest>,
) -> Result<Response<openshell_core::proto::RemoveWorkspaceMemberResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn list_workspace_members(
&self,
_request: tonic::Request<openshell_core::proto::ListWorkspaceMembersRequest>,
) -> Result<Response<openshell_core::proto::ListWorkspaceMembersResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
}
// ── test server fixture ──────────────────────────────────────────────
@@ -672,6 +727,7 @@ async fn explicit_provider_name_passes_through_when_it_exists() {
&["nvidia".to_string()],
&[],
Some(true), // --auto-providers (should not matter here)
"default",
)
.await
.expect("should succeed");
@@ -700,6 +756,7 @@ async fn explicit_provider_name_auto_creates_when_valid_type() {
&["nvidia".to_string()],
&[],
Some(true), // --auto-providers to skip interactive prompt
"default",
)
.await
.expect("should auto-create the provider");
@@ -733,6 +790,7 @@ async fn explicit_provider_name_errors_for_unrecognised_name() {
&["my-custom-thing".to_string()],
&[],
Some(true),
"default",
)
.await
.expect_err("should fail for unrecognised provider name");
@@ -764,6 +822,7 @@ async fn inferred_type_auto_creates_provider() {
&[],
&["claude-code".to_string()],
Some(true), // --auto-providers
"default",
)
.await
.expect("should auto-create the inferred provider");
@@ -793,6 +852,7 @@ async fn no_auto_providers_skips_missing_explicit_provider() {
&["nvidia".to_string()],
&[],
Some(false), // --no-auto-providers
"default",
)
.await
.expect("should succeed with empty list");
@@ -828,6 +888,7 @@ async fn explicit_and_inferred_providers_combined() {
&["nvidia".to_string()],
&["claude-code".to_string()],
Some(true),
"default",
)
.await
.expect("should create both providers");
@@ -859,6 +920,7 @@ async fn explicit_and_inferred_deduplicates() {
&["nvidia".to_string()],
&["nvidia".to_string()],
Some(true),
"default",
)
.await
.expect("should succeed");
@@ -483,6 +483,55 @@ impl OpenShell for TestOpenShell {
) -> Result<Response<Self::ForwardTcpStream>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn create_workspace(
&self,
_request: tonic::Request<openshell_core::proto::CreateWorkspaceRequest>,
) -> Result<Response<openshell_core::proto::CreateWorkspaceResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn get_workspace(
&self,
_request: tonic::Request<openshell_core::proto::GetWorkspaceRequest>,
) -> Result<Response<openshell_core::proto::GetWorkspaceResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn list_workspaces(
&self,
_request: tonic::Request<openshell_core::proto::ListWorkspacesRequest>,
) -> Result<Response<openshell_core::proto::ListWorkspacesResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn delete_workspace(
&self,
_request: tonic::Request<openshell_core::proto::DeleteWorkspaceRequest>,
) -> Result<Response<openshell_core::proto::DeleteWorkspaceResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn add_workspace_member(
&self,
_request: tonic::Request<openshell_core::proto::AddWorkspaceMemberRequest>,
) -> Result<Response<openshell_core::proto::AddWorkspaceMemberResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn remove_workspace_member(
&self,
_request: tonic::Request<openshell_core::proto::RemoveWorkspaceMemberRequest>,
) -> Result<Response<openshell_core::proto::RemoveWorkspaceMemberResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn list_workspace_members(
&self,
_request: tonic::Request<openshell_core::proto::ListWorkspaceMembersRequest>,
) -> Result<Response<openshell_core::proto::ListWorkspaceMembersResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
}
async fn run_server(
@@ -137,6 +137,8 @@ impl OpenShell for TestOpenShell {
labels: HashMap::new(),
resource_version: 1,
annotations: HashMap::new(),
workspace: String::new(),
deletion_timestamp_ms: 0,
}),
spec: None,
status: None,
@@ -618,6 +620,8 @@ impl OpenShell for TestOpenShell {
labels: existing_metadata.labels,
resource_version: 0,
annotations: HashMap::new(),
workspace: String::new(),
deletion_timestamp_ms: 0,
}),
r#type: existing.r#type,
credentials: merge(existing.credentials, provider.credentials),
@@ -626,6 +630,7 @@ impl OpenShell for TestOpenShell {
existing.credential_expires_at_ms,
provider.credential_expires_at_ms,
),
profile_workspace: existing.profile_workspace,
};
let updated_name = updated.object_name().to_string();
providers.insert(updated_name, updated.clone());
@@ -996,6 +1001,55 @@ impl OpenShell for TestOpenShell {
) -> Result<Response<Self::ForwardTcpStream>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn create_workspace(
&self,
_request: tonic::Request<openshell_core::proto::CreateWorkspaceRequest>,
) -> Result<Response<openshell_core::proto::CreateWorkspaceResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn get_workspace(
&self,
_request: tonic::Request<openshell_core::proto::GetWorkspaceRequest>,
) -> Result<Response<openshell_core::proto::GetWorkspaceResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn list_workspaces(
&self,
_request: tonic::Request<openshell_core::proto::ListWorkspacesRequest>,
) -> Result<Response<openshell_core::proto::ListWorkspacesResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn delete_workspace(
&self,
_request: tonic::Request<openshell_core::proto::DeleteWorkspaceRequest>,
) -> Result<Response<openshell_core::proto::DeleteWorkspaceResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn add_workspace_member(
&self,
_request: tonic::Request<openshell_core::proto::AddWorkspaceMemberRequest>,
) -> Result<Response<openshell_core::proto::AddWorkspaceMemberResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn remove_workspace_member(
&self,
_request: tonic::Request<openshell_core::proto::RemoveWorkspaceMemberRequest>,
) -> Result<Response<openshell_core::proto::RemoveWorkspaceMemberResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn list_workspace_members(
&self,
_request: tonic::Request<openshell_core::proto::ListWorkspaceMembersRequest>,
) -> Result<Response<openshell_core::proto::ListWorkspaceMembersResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
}
/// Test fixture: TLS-enabled server with matching client certs.
@@ -1077,17 +1131,27 @@ async fn provider_cli_run_functions_support_full_crud_flow() {
&["API_KEY=abc".to_string()],
false,
&["profile=dev".to_string()],
"default",
&ts.tls,
)
.await
.expect("provider create");
run::provider_get(&ts.endpoint, "my-claude", &ts.tls)
run::provider_get(&ts.endpoint, "my-claude", "default", &ts.tls)
.await
.expect("provider get");
run::provider_list(&ts.endpoint, 100, 0, false, "table", &ts.tls)
.await
.expect("provider list");
run::provider_list(
&ts.endpoint,
100,
0,
false,
"table",
"default",
false,
&ts.tls,
)
.await
.expect("provider list");
run::provider_update(
&ts.endpoint,
@@ -1096,12 +1160,13 @@ async fn provider_cli_run_functions_support_full_crud_flow() {
&["API_KEY=rotated".to_string()],
&["profile=prod".to_string()],
&[],
"default",
&ts.tls,
)
.await
.expect("provider update");
run::provider_delete(&ts.endpoint, &["my-claude".to_string()], &ts.tls)
run::provider_delete(&ts.endpoint, &["my-claude".to_string()], "default", &ts.tls)
.await
.expect("provider delete");
}
@@ -1110,7 +1175,7 @@ async fn provider_cli_run_functions_support_full_crud_flow() {
async fn provider_list_profiles_cli_uses_profile_browsing_rpc() {
let ts = run_server().await;
run::provider_list_profiles(&ts.endpoint, "table", &ts.tls)
run::provider_list_profiles(&ts.endpoint, "table", "default", &ts.tls)
.await
.expect("provider list-profiles");
}
@@ -1128,19 +1193,34 @@ async fn provider_list_json_output() {
&["ANTHROPIC_API_KEY=test-key".to_string()],
false,
&["region=us-west".to_string()],
"default",
&ts.tls,
)
.await
.expect("provider create");
// Test JSON output (verifies it doesn't error)
run::provider_list(&ts.endpoint, 100, 0, false, "json", &ts.tls)
.await
.expect("provider list json should succeed");
run::provider_list(
&ts.endpoint,
100,
0,
false,
"json",
"default",
false,
&ts.tls,
)
.await
.expect("provider list json should succeed");
run::provider_delete(&ts.endpoint, &["test-provider".to_string()], &ts.tls)
.await
.expect("provider delete");
run::provider_delete(
&ts.endpoint,
&["test-provider".to_string()],
"default",
&ts.tls,
)
.await
.expect("provider delete");
}
#[tokio::test]
@@ -1156,19 +1236,34 @@ async fn provider_list_yaml_output() {
&["ANTHROPIC_API_KEY=test-key".to_string()],
false,
&["region=us-west".to_string()],
"default",
&ts.tls,
)
.await
.expect("provider create");
// Test YAML output (verifies it doesn't error)
run::provider_list(&ts.endpoint, 100, 0, false, "yaml", &ts.tls)
.await
.expect("provider list yaml should succeed");
run::provider_list(
&ts.endpoint,
100,
0,
false,
"yaml",
"default",
false,
&ts.tls,
)
.await
.expect("provider list yaml should succeed");
run::provider_delete(&ts.endpoint, &["test-provider".to_string()], &ts.tls)
.await
.expect("provider delete");
run::provider_delete(
&ts.endpoint,
&["test-provider".to_string()],
"default",
&ts.tls,
)
.await
.expect("provider delete");
}
#[tokio::test]
@@ -1176,9 +1271,18 @@ async fn provider_list_json_empty() {
let ts = run_server().await;
// Test JSON output with no providers (verifies it doesn't error on empty list)
run::provider_list(&ts.endpoint, 100, 0, false, "json", &ts.tls)
.await
.expect("provider list json empty should succeed");
run::provider_list(
&ts.endpoint,
100,
0,
false,
"json",
"default",
false,
&ts.tls,
)
.await
.expect("provider list json empty should succeed");
}
#[tokio::test]
@@ -1193,6 +1297,7 @@ async fn provider_refresh_cli_run_functions_wire_requests() {
&["MS_GRAPH_ACCESS_TOKEN=token".to_string()],
false,
&[],
"default",
&ts.tls,
)
.await
@@ -1209,6 +1314,7 @@ async fn provider_refresh_cli_run_functions_wire_requests() {
secret_material_keys: &["client_secret".to_string()],
credential_expires_at_ms: Some(1_767_225_600_000),
},
"default",
&ts.tls,
)
.await
@@ -1217,16 +1323,29 @@ async fn provider_refresh_cli_run_functions_wire_requests() {
&ts.endpoint,
"my-graph",
Some("MS_GRAPH_ACCESS_TOKEN"),
"default",
&ts.tls,
)
.await
.expect("provider refresh status");
run::provider_rotate(&ts.endpoint, "my-graph", "MS_GRAPH_ACCESS_TOKEN", &ts.tls)
.await
.expect("provider refresh rotate");
run::provider_refresh_delete(&ts.endpoint, "my-graph", "MS_GRAPH_ACCESS_TOKEN", &ts.tls)
.await
.expect("provider refresh delete");
run::provider_rotate(
&ts.endpoint,
"my-graph",
"MS_GRAPH_ACCESS_TOKEN",
"default",
&ts.tls,
)
.await
.expect("provider refresh rotate");
run::provider_refresh_delete(
&ts.endpoint,
"my-graph",
"MS_GRAPH_ACCESS_TOKEN",
"default",
&ts.tls,
)
.await
.expect("provider refresh delete");
let requests = ts.state.refresh_requests.lock().await.clone();
assert_eq!(
@@ -1267,6 +1386,7 @@ async fn provider_refresh_configure_reads_secret_material_from_env_off_argv() {
&["GOOGLE_CHAT_ACCESS_TOKEN=pending".to_string()],
false,
&[],
"default",
&ts.tls,
)
.await
@@ -1285,6 +1405,7 @@ async fn provider_refresh_configure_reads_secret_material_from_env_off_argv() {
secret_material_keys: &[],
credential_expires_at_ms: None,
},
"default",
&ts.tls,
)
.await
@@ -1326,6 +1447,7 @@ async fn provider_refresh_configure_rejects_key_supplied_via_both_material_and_e
secret_material_keys: &[],
credential_expires_at_ms: None,
},
"default",
&ts.tls,
)
.await
@@ -1355,6 +1477,7 @@ async fn provider_refresh_configure_fails_closed_when_secret_material_env_is_uns
secret_material_keys: &[],
credential_expires_at_ms: None,
},
"default",
&ts.tls,
)
.await
@@ -1397,6 +1520,8 @@ async fn provider_create_allows_empty_credentials_for_gateway_refresh_profiles()
false,
true,
&[],
"default",
"default",
&ts.tls,
)
.await
@@ -1437,6 +1562,7 @@ async fn provider_create_requires_runtime_credentials_for_empty_gateway_refresh_
&[],
false,
&[],
"default",
&ts.tls,
)
.await
@@ -1464,26 +1590,51 @@ async fn sandbox_provider_cli_run_functions_wire_requests_and_idempotent_results
&["GITHUB_TOKEN=ghp-test".to_string()],
false,
&[],
"default",
&ts.tls,
)
.await
.expect("provider create");
run::sandbox_provider_attach(&ts.endpoint, "dev-sandbox", "work-github", &ts.tls)
.await
.expect("sandbox provider attach");
run::sandbox_provider_attach(&ts.endpoint, "dev-sandbox", "work-github", &ts.tls)
.await
.expect("sandbox provider attach is idempotent");
run::sandbox_provider_list(&ts.endpoint, "dev-sandbox", &ts.tls)
run::sandbox_provider_attach(
&ts.endpoint,
"dev-sandbox",
"work-github",
"default",
&ts.tls,
)
.await
.expect("sandbox provider attach");
run::sandbox_provider_attach(
&ts.endpoint,
"dev-sandbox",
"work-github",
"default",
&ts.tls,
)
.await
.expect("sandbox provider attach is idempotent");
run::sandbox_provider_list(&ts.endpoint, "dev-sandbox", "default", &ts.tls)
.await
.expect("sandbox provider list");
run::sandbox_provider_detach(&ts.endpoint, "dev-sandbox", "work-github", &ts.tls)
.await
.expect("sandbox provider detach");
run::sandbox_provider_detach(&ts.endpoint, "dev-sandbox", "work-github", &ts.tls)
.await
.expect("sandbox provider detach is idempotent");
run::sandbox_provider_detach(
&ts.endpoint,
"dev-sandbox",
"work-github",
"default",
&ts.tls,
)
.await
.expect("sandbox provider detach");
run::sandbox_provider_detach(
&ts.endpoint,
"dev-sandbox",
"work-github",
"default",
&ts.tls,
)
.await
.expect("sandbox provider detach is idempotent");
let requests = ts.state.sandbox_provider_requests.lock().await.clone();
assert_eq!(
@@ -1519,10 +1670,15 @@ async fn sandbox_provider_cli_run_functions_wire_requests_and_idempotent_results
async fn sandbox_provider_attach_cli_surfaces_server_errors() {
let ts = run_server().await;
let err =
run::sandbox_provider_attach(&ts.endpoint, "dev-sandbox", "missing-provider", &ts.tls)
.await
.expect_err("missing provider should fail");
let err = run::sandbox_provider_attach(
&ts.endpoint,
"dev-sandbox",
"missing-provider",
"default",
&ts.tls,
)
.await
.expect_err("missing provider should fail");
assert!(
err.to_string().contains("provider not found"),
@@ -1563,14 +1719,14 @@ binaries: [/usr/bin/custom]
)
.unwrap();
run::provider_profile_lint(&ts.endpoint, Some(&profile_path), None, &ts.tls)
run::provider_profile_lint(&ts.endpoint, Some(&profile_path), None, "default", &ts.tls)
.await
.expect("profile lint");
run::provider_profile_import(&ts.endpoint, Some(&profile_path), None, &ts.tls)
run::provider_profile_import(&ts.endpoint, Some(&profile_path), None, "default", &ts.tls)
.await
.expect("profile import");
let exported_yaml =
run::provider_profile_export_text(&ts.endpoint, "custom-api", "yaml", &ts.tls)
run::provider_profile_export_text(&ts.endpoint, "custom-api", "yaml", "default", &ts.tls)
.await
.expect("profile export text");
assert!(exported_yaml.contains("resource_version: 1"));
@@ -1581,9 +1737,15 @@ binaries: [/usr/bin/custom]
)
.replace("host: api.custom.example", "host: api.updated.example");
std::fs::write(&profile_path, updated_yaml).unwrap();
run::provider_profile_update(&ts.endpoint, "custom-api", &profile_path, &ts.tls)
.await
.expect("profile update");
run::provider_profile_update(
&ts.endpoint,
"custom-api",
&profile_path,
"default",
&ts.tls,
)
.await
.expect("profile update");
assert_eq!(
ts.state
.profiles
@@ -1594,10 +1756,10 @@ binaries: [/usr/bin/custom]
.map(|endpoint| endpoint.host.as_str()),
Some("api.updated.example")
);
run::provider_profile_export(&ts.endpoint, "custom-api", "yaml", &ts.tls)
run::provider_profile_export(&ts.endpoint, "custom-api", "yaml", "default", &ts.tls)
.await
.expect("profile export");
run::provider_list_profiles(&ts.endpoint, "json", &ts.tls)
run::provider_list_profiles(&ts.endpoint, "json", "default", &ts.tls)
.await
.expect("provider list-profiles json");
run::provider_create(
@@ -1608,6 +1770,7 @@ binaries: [/usr/bin/custom]
&["CUSTOM_API_KEY=abc".to_string()],
false,
&[],
"default",
&ts.tls,
)
.await
@@ -1623,10 +1786,15 @@ binaries: [/usr/bin/custom]
.expect("custom provider should be stored");
assert_eq!(provider.r#type, "custom-api");
run::provider_delete(&ts.endpoint, &["custom-provider".to_string()], &ts.tls)
.await
.expect("custom provider delete");
run::provider_profile_delete(&ts.endpoint, "custom-api", &ts.tls)
run::provider_delete(
&ts.endpoint,
&["custom-provider".to_string()],
"default",
&ts.tls,
)
.await
.expect("custom provider delete");
run::provider_profile_delete(&ts.endpoint, "custom-api", "default", &ts.tls)
.await
.expect("profile delete");
}
@@ -1662,6 +1830,7 @@ async fn provider_create_from_existing_uses_profile_discovery_when_v2_enabled()
&[],
false,
&[],
"default",
&ts.tls,
)
.await
@@ -1695,6 +1864,7 @@ async fn provider_create_from_existing_uses_registry_discovery_when_v2_disabled(
&[],
false,
&[],
"default",
&ts.tls,
)
.await
@@ -1738,6 +1908,7 @@ async fn provider_create_from_existing_vertex_discovers_credentials_and_config_w
&[],
false,
&[],
"default",
&ts.tls,
)
.await
@@ -1793,6 +1964,7 @@ async fn provider_create_from_existing_requires_profile_when_v2_enabled() {
&[],
false,
&[],
"default",
&ts.tls,
)
.await
@@ -1836,6 +2008,7 @@ async fn provider_create_from_existing_fails_when_profile_discovery_finds_nothin
&[],
false,
&[],
"default",
&ts.tls,
)
.await
@@ -1888,13 +2061,23 @@ async fn provider_update_from_existing_uses_profile_discovery_when_v2_enabled()
credentials: HashMap::new(),
config: HashMap::new(),
credential_expires_at_ms: HashMap::new(),
profile_workspace: "default".to_string(),
},
);
let _env = EnvVarGuard::set(&[("CUSTOM_UPDATE_DISCOVERY_API_KEY", "updated-profile-secret")]);
run::provider_update(&ts.endpoint, "custom-update", true, &[], &[], &[], &ts.tls)
.await
.expect("profile-backed provider update --from-existing");
run::provider_update(
&ts.endpoint,
"custom-update",
true,
&[],
&[],
&[],
"default",
&ts.tls,
)
.await
.expect("profile-backed provider update --from-existing");
let provider = ts
.state
@@ -1943,14 +2126,14 @@ binaries: [/usr/bin/yaml-client]
.unwrap();
std::fs::write(dir.path().join("notes.txt"), "ignored").unwrap();
run::provider_profile_import(&ts.endpoint, None, Some(dir.path()), &ts.tls)
run::provider_profile_import(&ts.endpoint, None, Some(dir.path()), "default", &ts.tls)
.await
.expect("profile import --from");
run::provider_profile_export(&ts.endpoint, "custom-yaml", "yaml", &ts.tls)
run::provider_profile_export(&ts.endpoint, "custom-yaml", "yaml", "default", &ts.tls)
.await
.expect("custom-yaml should be imported");
run::provider_profile_export(&ts.endpoint, "custom-json", "json", &ts.tls)
run::provider_profile_export(&ts.endpoint, "custom-json", "json", "default", &ts.tls)
.await
.expect("custom-json should be imported");
}
@@ -1990,7 +2173,7 @@ binaries:
)
.unwrap();
run::provider_profile_import(&ts.endpoint, Some(&profile_path), None, &ts.tls)
run::provider_profile_import(&ts.endpoint, Some(&profile_path), None, "default", &ts.tls)
.await
.expect("profile import");
@@ -2000,6 +2183,7 @@ binaries:
let profile = client
.get_provider_profile(openshell_core::proto::GetProviderProfileRequest {
id: "advanced-api".to_string(),
workspace: String::new(),
})
.await
.expect("get provider profile")
@@ -2034,15 +2218,16 @@ endpoints:
.unwrap();
std::fs::write(dir.path().join("broken.yaml"), "id: [\n").unwrap();
let err = run::provider_profile_import(&ts.endpoint, None, Some(dir.path()), &ts.tls)
.await
.expect_err("profile import --from should fail on parse errors");
let err =
run::provider_profile_import(&ts.endpoint, None, Some(dir.path()), "default", &ts.tls)
.await
.expect_err("profile import --from should fail on parse errors");
assert!(
err.to_string().contains("provider profile import failed"),
"unexpected error: {err}"
);
run::provider_profile_export(&ts.endpoint, "custom-good", "yaml", &ts.tls)
run::provider_profile_export(&ts.endpoint, "custom-good", "yaml", "default", &ts.tls)
.await
.expect_err("valid profiles should not be partially imported after local parse errors");
}
@@ -2065,7 +2250,7 @@ endpoints:
.unwrap();
std::fs::write(dir.path().join("broken.yaml"), "id: [\n").unwrap();
let err = run::provider_profile_lint(&ts.endpoint, None, Some(dir.path()), &ts.tls)
let err = run::provider_profile_lint(&ts.endpoint, None, Some(dir.path()), "default", &ts.tls)
.await
.expect_err("profile lint --from should fail on parse errors");
assert!(
@@ -2073,7 +2258,7 @@ endpoints:
"unexpected error: {err}"
);
run::provider_profile_export(&ts.endpoint, "custom-good", "yaml", &ts.tls)
run::provider_profile_export(&ts.endpoint, "custom-good", "yaml", "default", &ts.tls)
.await
.expect_err("lint should not import valid profiles");
}
@@ -2090,6 +2275,7 @@ async fn provider_create_rejects_key_only_credentials_without_local_env_value()
&["INVALID_PAIR".to_string()],
false,
&[],
"default",
&ts.tls,
)
.await
@@ -2115,6 +2301,7 @@ async fn provider_create_supports_generic_type_and_env_lookup_credentials() {
&["NAV_GENERIC_TEST_KEY".to_string()],
false,
&[],
"default",
&ts.tls,
)
.await
@@ -2126,6 +2313,7 @@ async fn provider_create_supports_generic_type_and_env_lookup_credentials() {
let response = client
.get_provider(GetProviderRequest {
name: "my-generic".to_string(),
workspace: String::new(),
})
.await
.expect("get provider should succeed")
@@ -2150,6 +2338,7 @@ async fn provider_create_rejects_combined_from_existing_and_credentials() {
&["API_KEY=abc".to_string()],
false,
&[],
"default",
&ts.tls,
)
.await
@@ -2174,6 +2363,7 @@ async fn provider_create_rejects_combined_from_gcloud_adc_and_from_existing() {
&[],
true,
&[],
"default",
&ts.tls,
)
.await
@@ -2199,6 +2389,7 @@ async fn provider_create_rejects_combined_from_gcloud_adc_and_credentials() {
&["GOOGLE_VERTEX_AI_TOKEN=token".to_string()],
true,
&[],
"default",
&ts.tls,
)
.await
@@ -2225,6 +2416,7 @@ async fn provider_create_rejects_empty_env_var_for_key_only_credential() {
&["NAV_EMPTY_ENV_KEY".to_string()],
false,
&[],
"default",
&ts.tls,
)
.await
@@ -2250,6 +2442,7 @@ async fn provider_create_supports_nvidia_type_with_nvidia_api_key() {
&["NVIDIA_API_KEY".to_string()],
false,
&[],
"default",
&ts.tls,
)
.await
@@ -2261,6 +2454,7 @@ async fn provider_create_supports_nvidia_type_with_nvidia_api_key() {
let response = client
.get_provider(GetProviderRequest {
name: "my-nvidia".to_string(),
workspace: String::new(),
})
.await
.expect("get provider should succeed")
@@ -2302,6 +2496,7 @@ async fn provider_create_from_gcloud_adc_happy_path() {
&[], // no explicit credentials; refresh bootstrap covers it
true, // from_gcloud_adc
&[],
"default",
&ts.tls,
)
.await
@@ -2387,6 +2582,7 @@ async fn provider_create_from_gcloud_adc_rejects_service_account() {
&[],
true,
&[],
"default",
&ts.tls,
)
.await
@@ -2424,6 +2620,7 @@ async fn provider_create_from_gcloud_adc_missing_file() {
&[],
true,
&[],
"default",
&ts.tls,
)
.await
@@ -2457,6 +2654,7 @@ async fn provider_create_from_gcloud_adc_rejects_wrong_provider_type_before_cred
&[],
true,
&[],
"default",
&ts.tls,
)
.await
@@ -2494,6 +2692,7 @@ async fn provider_create_from_gcloud_adc_rolls_back_provider_when_refresh_config
&[],
true,
&[],
"default",
&ts.tls,
)
.await
@@ -2544,6 +2743,7 @@ async fn provider_create_from_gcloud_adc_warn_path_keeps_provider_when_rollback_
&[],
true,
&[],
"default",
&ts.tls,
)
.await
@@ -2592,6 +2792,7 @@ async fn provider_create_from_gcloud_adc_rolls_back_provider_when_initial_rotate
&[],
true,
&[],
"default",
&ts.tls,
)
.await
@@ -2632,6 +2833,7 @@ async fn provider_create_from_existing_vertex_config_only_reports_missing_vertex
&[],
false,
&[],
"default",
&ts.tls,
)
.await
@@ -2678,6 +2880,7 @@ async fn provider_create_from_gcloud_adc_with_config_keys() {
"VERTEX_AI_PROJECT_ID=my-gcp-project".to_string(),
"VERTEX_AI_REGION=us-east1".to_string(),
],
"default",
&ts.tls,
)
.await
@@ -2752,6 +2955,7 @@ async fn provider_create_from_gcloud_adc_missing_refresh_token() {
&[],
true,
&[],
"default",
&ts.tls,
)
.await
@@ -2794,6 +2998,7 @@ async fn provider_create_from_gcloud_adc_missing_client_secret() {
&[],
true,
&[],
"default",
&ts.tls,
)
.await
@@ -95,6 +95,8 @@ impl OpenShell for TestOpenShell {
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: String::new(),
deletion_timestamp_ms: 0,
}),
..Sandbox::default()
};
@@ -117,6 +119,8 @@ impl OpenShell for TestOpenShell {
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: String::new(),
deletion_timestamp_ms: 0,
}),
..Sandbox::default()
};
@@ -378,6 +382,8 @@ impl OpenShell for TestOpenShell {
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: String::new(),
deletion_timestamp_ms: 0,
}),
..Sandbox::default()
};
@@ -665,6 +671,55 @@ impl OpenShell for TestOpenShell {
) -> Result<Response<Self::ForwardTcpStream>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn create_workspace(
&self,
_request: tonic::Request<openshell_core::proto::CreateWorkspaceRequest>,
) -> Result<Response<openshell_core::proto::CreateWorkspaceResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn get_workspace(
&self,
_request: tonic::Request<openshell_core::proto::GetWorkspaceRequest>,
) -> Result<Response<openshell_core::proto::GetWorkspaceResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn list_workspaces(
&self,
_request: tonic::Request<openshell_core::proto::ListWorkspacesRequest>,
) -> Result<Response<openshell_core::proto::ListWorkspacesResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn delete_workspace(
&self,
_request: tonic::Request<openshell_core::proto::DeleteWorkspaceRequest>,
) -> Result<Response<openshell_core::proto::DeleteWorkspaceResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn add_workspace_member(
&self,
_request: tonic::Request<openshell_core::proto::AddWorkspaceMemberRequest>,
) -> Result<Response<openshell_core::proto::AddWorkspaceMemberResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn remove_workspace_member(
&self,
_request: tonic::Request<openshell_core::proto::RemoveWorkspaceMemberRequest>,
) -> Result<Response<openshell_core::proto::RemoveWorkspaceMemberResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn list_workspace_members(
&self,
_request: tonic::Request<openshell_core::proto::ListWorkspaceMembersRequest>,
) -> Result<Response<openshell_core::proto::ListWorkspaceMembersResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
}
struct TestServer {
@@ -1139,6 +1194,7 @@ async fn sandbox_create_keeps_command_sessions_by_default() {
command: &["echo".into(), "OK".into()],
..test_config()
},
"default",
&tls,
)
.await
@@ -1146,7 +1202,7 @@ async fn sandbox_create_keeps_command_sessions_by_default() {
assert!(deleted_names(&server).await.is_empty());
assert_eq!(
load_last_sandbox("openshell").as_deref(),
load_last_sandbox("openshell", "default").as_deref(),
Some("default-command"),
"default sandboxes should be persisted as last-used"
);
@@ -1171,6 +1227,7 @@ async fn sandbox_create_sends_cpu_and_memory_limits_only() {
command: &["echo".into(), "OK".into()],
..test_config()
},
"default",
&tls,
)
.await
@@ -1238,6 +1295,7 @@ async fn sandbox_create_sends_driver_config_json() {
command: &["echo".into(), "OK".into()],
..test_config()
},
"default",
&tls,
)
.await
@@ -1299,6 +1357,7 @@ async fn sandbox_create_sends_gpu_default_request() {
command: &["echo".into(), "OK".into()],
..test_config()
},
"default",
&tls,
)
.await
@@ -1333,6 +1392,7 @@ async fn sandbox_create_sends_gpu_count_request() {
command: &["echo".into(), "OK".into()],
..test_config()
},
"default",
&tls,
)
.await
@@ -1368,6 +1428,7 @@ async fn sandbox_create_does_not_infer_command_providers_when_v2_enabled() {
tty_override: Some(true),
..test_config()
},
"default",
&tls,
)
.await
@@ -1413,6 +1474,7 @@ async fn sandbox_create_returns_vm_error_without_waiting_for_timeout() {
command: &["echo".into(), "OK".into()],
..test_config()
},
"default",
&tls,
)
.await
@@ -1454,6 +1516,7 @@ async fn sandbox_create_keeps_waiting_while_vm_progress_arrives() {
command: &["echo".into(), "OK".into()],
..test_config()
},
"default",
&tls,
)
.await
@@ -1487,6 +1550,7 @@ async fn sandbox_create_times_out_when_only_logs_arrive() {
command: &["echo".into(), "OK".into()],
..test_config()
},
"default",
&tls,
)
.await
@@ -1517,6 +1581,7 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() {
command: &["echo".into(), "OK".into()],
..test_config()
},
"default",
&tls,
)
.await
@@ -1527,7 +1592,7 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() {
vec![vec!["ephemeral-command".to_string()]]
);
assert_eq!(
load_last_sandbox("openshell"),
load_last_sandbox("openshell", "default"),
None,
"no-keep sandboxes should not be persisted as last-used"
);
@@ -1551,6 +1616,7 @@ async fn sandbox_create_deletes_shell_sessions_with_no_keep() {
tty_override: Some(true),
..test_config()
},
"default",
&tls,
)
.await
@@ -1561,7 +1627,7 @@ async fn sandbox_create_deletes_shell_sessions_with_no_keep() {
vec![vec!["ephemeral-shell".to_string()]]
);
assert_eq!(
load_last_sandbox("openshell"),
load_last_sandbox("openshell", "default"),
None,
"no-keep shell sessions should not be persisted as last-used"
);
@@ -1584,6 +1650,7 @@ async fn sandbox_create_keeps_sandbox_with_hidden_keep_flag() {
command: &["echo".into(), "OK".into()],
..test_config()
},
"default",
&tls,
)
.await
@@ -1591,7 +1658,7 @@ async fn sandbox_create_keeps_sandbox_with_hidden_keep_flag() {
assert!(deleted_names(&server).await.is_empty());
assert_eq!(
load_last_sandbox("openshell").as_deref(),
load_last_sandbox("openshell", "default").as_deref(),
Some("persistent-keep"),
"persistent sandboxes should remain selectable as last-used"
);
@@ -1619,6 +1686,7 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() {
command: &["echo".into(), "OK".into()],
..test_config()
},
"default",
&tls,
)
.await
@@ -1646,9 +1714,16 @@ async fn sandbox_forward_background_tracks_owned_child_when_pid_discovery_fails(
drop(listener);
let spec = openshell_core::forward::ForwardSpec::new(forward_port);
run::sandbox_forward(&server.endpoint, "owned-forward", &spec, true, &tls)
.await
.expect("background forward should track the owned SSH child without PID discovery");
run::sandbox_forward(
&server.endpoint,
"owned-forward",
&spec,
true,
&tls,
"default",
)
.await
.expect("background forward should track the owned SSH child without PID discovery");
let record = openshell_core::forward::read_forward_pid("owned-forward", forward_port)
.expect("owned background forward should write a PID file");
@@ -1676,9 +1751,16 @@ async fn sandbox_forward_foreground_fails_when_ssh_exits_before_listener_opens()
drop(listener);
let spec = openshell_core::forward::ForwardSpec::new(forward_port);
let err = run::sandbox_forward(&server.endpoint, "foreground-forward", &spec, false, &tls)
.await
.expect_err("foreground forward should fail when ssh exits before listener readiness");
let err = run::sandbox_forward(
&server.endpoint,
"foreground-forward",
&spec,
false,
&tls,
"default",
)
.await
.expect_err("foreground forward should fail when ssh exits before listener readiness");
let msg = format!("{err}");
assert!(
msg.contains("ssh exited before local forward listener opened"),
@@ -1699,9 +1781,16 @@ async fn sandbox_forward_background_terminates_owned_child_when_listener_never_o
drop(listener);
let spec = openshell_core::forward::ForwardSpec::new(forward_port);
let err = run::sandbox_forward(&server.endpoint, "unreachable-forward", &spec, true, &tls)
.await
.expect_err("background forward should fail when the listener never opens");
let err = run::sandbox_forward(
&server.endpoint,
"unreachable-forward",
&spec,
true,
&tls,
"default",
)
.await
.expect_err("background forward should fail when the listener never opens");
let msg = format!("{err}");
assert!(
msg.contains("ssh process started but local forward listener was not reachable"),
@@ -1758,6 +1847,7 @@ async fn sandbox_create_sends_environment_variables() {
]),
..test_config()
},
"default",
&tls,
)
.await
@@ -87,6 +87,8 @@ impl OpenShell for TestOpenShell {
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: std::collections::HashMap::new(),
workspace: String::new(),
deletion_timestamp_ms: 0,
}),
..Default::default()
}),
@@ -569,6 +571,55 @@ impl OpenShell for TestOpenShell {
) -> Result<Response<Self::ForwardTcpStream>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn create_workspace(
&self,
_request: tonic::Request<openshell_core::proto::CreateWorkspaceRequest>,
) -> Result<Response<openshell_core::proto::CreateWorkspaceResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn get_workspace(
&self,
_request: tonic::Request<openshell_core::proto::GetWorkspaceRequest>,
) -> Result<Response<openshell_core::proto::GetWorkspaceResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn list_workspaces(
&self,
_request: tonic::Request<openshell_core::proto::ListWorkspacesRequest>,
) -> Result<Response<openshell_core::proto::ListWorkspacesResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn delete_workspace(
&self,
_request: tonic::Request<openshell_core::proto::DeleteWorkspaceRequest>,
) -> Result<Response<openshell_core::proto::DeleteWorkspaceResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn add_workspace_member(
&self,
_request: tonic::Request<openshell_core::proto::AddWorkspaceMemberRequest>,
) -> Result<Response<openshell_core::proto::AddWorkspaceMemberResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn remove_workspace_member(
&self,
_request: tonic::Request<openshell_core::proto::RemoveWorkspaceMemberRequest>,
) -> Result<Response<openshell_core::proto::RemoveWorkspaceMemberResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn list_workspace_members(
&self,
_request: tonic::Request<openshell_core::proto::ListWorkspaceMembersRequest>,
) -> Result<Response<openshell_core::proto::ListWorkspaceMembersResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
}
struct TestServer {
@@ -636,7 +687,7 @@ async fn run_server() -> TestServer {
async fn sandbox_get_sends_correct_name() {
let ts = run_server().await;
run::sandbox_get(&ts.endpoint, "my-sandbox", false, &ts.tls)
run::sandbox_get(&ts.endpoint, "my-sandbox", false, "default", &ts.tls)
.await
.expect("sandbox_get should succeed");
@@ -653,7 +704,7 @@ async fn sandbox_get_sends_correct_name() {
async fn sandbox_get_policy_only_round_trip() {
let ts = run_server().await;
run::sandbox_get(&ts.endpoint, "my-sandbox", true, &ts.tls)
run::sandbox_get(&ts.endpoint, "my-sandbox", true, "default", &ts.tls)
.await
.expect("sandbox_get with policy_only should succeed");
@@ -670,16 +721,16 @@ async fn sandbox_get_with_persisted_last_sandbox() {
let _guard = EnvVarGuard::set(&[("XDG_CONFIG_HOME", xdg_dir.path().to_str().unwrap())]);
// Persist a last-used sandbox for "integration-cluster".
save_last_sandbox("integration-cluster", "persisted-sb")
save_last_sandbox("integration-cluster", "default", "persisted-sb")
.expect("save_last_sandbox should succeed");
// Resolve the name (simulates what the CLI does in main.rs).
let resolved = load_last_sandbox("integration-cluster")
let resolved = load_last_sandbox("integration-cluster", "default")
.expect("load_last_sandbox should return the saved name");
assert_eq!(resolved, "persisted-sb");
// Call sandbox_get with the resolved name.
run::sandbox_get(&ts.endpoint, &resolved, false, &ts.tls)
run::sandbox_get(&ts.endpoint, &resolved, false, "default", &ts.tls)
.await
.expect("sandbox_get should succeed");
@@ -703,6 +754,7 @@ async fn policy_get_full_json_cli_prints_policy_payload() {
0,
run::PolicyGetView::Full,
"json",
"default",
&ts.tls,
(&mut stdout, &mut stderr),
)
@@ -751,6 +803,7 @@ async fn policy_get_base_json_cli_prints_round_trippable_policy_payload() {
0,
run::PolicyGetView::Base,
"json",
"default",
&ts.tls,
(&mut stdout, &mut stderr),
)
@@ -792,6 +845,7 @@ async fn policy_get_explicit_revision_uses_stored_policy_status() {
3,
run::PolicyGetView::Full,
"json",
"default",
&ts.tls,
(&mut stdout, &mut stderr),
)
@@ -827,9 +881,9 @@ async fn explicit_name_takes_precedence_over_persisted() {
let _guard = EnvVarGuard::set(&[("XDG_CONFIG_HOME", xdg_dir.path().to_str().unwrap())]);
// Persist one name, but supply a different one explicitly.
save_last_sandbox("my-cluster", "old-sandbox").expect("save should succeed");
save_last_sandbox("my-cluster", "default", "old-sandbox").expect("save should succeed");
run::sandbox_get(&ts.endpoint, "explicit-sandbox", false, &ts.tls)
run::sandbox_get(&ts.endpoint, "explicit-sandbox", false, "default", &ts.tls)
.await
.expect("sandbox_get should succeed");
+43 -4
View File
@@ -171,9 +171,19 @@ pub fn detect_driver() -> Option<ComputeDriverKind> {
}
fn is_podman_available() -> bool {
podman_socket_candidates()
detect_podman_socket().is_some()
}
/// Return the first responsive Podman API socket, or `None` if none respond.
pub fn detect_podman_socket() -> Option<PathBuf> {
detect_podman_socket_from_candidates(&podman_socket_candidates())
}
fn detect_podman_socket_from_candidates(candidates: &[PathBuf]) -> Option<PathBuf> {
candidates
.iter()
.any(|path| podman_socket_responds(path))
.find(|path| podman_socket_responds(path))
.cloned()
}
fn podman_socket_candidates() -> Vec<PathBuf> {
@@ -972,8 +982,9 @@ mod tests {
ComputeDriverKind, Config, DEFAULT_SERVICE_ROUTING_DOMAIN, GatewayInterceptorBindingPolicy,
GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, GatewayJwtConfig,
GatewayProviderProfileSourceConfig, detect_docker_socket_from_candidates, detect_driver,
docker_host_unix_socket_path, docker_socket_responds, is_unix_socket,
normalize_compute_driver_name, podman_socket_candidates_from_env, podman_socket_responds,
detect_podman_socket_from_candidates, docker_host_unix_socket_path, docker_socket_responds,
is_unix_socket, normalize_compute_driver_name, podman_socket_candidates_from_env,
podman_socket_responds,
};
#[cfg(unix)]
use std::io::{Read as _, Write as _};
@@ -1349,6 +1360,34 @@ mod tests {
)));
}
#[cfg(unix)]
#[test]
fn podman_socket_detection_returns_the_responsive_candidate() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let inactive_path = temp_dir.path().join("inactive.sock");
let inactive_listener = UnixListener::bind(&inactive_path).expect("bind inactive socket");
drop(inactive_listener);
let responsive_path = temp_dir.path().join("responsive.sock");
let listener = UnixListener::bind(&responsive_path).expect("bind responsive socket");
let handle = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept podman probe");
let mut request = [0_u8; 128];
let _ = stream.read(&mut request).expect("read podman probe");
stream
.write_all(
b"HTTP/1.1 200 OK\r\nLibpod-Api-Version: 5.8.2\r\nContent-Length: 2\r\n\r\nOK",
)
.expect("write podman ping response");
});
assert_eq!(
detect_podman_socket_from_candidates(&[inactive_path, responsive_path.clone(),]),
Some(responsive_path)
);
handle.join().expect("probe server exits");
}
#[test]
#[allow(unsafe_code)] // std::env::set_var/remove_var require unsafe in Rust 2024
fn detect_driver_prefers_kubernetes_when_k8s_env_is_set() {
+10
View File
@@ -27,6 +27,16 @@ pub const LABEL_SANDBOX_NAME: &str = "openshell.ai/sandbox-name";
/// Container/pod label carrying the sandbox namespace.
pub const LABEL_SANDBOX_NAMESPACE: &str = "openshell.ai/sandbox-namespace";
/// Container/pod label carrying the sandbox workspace.
pub const LABEL_SANDBOX_WORKSPACE: &str = "openshell.ai/sandbox-workspace";
/// Label selector that matches all OpenShell-managed resources which carry a
/// sandbox ID label. Used by list and watch operations to exclude foreign
/// resources from the same namespace.
pub fn openshell_sandbox_label_selector() -> String {
format!("{LABEL_MANAGED_BY}={LABEL_MANAGED_BY_VALUE},{LABEL_SANDBOX_ID}")
}
// ---------------------------------------------------------------------------
/// Path to the sandbox supervisor binary inside the container image.
+67 -6
View File
@@ -542,6 +542,36 @@ mod auth_tests {
}
}
#[cfg(test)]
mod workspace_tests {
use super::*;
#[test]
fn cached_client_workspace_defaults_to_empty_before_poll() {
let client_ws: Arc<tokio::sync::OnceCell<String>> = Arc::new(tokio::sync::OnceCell::new());
assert_eq!(client_ws.get().cloned().unwrap_or_default(), "");
}
#[test]
fn cached_client_workspace_returns_learned_value() {
let client_ws: Arc<tokio::sync::OnceCell<String>> = Arc::new(tokio::sync::OnceCell::new());
let _ = client_ws.set("beta".to_string());
assert_eq!(client_ws.get().cloned().unwrap_or_default(), "beta");
}
#[test]
fn cached_client_workspace_is_set_once() {
let client_ws: Arc<tokio::sync::OnceCell<String>> = Arc::new(tokio::sync::OnceCell::new());
let _ = client_ws.set("beta".to_string());
let _ = client_ws.set("gamma".to_string());
assert_eq!(
client_ws.get().cloned().unwrap_or_default(),
"beta",
"workspace should not change after first poll"
);
}
}
/// Connect to the `OpenShell` server.
async fn connect(endpoint: &str) -> Result<OpenShellClient<AuthedChannel>> {
let channel = connect_channel(endpoint).await?;
@@ -620,11 +650,13 @@ async fn sync_policy_with_client(
client: &mut OpenShellClient<AuthedChannel>,
sandbox: &str,
policy: &ProtoSandboxPolicy,
workspace: &str,
) -> Result<()> {
client
.update_config(UpdateConfigRequest {
name: sandbox.to_string(),
policy: Some(policy.clone()),
workspace: workspace.to_string(),
..Default::default()
})
.await
@@ -643,6 +675,7 @@ pub async fn discover_and_sync_policy(
sandbox_id: &str,
sandbox: &str,
discovered_policy: &ProtoSandboxPolicy,
workspace: &str,
) -> Result<ProtoSandboxPolicy> {
debug!(
endpoint = %endpoint,
@@ -654,7 +687,7 @@ pub async fn discover_and_sync_policy(
let mut client = connect(endpoint).await?;
// Sync the discovered policy to the gateway.
sync_policy_with_client(&mut client, sandbox, discovered_policy).await?;
sync_policy_with_client(&mut client, sandbox, discovered_policy, workspace).await?;
// Re-fetch from the gateway to get the canonical version/hash.
fetch_policy_with_client(&mut client, sandbox_id)
@@ -668,10 +701,15 @@ pub async fn discover_and_sync_policy(
///
/// Used by the supervisor to push baseline-path-enriched policies so the
/// gateway stores the effective policy users see via `openshell sandbox get`.
pub async fn sync_policy(endpoint: &str, sandbox: &str, policy: &ProtoSandboxPolicy) -> Result<()> {
pub async fn sync_policy(
endpoint: &str,
sandbox: &str,
policy: &ProtoSandboxPolicy,
workspace: &str,
) -> Result<()> {
debug!(endpoint = %endpoint, sandbox = %sandbox, "Syncing enriched policy to gateway");
let mut client = connect(endpoint).await?;
sync_policy_with_client(&mut client, sandbox, policy).await
sync_policy_with_client(&mut client, sandbox, policy, workspace).await
}
/// Sync an enriched policy and return the authoritative revision snapshot.
@@ -680,9 +718,10 @@ pub async fn sync_policy_and_fetch_snapshot(
sandbox_id: &str,
sandbox: &str,
policy: &ProtoSandboxPolicy,
workspace: &str,
) -> Result<SettingsPollResult> {
let mut client = connect(endpoint).await?;
sync_policy_with_client(&mut client, sandbox, policy).await?;
sync_policy_with_client(&mut client, sandbox, policy, workspace).await?;
fetch_settings_snapshot_with_client(&mut client, sandbox_id).await
}
@@ -722,6 +761,7 @@ pub async fn fetch_provider_environment(
#[derive(Clone)]
pub struct CachedOpenShellClient {
client: OpenShellClient<AuthedChannel>,
workspace: Arc<tokio::sync::OnceCell<String>>,
}
/// Settings poll result returned by [`CachedOpenShellClient::poll_settings`].
@@ -738,6 +778,8 @@ pub struct SettingsPollResult {
pub global_policy_version: u32,
pub provider_env_revision: u64,
pub supervisor_middleware_services: Vec<crate::proto::SupervisorMiddlewareService>,
/// Workspace the sandbox belongs to.
pub workspace: String,
}
fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> SettingsPollResult {
@@ -752,6 +794,7 @@ fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> Settin
global_policy_version: inner.global_policy_version,
provider_env_revision: inner.provider_env_revision,
supervisor_middleware_services: inner.supervisor_middleware_services,
workspace: inner.workspace,
}
}
@@ -766,7 +809,10 @@ impl CachedOpenShellClient {
pub async fn connect(endpoint: &str) -> Result<Self> {
debug!(endpoint = %endpoint, "Connecting openshell gRPC client for policy polling");
let client = connect(endpoint).await?;
Ok(Self { client })
Ok(Self {
client,
workspace: Arc::new(tokio::sync::OnceCell::new()),
})
}
/// Get a clone of the underlying tonic client for direct RPC calls.
@@ -785,7 +831,20 @@ impl CachedOpenShellClient {
.await
.into_diagnostic()?;
Ok(settings_poll_result(response.into_inner()))
let result = settings_poll_result(response.into_inner());
let _ = self.workspace.set(result.workspace.clone());
Ok(result)
}
/// Returns the workspace learned from the server, or empty if not yet polled.
pub fn workspace(&self) -> String {
self.workspace.get().cloned().unwrap_or_default()
}
/// Pre-seed the workspace without polling. The value is ignored if the
/// workspace was already learned from `poll_settings`.
pub fn set_workspace(&self, workspace: String) {
let _ = self.workspace.set(workspace);
}
/// Submit denial summaries and/or agent-authored proposals for policy analysis.
@@ -811,6 +870,7 @@ impl CachedOpenShellClient {
proposed_chunks,
network_activity_summaries,
analysis_mode: analysis_mode.to_string(),
workspace: self.workspace(),
})
.await
.into_diagnostic()?;
@@ -833,6 +893,7 @@ impl CachedOpenShellClient {
.get_draft_policy(GetDraftPolicyRequest {
name: sandbox_name.to_string(),
status_filter: status_filter.to_string(),
workspace: self.workspace(),
})
.await
.into_diagnostic()?;
+3 -1
View File
@@ -47,7 +47,9 @@ pub use config::{
MtlsAuthConfig, OidcConfig, TlsConfig,
};
pub use error::{ComputeDriverError, Error, Result};
pub use metadata::{GetResourceVersion, ObjectId, ObjectLabels, ObjectName, SetResourceVersion};
pub use metadata::{
GetResourceVersion, ObjectId, ObjectLabels, ObjectName, ObjectWorkspace, SetResourceVersion,
};
/// Build version string derived from git metadata.
///
+165 -1
View File
@@ -7,7 +7,7 @@
use crate::proto::{
InferenceRoute, ObjectForTest, Provider, Sandbox, SandboxStatus, ServiceEndpoint, SshSession,
StoredProviderCredentialRefreshState, StoredProviderProfile,
StoredProviderCredentialRefreshState, StoredProviderProfile, Workspace, WorkspaceMember,
};
use std::collections::HashMap;
@@ -36,6 +36,12 @@ pub trait GetResourceVersion {
fn get_resource_version(&self) -> u64;
}
/// Provides access to the object's workspace for persistence scoping.
pub trait ObjectWorkspace {
fn object_workspace(&self) -> &str;
fn requires_workspace() -> bool;
}
// Implementations for Sandbox
impl ObjectId for Sandbox {
fn object_id(&self) -> &str {
@@ -69,6 +75,15 @@ impl GetResourceVersion for Sandbox {
}
}
impl ObjectWorkspace for Sandbox {
fn object_workspace(&self) -> &str {
self.metadata.as_ref().map_or("", |m| m.workspace.as_str())
}
fn requires_workspace() -> bool {
true
}
}
impl Sandbox {
pub fn phase(&self) -> i32 {
self.status.as_ref().map_or(0, |s| s.phase)
@@ -89,6 +104,49 @@ impl Sandbox {
}
}
// Implementations for Workspace
impl ObjectId for Workspace {
fn object_id(&self) -> &str {
self.metadata.as_ref().map_or("", |m| m.id.as_str())
}
}
impl ObjectName for Workspace {
fn object_name(&self) -> &str {
self.metadata.as_ref().map_or("", |m| m.name.as_str())
}
}
impl ObjectLabels for Workspace {
fn object_labels(&self) -> Option<HashMap<String, String>> {
self.metadata.as_ref().map(|m| m.labels.clone())
}
}
impl SetResourceVersion for Workspace {
fn set_resource_version(&mut self, version: u64) {
if let Some(meta) = self.metadata.as_mut() {
meta.resource_version = version;
}
}
}
impl GetResourceVersion for Workspace {
fn get_resource_version(&self) -> u64 {
self.metadata.as_ref().map_or(0, |m| m.resource_version)
}
}
impl ObjectWorkspace for Workspace {
#[allow(clippy::unnecessary_literal_bound)]
fn object_workspace(&self) -> &str {
""
}
fn requires_workspace() -> bool {
false
}
}
// Implementations for Provider
impl ObjectId for Provider {
fn object_id(&self) -> &str {
@@ -122,6 +180,15 @@ impl GetResourceVersion for Provider {
}
}
impl ObjectWorkspace for Provider {
fn object_workspace(&self) -> &str {
self.metadata.as_ref().map_or("", |m| m.workspace.as_str())
}
fn requires_workspace() -> bool {
true
}
}
// Implementations for StoredProviderProfile
impl ObjectId for StoredProviderProfile {
fn object_id(&self) -> &str {
@@ -155,6 +222,15 @@ impl GetResourceVersion for StoredProviderProfile {
}
}
impl ObjectWorkspace for StoredProviderProfile {
fn object_workspace(&self) -> &str {
self.metadata.as_ref().map_or("", |m| m.workspace.as_str())
}
fn requires_workspace() -> bool {
false
}
}
// Implementations for StoredProviderCredentialRefreshState
impl ObjectId for StoredProviderCredentialRefreshState {
fn object_id(&self) -> &str {
@@ -188,6 +264,15 @@ impl GetResourceVersion for StoredProviderCredentialRefreshState {
}
}
impl ObjectWorkspace for StoredProviderCredentialRefreshState {
fn object_workspace(&self) -> &str {
self.metadata.as_ref().map_or("", |m| m.workspace.as_str())
}
fn requires_workspace() -> bool {
true
}
}
// Implementations for SshSession
impl ObjectId for SshSession {
fn object_id(&self) -> &str {
@@ -221,6 +306,15 @@ impl GetResourceVersion for SshSession {
}
}
impl ObjectWorkspace for SshSession {
fn object_workspace(&self) -> &str {
self.metadata.as_ref().map_or("", |m| m.workspace.as_str())
}
fn requires_workspace() -> bool {
true
}
}
// Implementations for ServiceEndpoint
impl ObjectId for ServiceEndpoint {
fn object_id(&self) -> &str {
@@ -254,6 +348,15 @@ impl GetResourceVersion for ServiceEndpoint {
}
}
impl ObjectWorkspace for ServiceEndpoint {
fn object_workspace(&self) -> &str {
self.metadata.as_ref().map_or("", |m| m.workspace.as_str())
}
fn requires_workspace() -> bool {
true
}
}
// Implementations for InferenceRoute
impl ObjectId for InferenceRoute {
fn object_id(&self) -> &str {
@@ -287,6 +390,57 @@ impl GetResourceVersion for InferenceRoute {
}
}
impl ObjectWorkspace for InferenceRoute {
fn object_workspace(&self) -> &str {
self.metadata.as_ref().map_or("", |m| m.workspace.as_str())
}
fn requires_workspace() -> bool {
true
}
}
// Implementations for WorkspaceMember
impl ObjectId for WorkspaceMember {
fn object_id(&self) -> &str {
self.metadata.as_ref().map_or("", |m| m.id.as_str())
}
}
impl ObjectName for WorkspaceMember {
fn object_name(&self) -> &str {
self.metadata.as_ref().map_or("", |m| m.name.as_str())
}
}
impl ObjectLabels for WorkspaceMember {
fn object_labels(&self) -> Option<HashMap<String, String>> {
self.metadata.as_ref().map(|m| m.labels.clone())
}
}
impl SetResourceVersion for WorkspaceMember {
fn set_resource_version(&mut self, version: u64) {
if let Some(meta) = self.metadata.as_mut() {
meta.resource_version = version;
}
}
}
impl GetResourceVersion for WorkspaceMember {
fn get_resource_version(&self) -> u64 {
self.metadata.as_ref().map_or(0, |m| m.resource_version)
}
}
impl ObjectWorkspace for WorkspaceMember {
fn object_workspace(&self) -> &str {
self.metadata.as_ref().map_or("", |m| m.workspace.as_str())
}
fn requires_workspace() -> bool {
true
}
}
// Implementations for ObjectForTest (test-only proto type)
impl ObjectId for ObjectForTest {
fn object_id(&self) -> &str {
@@ -318,3 +472,13 @@ impl GetResourceVersion for ObjectForTest {
0
}
}
impl ObjectWorkspace for ObjectForTest {
#[allow(clippy::unnecessary_literal_bound)]
fn object_workspace(&self) -> &str {
""
}
fn requires_workspace() -> bool {
false
}
}
+137 -22
View File
@@ -10,6 +10,7 @@
//! - The mechanistic mapper for proposal filtering
//! - The gateway server for defense-in-depth validation on approval
use ipnet::{IpNet, Ipv4Net, Ipv6Net};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
/// Check if a hostname is a known cloud metadata hostname that resolves to an
@@ -80,9 +81,9 @@ pub fn is_always_blocked_ip(ip: IpAddr) -> bool {
///
/// Used at policy load time and server-side approval to reject entries that
/// would be silently blocked at runtime by [`is_always_blocked_ip`].
pub fn is_always_blocked_net(net: ipnet::IpNet) -> bool {
pub fn is_always_blocked_net(net: IpNet) -> bool {
match net {
ipnet::IpNet::V4(v4net) => {
IpNet::V4(v4net) => {
let network = v4net.network();
let broadcast = v4net.broadcast();
@@ -107,7 +108,7 @@ pub fn is_always_blocked_net(net: ipnet::IpNet) -> bool {
false
}
ipnet::IpNet::V6(v6net) => {
IpNet::V6(v6net) => {
// For IPv6, check the network address itself and representative
// addresses within the range.
let network = v6net.network();
@@ -155,6 +156,43 @@ pub fn is_always_blocked_net(net: ipnet::IpNet) -> bool {
}
}
const RFC1918_10_NET: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(10, 0, 0, 0), 8);
const RFC1918_172_NET: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(172, 16, 0, 0), 12);
const RFC1918_192_NET: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(192, 168, 0, 0), 16);
const CGNAT_NET: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(100, 64, 0, 0), 10);
const IETF_PROTOCOL_ASSIGNMENTS_NET: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(192, 0, 0, 0), 24);
const TEST_NET_1: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(192, 0, 2, 0), 24);
const BENCHMARKING_NET: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(198, 18, 0, 0), 15);
const TEST_NET_2: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(198, 51, 100, 0), 24);
const TEST_NET_3: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(203, 0, 113, 0), 24);
const LIMITED_BROADCAST_NET: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::BROADCAST, 32);
const IPV6_ULA_NET: Ipv6Net = Ipv6Net::new_assert(Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 0), 7);
const NON_HARD_INTERNAL_V4_NETS: [Ipv4Net; 10] = [
RFC1918_10_NET,
RFC1918_172_NET,
RFC1918_192_NET,
CGNAT_NET,
IETF_PROTOCOL_ASSIGNMENTS_NET,
TEST_NET_1,
BENCHMARKING_NET,
TEST_NET_2,
TEST_NET_3,
LIMITED_BROADCAST_NET,
];
fn ipv4_nets_intersect(left: Ipv4Net, right: Ipv4Net) -> bool {
left.network() <= right.broadcast() && right.network() <= left.broadcast()
}
fn ipv6_nets_intersect(left: Ipv6Net, right: Ipv6Net) -> bool {
left.network() <= right.broadcast() && right.network() <= left.broadcast()
}
fn ipv4_net_to_mapped_ipv6(net: Ipv4Net) -> Ipv6Net {
Ipv6Net::new_assert(net.network().to_ipv6_mapped(), 96_u8 + net.prefix_len())
}
/// Check if an IP address is internal (loopback, private RFC 1918, link-local,
/// or unspecified).
///
@@ -188,6 +226,26 @@ pub fn is_internal_ip(ip: IpAddr) -> bool {
}
}
/// Check if a CIDR network intersects any address range classified by
/// [`is_internal_ip`].
pub fn is_internal_net(net: IpNet) -> bool {
if is_always_blocked_net(net) {
return true;
}
match net {
IpNet::V4(net) => NON_HARD_INTERNAL_V4_NETS
.iter()
.any(|internal| ipv4_nets_intersect(net, *internal)),
IpNet::V6(net) => {
ipv6_nets_intersect(net, IPV6_ULA_NET)
|| NON_HARD_INTERNAL_V4_NETS
.iter()
.any(|internal| ipv6_nets_intersect(net, ipv4_net_to_mapped_ipv6(*internal)))
}
}
}
/// IPv4 internal address check covering RFC 1918, CGNAT (RFC 6598), and other
/// special-use ranges that should never be reachable from sandbox egress.
fn is_internal_v4(v4: Ipv4Addr) -> bool {
@@ -382,45 +440,45 @@ mod tests {
#[test]
fn test_always_blocked_net_loopback_v4() {
let net: ipnet::IpNet = "127.0.0.0/8".parse().unwrap();
let net: IpNet = "127.0.0.0/8".parse().unwrap();
assert!(is_always_blocked_net(net));
}
#[test]
fn test_always_blocked_net_link_local_v4() {
let net: ipnet::IpNet = "169.254.0.0/16".parse().unwrap();
let net: IpNet = "169.254.0.0/16".parse().unwrap();
assert!(is_always_blocked_net(net));
}
#[test]
fn test_always_blocked_net_unspecified_v4() {
let net: ipnet::IpNet = "0.0.0.0/32".parse().unwrap();
let net: IpNet = "0.0.0.0/32".parse().unwrap();
assert!(is_always_blocked_net(net));
}
#[test]
fn test_always_blocked_net_loopback_v6() {
let net: ipnet::IpNet = "::1/128".parse().unwrap();
let net: IpNet = "::1/128".parse().unwrap();
assert!(is_always_blocked_net(net));
}
#[test]
fn test_always_blocked_net_link_local_v6() {
let net: ipnet::IpNet = "fe80::/10".parse().unwrap();
let net: IpNet = "fe80::/10".parse().unwrap();
assert!(is_always_blocked_net(net));
}
#[test]
fn test_always_blocked_net_ipv4_mapped_v6_loopback() {
let net: ipnet::IpNet = "::ffff:127.0.0.1/128".parse().unwrap();
let net: IpNet = "::ffff:127.0.0.1/128".parse().unwrap();
assert!(is_always_blocked_net(net));
}
#[test]
fn test_always_blocked_net_allows_rfc1918() {
let net10: ipnet::IpNet = "10.0.0.0/8".parse().unwrap();
let net172: ipnet::IpNet = "172.16.0.0/12".parse().unwrap();
let net192: ipnet::IpNet = "192.168.0.0/16".parse().unwrap();
let net10: IpNet = "10.0.0.0/8".parse().unwrap();
let net172: IpNet = "172.16.0.0/12".parse().unwrap();
let net192: IpNet = "192.168.0.0/16".parse().unwrap();
assert!(!is_always_blocked_net(net10));
assert!(!is_always_blocked_net(net172));
assert!(!is_always_blocked_net(net192));
@@ -428,44 +486,44 @@ mod tests {
#[test]
fn test_always_blocked_net_allows_public() {
let net: ipnet::IpNet = "8.8.8.0/24".parse().unwrap();
let net: IpNet = "8.8.8.0/24".parse().unwrap();
assert!(!is_always_blocked_net(net));
}
#[test]
fn test_always_blocked_net_single_ip_loopback() {
let net: ipnet::IpNet = "127.0.0.1/32".parse().unwrap();
let net: IpNet = "127.0.0.1/32".parse().unwrap();
assert!(is_always_blocked_net(net));
}
#[test]
fn test_always_blocked_net_single_ip_metadata() {
let net: ipnet::IpNet = "169.254.169.254/32".parse().unwrap();
let net: IpNet = "169.254.169.254/32".parse().unwrap();
assert!(is_always_blocked_net(net));
}
#[test]
fn test_always_blocked_net_broad_cidr_containing_blocked() {
// 0.0.0.0/0 contains everything including unspecified, loopback, link-local
let net: ipnet::IpNet = "0.0.0.0/0".parse().unwrap();
let net: IpNet = "0.0.0.0/0".parse().unwrap();
assert!(is_always_blocked_net(net));
}
#[test]
fn test_always_blocked_net_v6_broad_containing_loopback() {
let net: ipnet::IpNet = "::/0".parse().unwrap();
let net: IpNet = "::/0".parse().unwrap();
assert!(is_always_blocked_net(net));
}
#[test]
fn test_always_blocked_net_v6_ipv4_mapped_loopback_single() {
let net: ipnet::IpNet = "::ffff:127.0.0.1/128".parse().unwrap();
let net: IpNet = "::ffff:127.0.0.1/128".parse().unwrap();
assert!(is_always_blocked_net(net));
}
#[test]
fn test_always_blocked_net_v6_ipv4_mapped_link_local_single() {
let net: ipnet::IpNet = "::ffff:169.254.0.1/128".parse().unwrap();
let net: IpNet = "::ffff:169.254.0.1/128".parse().unwrap();
assert!(is_always_blocked_net(net));
}
@@ -474,7 +532,7 @@ mod tests {
// ::ffff:168.0.0.0/103 has a public network address (168.0.0.0) but
// the range covers 168.0.0.0–169.255.255.255, which includes the
// link-local block 169.254.0.0/16.
let net: ipnet::IpNet = "::ffff:168.0.0.0/103".parse().unwrap();
let net: IpNet = "::ffff:168.0.0.0/103".parse().unwrap();
assert!(is_always_blocked_net(net));
}
@@ -482,17 +540,74 @@ mod tests {
fn test_always_blocked_net_v6_ipv4_mapped_broad_spans_loopback() {
// ::ffff:64.0.0.0/98 has a public network address (64.0.0.0) but the
// range covers 64.0.0.0–127.255.255.255, which includes loopback.
let net: ipnet::IpNet = "::ffff:64.0.0.0/98".parse().unwrap();
let net: IpNet = "::ffff:64.0.0.0/98".parse().unwrap();
assert!(is_always_blocked_net(net));
}
#[test]
fn test_always_blocked_net_v6_ipv4_mapped_allows_public() {
// ::ffff:8.8.8.8/128 is a public address — should not be blocked.
let net: ipnet::IpNet = "::ffff:8.8.8.8/128".parse().unwrap();
let net: IpNet = "::ffff:8.8.8.8/128".parse().unwrap();
assert!(!is_always_blocked_net(net));
}
// -- is_internal_net --
#[test]
fn test_internal_net_rfc1918_contained_and_supernet() {
for cidr in ["10.1.0.0/16", "8.0.0.0/5"] {
assert!(is_internal_net(cidr.parse().unwrap()), "{cidr}");
}
}
#[test]
fn test_internal_net_cgnat_contained_and_supernet() {
for cidr in ["100.64.0.0/10", "100.0.0.0/9"] {
assert!(is_internal_net(cidr.parse().unwrap()), "{cidr}");
}
}
#[test]
fn test_internal_net_ipv4_special_use_ranges() {
for cidr in [
"192.0.0.0/24",
"192.0.2.0/24",
"198.18.0.0/15",
"198.51.100.0/24",
"203.0.113.0/24",
"255.255.255.255/32",
] {
assert!(is_internal_net(cidr.parse().unwrap()), "{cidr}");
}
}
#[test]
fn test_internal_net_ipv6_ula() {
for cidr in ["fc00::/7", "fd00::/8"] {
assert!(is_internal_net(cidr.parse().unwrap()), "{cidr}");
}
}
#[test]
fn test_internal_net_ipv4_mapped_cgnat_supernet() {
let net: IpNet = "::ffff:100.0.0.0/105".parse().unwrap();
assert!(is_internal_net(net));
}
#[test]
fn test_internal_net_always_blocked() {
for cidr in ["127.0.0.0/8", "fe80::/10"] {
assert!(is_internal_net(cidr.parse().unwrap()), "{cidr}");
}
}
#[test]
fn test_internal_net_allows_large_public_ranges() {
for cidr in ["8.0.0.0/8", "::ffff:8.0.0.0/104"] {
assert!(!is_internal_net(cidr.parse().unwrap()), "{cidr}");
}
}
// -- is_internal_ip --
#[test]
+25 -12
View File
@@ -25,7 +25,8 @@ use openshell_core::config::{
use openshell_core::driver_mounts;
use openshell_core::driver_utils::{
LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME,
LABEL_SANDBOX_NAMESPACE, SUPERVISOR_IMAGE_BINARY_PATH, supervisor_image_should_refresh,
LABEL_SANDBOX_NAMESPACE, LABEL_SANDBOX_WORKSPACE, SUPERVISOR_IMAGE_BINARY_PATH,
supervisor_image_should_refresh,
};
use openshell_core::gpu::{
CdiGpuDefaultSelector, CdiGpuInventory, CdiGpuSelectionError, driver_gpu_requirements,
@@ -1571,6 +1572,7 @@ fn pending_sandbox_snapshot(
conditions: vec![condition],
deleting,
}),
workspace: sandbox.workspace.clone(),
}
}
@@ -2336,6 +2338,10 @@ fn build_container_create_body_with_gpu_devices(
);
labels.insert(LABEL_SANDBOX_ID.to_string(), sandbox.id.clone());
labels.insert(LABEL_SANDBOX_NAME.to_string(), sandbox.name.clone());
labels.insert(
LABEL_SANDBOX_WORKSPACE.to_string(),
sandbox.workspace.clone(),
);
// The list/get/find paths filter by `config.sandbox_namespace`, so use
// the same value here. `DriverSandbox.namespace` is unset on the request
// path (the gateway elides it), and using it would produce containers
@@ -2783,6 +2789,10 @@ fn sandbox_from_container_summary(
.get(LABEL_SANDBOX_NAMESPACE)
.cloned()
.unwrap_or_default();
let workspace = labels
.get(LABEL_SANDBOX_WORKSPACE)
.cloned()
.unwrap_or_default();
let supervisor_connected = readiness.is_supervisor_connected(&id);
Some(DriverSandbox {
@@ -2795,6 +2805,7 @@ fn sandbox_from_container_summary(
&name,
supervisor_connected,
)),
workspace,
})
}
@@ -2968,24 +2979,26 @@ const CONTAINER_NAME_PREFIX: &str = "openshell-";
fn container_name_for_sandbox(sandbox: &DriverSandbox) -> String {
let id_suffix = sanitize_docker_name(&sandbox.id);
let workspace = sanitize_docker_name(&sandbox.workspace);
let name = sanitize_docker_name(&sandbox.name);
// Format: openshell-{workspace}--{name}-{id}
// The workspace and id are never truncated — they ensure uniqueness.
// Only the sandbox name portion is truncated when the total exceeds
// MAX_CONTAINER_NAME_LEN.
if name.is_empty() {
let mut base = format!("{CONTAINER_NAME_PREFIX}{id_suffix}");
// The prefix is always < MAX_CONTAINER_NAME_LEN. Truncate the id
// suffix only if the sandbox id itself is pathologically long.
let mut base = format!("{CONTAINER_NAME_PREFIX}{workspace}---{id_suffix}");
if base.len() > MAX_CONTAINER_NAME_LEN {
base.truncate(MAX_CONTAINER_NAME_LEN);
}
return base;
return trim_container_name_tail(base);
}
// Reserve space for the prefix and the `-<id_suffix>` tail so the id
// suffix — which is what makes the name unique between sandboxes that
// share a human-readable prefix — is never truncated away.
let reserved = CONTAINER_NAME_PREFIX.len() + 1 + id_suffix.len();
// Reserve space for fixed parts: prefix + workspace + "--" + "-" + id
let reserved = CONTAINER_NAME_PREFIX.len() + workspace.len() + 2 + 1 + id_suffix.len();
if reserved >= MAX_CONTAINER_NAME_LEN {
// Pathological sandbox id. Fall back to `<prefix><id>` and truncate.
let mut base = format!("{CONTAINER_NAME_PREFIX}{id_suffix}");
let mut base = format!("{CONTAINER_NAME_PREFIX}{workspace}---{id_suffix}");
base.truncate(MAX_CONTAINER_NAME_LEN);
return trim_container_name_tail(base);
}
@@ -2996,7 +3009,7 @@ fn container_name_for_sandbox(sandbox: &DriverSandbox) -> String {
} else {
name
};
format!("{CONTAINER_NAME_PREFIX}{truncated_name}-{id_suffix}")
format!("{CONTAINER_NAME_PREFIX}{workspace}--{truncated_name}-{id_suffix}")
}
/// Docker container names may not end with `-`, `.`, or `_`. Truncation can
+8 -2
View File
@@ -46,6 +46,7 @@ fn test_sandbox() -> DriverSandbox {
sandbox_token: String::new(),
}),
status: None,
workspace: String::new(),
}
}
@@ -2018,6 +2019,7 @@ fn container_name_preserves_id_suffix_for_long_names() {
namespace: "default".to_string(),
spec: None,
status: None,
workspace: "default".to_string(),
};
let second = DriverSandbox {
id: "sbx-second-0987654321".to_string(),
@@ -2043,15 +2045,19 @@ fn container_name_preserves_id_suffix_for_long_names() {
}
#[test]
fn container_name_empty_sandbox_name_uses_id_only() {
fn container_name_empty_sandbox_name_uses_workspace_and_id() {
let sandbox = DriverSandbox {
id: "sbx-abc".to_string(),
name: String::new(),
namespace: "default".to_string(),
spec: None,
status: None,
workspace: "default".to_string(),
};
assert_eq!(container_name_for_sandbox(&sandbox), "openshell-sbx-abc",);
assert_eq!(
container_name_for_sandbox(&sandbox),
"openshell-default---sbx-abc",
);
}
#[test]
+420 -116
View File
@@ -13,14 +13,15 @@ use futures::{Stream, StreamExt, TryStreamExt};
use k8s_openapi::api::core::v1::{
Event as KubeEventObj, Namespace, Node, PersistentVolumeClaimVolumeSource, Volume, VolumeMount,
};
use kube::api::{Api, ApiResource, DeleteParams, ListParams, PostParams};
use kube::api::{Api, ApiResource, DeleteParams, ListParams, PostParams, Preconditions};
use kube::core::gvk::GroupVersionKind;
use kube::core::{DynamicObject, ObjectMeta};
use kube::runtime::watcher::{self, Event};
use kube::{Client, Error as KubeError};
use openshell_core::driver_mounts;
use openshell_core::driver_utils::{
LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, LABEL_SANDBOX_ID, SUPERVISOR_IMAGE_BINARY_PATH,
LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME,
LABEL_SANDBOX_WORKSPACE, SUPERVISOR_IMAGE_BINARY_PATH, openshell_sandbox_label_selector,
};
use openshell_core::gpu::{driver_gpu_requirements, effective_driver_gpu_count};
use openshell_core::progress::{
@@ -667,6 +668,7 @@ impl KubernetesComputeDriver {
let _ = self
.validate_driver_config_for_sandbox(sandbox)
.map_err(tonic::Status::invalid_argument)?;
validate_kube_resource_name_length(&sandbox.workspace, &sandbox.name)?;
let gpu_requirements = sandbox
.spec
.as_ref()
@@ -684,9 +686,9 @@ impl KubernetesComputeDriver {
Ok(())
}
pub async fn get_sandbox(&self, name: &str) -> Result<Option<Sandbox>, String> {
pub async fn get_sandbox(&self, sandbox_id: &str) -> Result<Option<Sandbox>, String> {
info!(
sandbox_name = %name,
sandbox_id = %sandbox_id,
namespace = %self.config.namespace,
"Fetching sandbox from Kubernetes"
);
@@ -694,15 +696,24 @@ impl KubernetesComputeDriver {
let agent_sandbox_api = self
.supported_agent_sandbox_api(self.client.clone())
.await?;
match tokio::time::timeout(KUBE_API_TIMEOUT, agent_sandbox_api.api.get(name)).await {
Ok(Ok(obj)) => sandbox_from_object(&self.config.namespace, obj).map(Some),
Ok(Err(KubeError::Api(err))) if err.code == 404 => {
debug!(sandbox_name = %name, "Sandbox not found in Kubernetes");
Ok(None)
}
let selector =
format!("{LABEL_MANAGED_BY}={LABEL_MANAGED_BY_VALUE},{LABEL_SANDBOX_ID}={sandbox_id}");
let lp = ListParams::default().labels(&selector);
match tokio::time::timeout(KUBE_API_TIMEOUT, agent_sandbox_api.api.list(&lp)).await {
Ok(Ok(list)) => list.items.into_iter().next().map_or_else(
|| {
debug!(sandbox_id = %sandbox_id, "Sandbox not found in Kubernetes");
Ok(None)
},
|obj| {
Ok(sandbox_from_object(&self.config.namespace, obj)
.ok()
.map(|(_, s)| s))
},
),
Ok(Err(err)) => {
warn!(
sandbox_name = %name,
sandbox_id = %sandbox_id,
error = %err,
"Failed to fetch sandbox from Kubernetes"
);
@@ -710,7 +721,7 @@ impl KubernetesComputeDriver {
}
Err(_elapsed) => {
warn!(
sandbox_name = %name,
sandbox_id = %sandbox_id,
timeout_secs = KUBE_API_TIMEOUT.as_secs(),
"Timed out fetching sandbox from Kubernetes"
);
@@ -733,16 +744,27 @@ impl KubernetesComputeDriver {
.await?;
match tokio::time::timeout(
KUBE_API_TIMEOUT,
agent_sandbox_api.api.list(&ListParams::default()),
agent_sandbox_api
.api
.list(&ListParams::default().labels(&openshell_sandbox_label_selector())),
)
.await
{
Ok(Ok(list)) => {
let mut sandboxes = list
let mut sandboxes: Vec<Sandbox> = list
.items
.into_iter()
.map(|obj| sandbox_from_object(&self.config.namespace, obj))
.collect::<Result<Vec<_>, _>>()?;
.filter_map(|obj| {
let name = obj.metadata.name.clone().unwrap_or_default();
match sandbox_from_object(&self.config.namespace, obj) {
Ok((_, s)) => Some(s),
Err(err) => {
warn!(object_name = %name, error = %err, "skipping unrecognized Sandbox in list");
None
}
}
})
.collect();
sandboxes.sort_by(|left, right| {
left.name
.cmp(&right.name)
@@ -839,30 +861,25 @@ impl KubernetesComputeDriver {
let data = sandbox_to_k8s_spec(sandbox.spec.as_ref(), &params)
.map_err(KubernetesDriverError::InvalidArgument)?;
let mut obj = DynamicObject::new(name, &agent_sandbox_api.resource);
let kube_name = kube_resource_name(&sandbox.workspace, name);
let mut obj = DynamicObject::new(&kube_name, &agent_sandbox_api.resource);
// Copy only the SCC-related annotations onto the Sandbox CR for
// traceability. Copying the full namespace annotation map exposes
// unrelated cluster metadata and can fail with oversized annotations.
let scc_annotations: BTreeMap<String, String> = [
let mut annotations = sandbox_annotations(sandbox);
for key in [
crate::config::ANNOTATION_SCC_UID_RANGE,
crate::config::ANNOTATION_SCC_SUPPLEMENTAL_GROUPS,
]
.iter()
.filter_map(|key| {
ns_annotations
.get(*key)
.map(|v| ((*key).to_string(), v.clone()))
})
.collect();
] {
if let Some(v) = ns_annotations.get(key) {
annotations.insert(key.to_string(), v.clone());
}
}
obj.metadata = ObjectMeta {
name: Some(name.to_string()),
name: Some(kube_name),
namespace: Some(self.config.namespace.clone()),
labels: Some(sandbox_labels(sandbox)),
annotations: if scc_annotations.is_empty() {
None
} else {
Some(scc_annotations)
},
annotations: Some(annotations),
..Default::default()
};
@@ -905,9 +922,9 @@ impl KubernetesComputeDriver {
}
}
pub async fn delete_sandbox(&self, name: &str) -> Result<bool, String> {
pub async fn delete_sandbox(&self, sandbox_id: &str) -> Result<bool, String> {
info!(
sandbox_name = %name,
sandbox_id = %sandbox_id,
namespace = %self.config.namespace,
"Deleting sandbox from Kubernetes"
);
@@ -915,23 +932,71 @@ impl KubernetesComputeDriver {
let agent_sandbox_api = self
.supported_agent_sandbox_api(self.client.clone())
.await?;
let selector =
format!("{LABEL_MANAGED_BY}={LABEL_MANAGED_BY_VALUE},{LABEL_SANDBOX_ID}={sandbox_id}");
let lp = ListParams::default().labels(&selector);
let (kube_name, preconditions) = match tokio::time::timeout(
KUBE_API_TIMEOUT,
agent_sandbox_api.api.list(&lp),
)
.await
{
Ok(Ok(list)) => {
if let Some(obj) = list.items.into_iter().next() {
match obj.metadata.name {
Some(name) => {
let pc = Preconditions {
uid: obj.metadata.uid,
resource_version: obj.metadata.resource_version,
};
(name, pc)
}
None => return Ok(false),
}
} else {
debug!(sandbox_id = %sandbox_id, "Sandbox not found in Kubernetes (already deleted)");
return Ok(false);
}
}
Ok(Err(err)) => {
warn!(
sandbox_id = %sandbox_id,
error = %err,
"Failed to list sandbox for deletion from Kubernetes"
);
return Err(err.to_string());
}
Err(_elapsed) => {
warn!(
sandbox_id = %sandbox_id,
timeout_secs = KUBE_API_TIMEOUT.as_secs(),
"Timed out listing sandbox for deletion from Kubernetes"
);
return Err(format!(
"timed out after {}s waiting for Kubernetes API",
KUBE_API_TIMEOUT.as_secs()
));
}
};
let dp = DeleteParams::default().preconditions(preconditions);
match tokio::time::timeout(
KUBE_API_TIMEOUT,
agent_sandbox_api.api.delete(name, &DeleteParams::default()),
agent_sandbox_api.api.delete(&kube_name, &dp),
)
.await
{
Ok(Ok(_response)) => {
info!(sandbox_name = %name, "Sandbox deleted from Kubernetes");
info!(sandbox_id = %sandbox_id, "Sandbox deleted from Kubernetes");
Ok(true)
}
Ok(Err(KubeError::Api(err))) if err.code == 404 => {
debug!(sandbox_name = %name, "Sandbox not found in Kubernetes (already deleted)");
Ok(Err(KubeError::Api(err))) if err.code == 404 || err.code == 409 => {
debug!(sandbox_id = %sandbox_id, "Sandbox not found in Kubernetes (already deleted or replaced)");
Ok(false)
}
Ok(Err(err)) => {
warn!(
sandbox_name = %name,
sandbox_id = %sandbox_id,
error = %err,
"Failed to delete sandbox from Kubernetes"
);
@@ -939,7 +1004,7 @@ impl KubernetesComputeDriver {
}
Err(_elapsed) => {
warn!(
sandbox_name = %name,
sandbox_id = %sandbox_id,
timeout_secs = KUBE_API_TIMEOUT.as_secs(),
"Timed out deleting sandbox from Kubernetes"
);
@@ -951,13 +1016,15 @@ impl KubernetesComputeDriver {
}
}
pub async fn sandbox_exists(&self, name: &str) -> Result<bool, String> {
pub async fn sandbox_exists(&self, sandbox_id: &str) -> Result<bool, String> {
let agent_sandbox_api = self
.supported_agent_sandbox_api(self.client.clone())
.await?;
match tokio::time::timeout(KUBE_API_TIMEOUT, agent_sandbox_api.api.get(name)).await {
Ok(Ok(_)) => Ok(true),
Ok(Err(KubeError::Api(err))) if err.code == 404 => Ok(false),
let selector =
format!("{LABEL_MANAGED_BY}={LABEL_MANAGED_BY_VALUE},{LABEL_SANDBOX_ID}={sandbox_id}");
let lp = ListParams::default().labels(&selector);
match tokio::time::timeout(KUBE_API_TIMEOUT, agent_sandbox_api.api.list(&lp)).await {
Ok(Ok(list)) => Ok(!list.items.is_empty()),
Ok(Err(err)) => Err(err.to_string()),
Err(_elapsed) => Err(format!(
"timed out after {}s waiting for Kubernetes API",
@@ -974,8 +1041,8 @@ impl KubernetesComputeDriver {
.supported_agent_sandbox_api(self.watch_client.clone())
.await?;
let event_api: Api<KubeEventObj> = Api::namespaced(self.watch_client.clone(), &namespace);
let mut sandbox_stream =
watcher::watcher(agent_sandbox_api.api, watcher::Config::default()).boxed();
let watcher_config = watcher::Config::default().labels(&openshell_sandbox_label_selector());
let mut sandbox_stream = watcher::watcher(agent_sandbox_api.api, watcher_config).boxed();
let mut event_stream = watcher::watcher(event_api, watcher::Config::default()).boxed();
let (tx, rx) = mpsc::channel(256);
@@ -987,63 +1054,44 @@ impl KubernetesComputeDriver {
tokio::select! {
result = sandbox_stream.try_next() => match result {
Ok(Some(Event::Applied(obj))) => {
match sandbox_from_object(&namespace, obj) {
Ok(sandbox) => {
update_indexes(&mut sandbox_name_to_id, &mut agent_pod_to_id, &sandbox);
if let Ok((kube_name, sandbox)) = sandbox_from_object(&namespace, obj) {
update_indexes(&mut sandbox_name_to_id, &mut agent_pod_to_id, &kube_name, &sandbox);
let event = WatchSandboxesEvent {
payload: Some(watch_sandboxes_event::Payload::Sandbox(
WatchSandboxesSandboxEvent { sandbox: Some(sandbox) }
)),
};
if tx.send(Ok(event)).await.is_err() {
break;
}
}
}
Ok(Some(Event::Deleted(obj))) => {
if is_openshell_managed(&obj)
&& let Ok(sandbox_id) = sandbox_id_from_object(&obj)
{
remove_indexes(&mut sandbox_name_to_id, &mut agent_pod_to_id, &sandbox_id);
let event = WatchSandboxesEvent {
payload: Some(watch_sandboxes_event::Payload::Deleted(
WatchSandboxesDeletedEvent { sandbox_id }
)),
};
if tx.send(Ok(event)).await.is_err() {
break;
}
}
}
Ok(Some(Event::Restarted(objs))) => {
for obj in objs {
if let Ok((kube_name, sandbox)) = sandbox_from_object(&namespace, obj) {
update_indexes(&mut sandbox_name_to_id, &mut agent_pod_to_id, &kube_name, &sandbox);
let event = WatchSandboxesEvent {
payload: Some(watch_sandboxes_event::Payload::Sandbox(
WatchSandboxesSandboxEvent { sandbox: Some(sandbox) }
)),
};
if tx.send(Ok(event)).await.is_err() {
break;
}
}
Err(err) => {
if tx.send(Err(KubernetesDriverError::Message(err))).await.is_err() {
break;
}
}
}
}
Ok(Some(Event::Deleted(obj))) => {
match sandbox_id_from_object(&obj) {
Ok(sandbox_id) => {
remove_indexes(&mut sandbox_name_to_id, &mut agent_pod_to_id, &sandbox_id);
let event = WatchSandboxesEvent {
payload: Some(watch_sandboxes_event::Payload::Deleted(
WatchSandboxesDeletedEvent { sandbox_id }
)),
};
if tx.send(Ok(event)).await.is_err() {
break;
}
}
Err(err) => {
if tx.send(Err(KubernetesDriverError::Message(err))).await.is_err() {
break;
}
}
}
}
Ok(Some(Event::Restarted(objs))) => {
for obj in objs {
match sandbox_from_object(&namespace, obj) {
Ok(sandbox) => {
update_indexes(&mut sandbox_name_to_id, &mut agent_pod_to_id, &sandbox);
let event = WatchSandboxesEvent {
payload: Some(watch_sandboxes_event::Payload::Sandbox(
WatchSandboxesSandboxEvent { sandbox: Some(sandbox) }
)),
};
if tx.send(Ok(event)).await.is_err() {
return;
}
}
Err(err) => {
if tx.send(Err(KubernetesDriverError::Message(err))).await.is_err() {
return;
}
return;
}
}
}
@@ -1090,7 +1138,8 @@ impl KubernetesComputeDriver {
let _ = tx.send(Err(KubernetesDriverError::Message(err.to_string()))).await;
break;
}
}
},
() = tx.closed() => break,
}
}
});
@@ -1115,9 +1164,31 @@ fn validate_gpu_request(
Ok(())
}
fn kube_resource_name(workspace: &str, name: &str) -> String {
format!("{workspace}--{name}")
}
const MAX_KUBE_NAME_LEN: usize = 63;
fn validate_kube_resource_name_length(workspace: &str, name: &str) -> Result<(), tonic::Status> {
let combined = workspace.len() + 2 + name.len(); // "--" separator
if combined > MAX_KUBE_NAME_LEN {
return Err(tonic::Status::invalid_argument(format!(
"combined Kubernetes resource name '{workspace}--{name}' is {combined} characters, \
exceeding the DNS-1123 limit of {MAX_KUBE_NAME_LEN}"
)));
}
Ok(())
}
fn sandbox_labels(sandbox: &Sandbox) -> BTreeMap<String, String> {
let mut labels = BTreeMap::new();
labels.insert(LABEL_SANDBOX_ID.to_string(), sandbox.id.clone());
labels.insert(LABEL_SANDBOX_NAME.to_string(), sandbox.name.clone());
labels.insert(
LABEL_SANDBOX_WORKSPACE.to_string(),
sandbox.workspace.clone(),
);
labels.insert(
LABEL_MANAGED_BY.to_string(),
LABEL_MANAGED_BY_VALUE.to_string(),
@@ -1125,24 +1196,70 @@ fn sandbox_labels(sandbox: &Sandbox) -> BTreeMap<String, String> {
labels
}
fn sandbox_annotations(sandbox: &Sandbox) -> BTreeMap<String, String> {
let mut annotations = BTreeMap::new();
annotations.insert(LABEL_SANDBOX_ID.to_string(), sandbox.id.clone());
annotations.insert(LABEL_SANDBOX_NAME.to_string(), sandbox.name.clone());
annotations.insert(
LABEL_SANDBOX_WORKSPACE.to_string(),
sandbox.workspace.clone(),
);
annotations
}
fn sandbox_id_from_object(obj: &DynamicObject) -> Result<String, String> {
if let Some(annotations) = obj.metadata.annotations.as_ref()
&& let Some(id) = annotations.get(LABEL_SANDBOX_ID)
{
return Ok(id.clone());
}
if let Some(labels) = obj.metadata.labels.as_ref()
&& let Some(id) = labels.get(LABEL_SANDBOX_ID)
{
return Ok(id.clone());
}
let name = obj.metadata.name.clone().unwrap_or_default();
if let Some(id) = name.strip_prefix("sandbox-") {
return Ok(id.to_string());
}
Err("sandbox id not found on object".to_string())
}
fn sandbox_from_object(namespace: &str, obj: DynamicObject) -> Result<Sandbox, String> {
let id = sandbox_id_from_object(&obj)?;
let name = obj.metadata.name.clone().unwrap_or_default();
fn annotation_or_label(obj: &DynamicObject, key: &str) -> Option<String> {
obj.metadata
.annotations
.as_ref()
.and_then(|a| a.get(key))
.or_else(|| obj.metadata.labels.as_ref().and_then(|l| l.get(key)))
.cloned()
}
fn is_openshell_managed(obj: &DynamicObject) -> bool {
annotation_or_label(obj, LABEL_MANAGED_BY).as_deref() == Some(LABEL_MANAGED_BY_VALUE)
}
/// Returns `(kube_resource_name, DriverSandbox)`.
///
/// Returns `Err` in two cases (callers should skip, not fail):
/// - The object is not managed by `OpenShell` (missing/wrong `managed-by` label).
/// - The object is managed by `OpenShell` but missing required fields (orphan).
fn sandbox_from_object(namespace: &str, obj: DynamicObject) -> Result<(String, Sandbox), String> {
let kube_name = obj.metadata.name.clone().unwrap_or_default();
if !is_openshell_managed(&obj) {
debug!(object = %kube_name, "skipping sandbox CR not managed by openshell");
return Err(format!("object {kube_name} not managed by openshell"));
}
let Ok(id) = sandbox_id_from_object(&obj) else {
warn!(object = %kube_name, "openshell-managed sandbox CR missing id");
return Err(format!("object {kube_name} missing sandbox id"));
};
let Some(name) = annotation_or_label(&obj, LABEL_SANDBOX_NAME) else {
warn!(object = %kube_name, "openshell-managed sandbox CR missing name");
return Err(format!("object {kube_name} missing sandbox name"));
};
let Some(workspace) = annotation_or_label(&obj, LABEL_SANDBOX_WORKSPACE) else {
warn!(object = %kube_name, "openshell-managed sandbox CR missing workspace");
return Err(format!("object {kube_name} missing sandbox workspace"));
};
let namespace = obj
.metadata
.namespace
@@ -1150,22 +1267,27 @@ fn sandbox_from_object(namespace: &str, obj: DynamicObject) -> Result<Sandbox, S
.unwrap_or_else(|| namespace.to_string());
let status = status_from_object(&obj);
Ok(Sandbox {
id,
name,
namespace,
spec: None,
status,
})
Ok((
kube_name,
Sandbox {
id,
name,
namespace,
spec: None,
status,
workspace,
},
))
}
fn update_indexes(
sandbox_name_to_id: &mut std::collections::HashMap<String, String>,
agent_pod_to_id: &mut std::collections::HashMap<String, String>,
kube_name: &str,
sandbox: &Sandbox,
) {
if !sandbox.name.is_empty() {
sandbox_name_to_id.insert(sandbox.name.clone(), sandbox.id.clone());
if !kube_name.is_empty() {
sandbox_name_to_id.insert(kube_name.to_string(), sandbox.id.clone());
}
if let Some(status) = sandbox.status.as_ref()
&& !status.instance_id.is_empty()
@@ -5617,4 +5739,186 @@ mod tests {
assert!(validate_kubernetes_dns1123_label("MySandbox", "sandbox name").is_err());
assert!(validate_kubernetes_dns1123_label("dotted.name", "sandbox name").is_err());
}
#[test]
fn kube_resource_name_qualifies_with_workspace() {
assert_eq!(kube_resource_name("alpha", "work"), "alpha--work");
assert_eq!(
kube_resource_name("default", "my-sandbox"),
"default--my-sandbox"
);
}
#[test]
fn kube_resource_name_different_workspaces_produce_different_names() {
let alpha = kube_resource_name("alpha", "work");
let beta = kube_resource_name("beta", "work");
assert_ne!(alpha, beta);
}
#[test]
fn kube_resource_name_length_validation_accepts_short_names() {
validate_kube_resource_name_length("default", "my-sandbox").unwrap();
}
#[test]
fn kube_resource_name_length_validation_rejects_oversized_names() {
let long_ws = "a".repeat(40);
let long_name = "b".repeat(25);
let err = validate_kube_resource_name_length(&long_ws, &long_name).unwrap_err();
assert_eq!(err.code(), tonic::Code::InvalidArgument);
assert!(err.message().contains("67"));
}
#[test]
fn sandbox_from_object_reads_annotations() {
let obj = DynamicObject {
types: None,
metadata: ObjectMeta {
name: Some("alpha--work".to_string()),
namespace: Some("default".to_string()),
annotations: Some(BTreeMap::from([
(LABEL_SANDBOX_ID.to_string(), "uuid-123".to_string()),
(LABEL_SANDBOX_NAME.to_string(), "work".to_string()),
(LABEL_SANDBOX_WORKSPACE.to_string(), "alpha".to_string()),
])),
labels: Some(BTreeMap::from([
(LABEL_SANDBOX_ID.to_string(), "uuid-123".to_string()),
(LABEL_SANDBOX_NAME.to_string(), "work".to_string()),
(LABEL_SANDBOX_WORKSPACE.to_string(), "alpha".to_string()),
(
LABEL_MANAGED_BY.to_string(),
LABEL_MANAGED_BY_VALUE.to_string(),
),
])),
..Default::default()
},
data: serde_json::json!({}),
};
let (kube_name, sandbox) = sandbox_from_object("default", obj).unwrap();
assert_eq!(kube_name, "alpha--work");
assert_eq!(sandbox.name, "work");
assert_eq!(sandbox.workspace, "alpha");
assert_eq!(sandbox.id, "uuid-123");
}
#[test]
fn sandbox_from_object_falls_back_to_labels() {
let obj = DynamicObject {
types: None,
metadata: ObjectMeta {
name: Some("alpha--work".to_string()),
namespace: Some("default".to_string()),
annotations: None,
labels: Some(BTreeMap::from([
(LABEL_SANDBOX_ID.to_string(), "uuid-456".to_string()),
(LABEL_SANDBOX_NAME.to_string(), "work".to_string()),
(LABEL_SANDBOX_WORKSPACE.to_string(), "alpha".to_string()),
(
LABEL_MANAGED_BY.to_string(),
LABEL_MANAGED_BY_VALUE.to_string(),
),
])),
..Default::default()
},
data: serde_json::json!({}),
};
let (_, sandbox) = sandbox_from_object("default", obj).unwrap();
assert_eq!(sandbox.name, "work");
assert_eq!(sandbox.workspace, "alpha");
assert_eq!(sandbox.id, "uuid-456");
}
#[test]
fn sandbox_from_object_skips_unmanaged_cr() {
let obj = DynamicObject {
types: None,
metadata: ObjectMeta {
name: Some("foreign-sandbox".to_string()),
namespace: Some("default".to_string()),
labels: Some(BTreeMap::from([(
"some-other-label".to_string(),
"value".to_string(),
)])),
..Default::default()
},
data: serde_json::json!({}),
};
let result = sandbox_from_object("default", obj);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not managed by openshell"));
}
#[test]
fn sandbox_from_object_warns_on_managed_cr_missing_workspace() {
let obj = DynamicObject {
types: None,
metadata: ObjectMeta {
name: Some("work".to_string()),
namespace: Some("default".to_string()),
labels: Some(BTreeMap::from([
(LABEL_SANDBOX_ID.to_string(), "uuid-789".to_string()),
(LABEL_SANDBOX_NAME.to_string(), "work".to_string()),
(
LABEL_MANAGED_BY.to_string(),
LABEL_MANAGED_BY_VALUE.to_string(),
),
])),
..Default::default()
},
data: serde_json::json!({}),
};
let result = sandbox_from_object("default", obj);
assert!(result.is_err());
assert!(result.unwrap_err().contains("missing sandbox workspace"));
}
#[test]
fn sandbox_labels_includes_workspace_and_name() {
let sandbox = Sandbox {
id: "uuid-1".to_string(),
name: "work".to_string(),
workspace: "alpha".to_string(),
..Default::default()
};
let labels = sandbox_labels(&sandbox);
assert_eq!(labels.get(LABEL_SANDBOX_ID).unwrap(), "uuid-1");
assert_eq!(labels.get(LABEL_SANDBOX_NAME).unwrap(), "work");
assert_eq!(labels.get(LABEL_SANDBOX_WORKSPACE).unwrap(), "alpha");
assert_eq!(
labels.get(LABEL_MANAGED_BY).unwrap(),
LABEL_MANAGED_BY_VALUE
);
}
#[test]
fn sandbox_annotations_stores_authoritative_values() {
let sandbox = Sandbox {
id: "uuid-2".to_string(),
name: "dev".to_string(),
workspace: "beta".to_string(),
..Default::default()
};
let annotations = sandbox_annotations(&sandbox);
assert_eq!(annotations.get(LABEL_SANDBOX_ID).unwrap(), "uuid-2");
assert_eq!(annotations.get(LABEL_SANDBOX_NAME).unwrap(), "dev");
assert_eq!(annotations.get(LABEL_SANDBOX_WORKSPACE).unwrap(), "beta");
}
#[test]
fn sandbox_id_from_object_errors_without_label() {
let obj = DynamicObject {
types: None,
metadata: ObjectMeta {
name: Some("some-name".to_string()),
..Default::default()
},
data: serde_json::json!({}),
};
assert!(sandbox_id_from_object(&obj).is_err());
}
}
+7 -10
View File
@@ -57,23 +57,17 @@ impl ComputeDriver for ComputeDriverService {
request: Request<GetSandboxRequest>,
) -> Result<Response<GetSandboxResponse>, Status> {
let request = request.into_inner();
if request.sandbox_name.is_empty() {
return Err(Status::invalid_argument("sandbox_name is required"));
if request.sandbox_id.is_empty() {
return Err(Status::invalid_argument("sandbox_id is required"));
}
let sandbox = self
.driver
.get_sandbox(&request.sandbox_name)
.get_sandbox(&request.sandbox_id)
.await
.map_err(Status::internal)?
.ok_or_else(|| Status::not_found("sandbox not found"))?;
if !request.sandbox_id.is_empty() && request.sandbox_id != sandbox.id {
return Err(Status::failed_precondition(
"sandbox_id did not match the fetched sandbox",
));
}
Ok(Response::new(GetSandboxResponse {
sandbox: Some(sandbox),
}))
@@ -120,9 +114,12 @@ impl ComputeDriver for ComputeDriverService {
request: Request<DeleteSandboxRequest>,
) -> Result<Response<DeleteSandboxResponse>, Status> {
let request = request.into_inner();
if request.sandbox_id.is_empty() {
return Err(Status::invalid_argument("sandbox_id is required"));
}
let deleted = self
.driver
.delete_sandbox(&request.sandbox_name)
.delete_sandbox(&request.sandbox_id)
.await
.map_err(Status::internal)?;
Ok(Response::new(DeleteSandboxResponse { deleted }))
+1 -1
View File
@@ -337,7 +337,7 @@ Podman resources after out-of-band container removal or label drift.
| Environment Variable | CLI Flag | Default | Description |
|---|---|---|---|
| `OPENSHELL_PODMAN_SOCKET` | `--podman-socket` | `$XDG_RUNTIME_DIR/podman/podman.sock` on Linux, `$HOME/.local/share/containers/podman/machine/podman.sock` on macOS | Podman API Unix socket path. |
| `OPENSHELL_PODMAN_SOCKET` | `--podman-socket` | Probes known local Podman API sockets and uses the first responsive socket. Fails to start if none respond. | Podman API Unix socket path. |
| `OPENSHELL_SANDBOX_IMAGE` | `--sandbox-image` | From gateway config | Default OCI image for sandboxes. |
| `OPENSHELL_SANDBOX_IMAGE_PULL_POLICY` | `--sandbox-image-pull-policy` | `missing` | Pull policy: `always`, `missing`, `never`, or `newer`. |
| `OPENSHELL_GRPC_ENDPOINT` | `--grpc-endpoint` | Auto-detected via `host.containers.internal` | Gateway gRPC endpoint for sandbox callbacks. |
+3 -3
View File
@@ -484,12 +484,12 @@ impl PodmanClient {
.await
}
/// List containers matching a label filter (e.g. `"openshell.managed=true"`).
/// List containers matching label filters (e.g. `&["openshell.managed=true"]`).
pub async fn list_containers(
&self,
label_filter: &str,
label_filters: &[&str],
) -> Result<Vec<ContainerListEntry>, PodmanApiError> {
let filters = serde_json::json!({"label": [label_filter]});
let filters = serde_json::json!({"label": label_filters});
let encoded = url_encode(&filters.to_string());
self.request_json(
hyper::Method::GET,
+4 -79
View File
@@ -70,10 +70,9 @@ impl FromStr for ImagePullPolicy {
#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PodmanComputeConfig {
/// Path to the Podman API Unix socket.
/// Default: `$XDG_RUNTIME_DIR/podman/podman.sock` (Linux),
/// `$HOME/.local/share/containers/podman/machine/podman.sock` (macOS).
pub socket_path: PathBuf,
/// Podman API Unix socket. When unset, use the socket selected by
/// gateway auto-detection.
pub socket_path: Option<PathBuf>,
/// Default OCI image for sandboxes.
pub default_image: String,
/// Image pull policy for sandbox images.
@@ -239,38 +238,12 @@ impl PodmanComputeConfig {
String::new()
}
}
/// Resolve the default socket path from the environment.
///
/// - **macOS**: `$HOME/.local/share/containers/podman/machine/podman.sock`
/// (the symlink created by `podman machine` pointing to the VM API socket).
/// - **Linux**: `$XDG_RUNTIME_DIR/podman/podman.sock` when set (by
/// `pam_systemd`/logind), otherwise `/run/user/{uid}/podman/podman.sock`
/// using the real UID via `getuid()`.
#[must_use]
pub fn default_socket_path() -> PathBuf {
#[cfg(target_os = "macos")]
{
let home = std::env::var("HOME").expect("HOME must be set on macOS");
PathBuf::from(home).join(".local/share/containers/podman/machine/podman.sock")
}
#[cfg(target_os = "linux")]
{
std::env::var("XDG_RUNTIME_DIR").map_or_else(
|_| {
let uid = rustix::process::getuid().as_raw();
PathBuf::from(format!("/run/user/{uid}/podman/podman.sock"))
},
|xdg| PathBuf::from(xdg).join("podman/podman.sock"),
)
}
}
}
impl Default for PodmanComputeConfig {
fn default() -> Self {
Self {
socket_path: Self::default_socket_path(),
socket_path: None,
default_image: openshell_core::image::default_sandbox_image(),
image_pull_policy: ImagePullPolicy::default(),
grpc_endpoint: String::new(),
@@ -324,39 +297,6 @@ impl std::fmt::Debug for PodmanComputeConfig {
mod tests {
use super::*;
/// Serialises env-mutating tests so that parallel test threads cannot
/// observe each other's changes to `XDG_RUNTIME_DIR`.
static ENV_LOCK: std::sync::LazyLock<std::sync::Mutex<()>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(()));
#[test]
#[cfg(target_os = "linux")]
fn default_socket_path_respects_xdg_runtime_dir() {
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
temp_env::with_vars([("XDG_RUNTIME_DIR", Some("/tmp/test-xdg"))], || {
let path = PodmanComputeConfig::default_socket_path();
assert_eq!(path, PathBuf::from("/tmp/test-xdg/podman/podman.sock"));
});
}
#[test]
#[cfg(target_os = "linux")]
fn default_socket_path_falls_back_to_uid() {
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
temp_env::with_vars([("XDG_RUNTIME_DIR", None::<&str>)], || {
let path = PodmanComputeConfig::default_socket_path();
let uid = rustix::process::getuid().as_raw();
assert_eq!(
path,
PathBuf::from(format!("/run/user/{uid}/podman/podman.sock"))
);
});
}
#[test]
fn default_config_sets_health_check_interval() {
let cfg = PodmanComputeConfig::default();
@@ -453,21 +393,6 @@ mod tests {
assert!(cfg.resolve_sandbox_identity().is_err());
}
#[test]
#[cfg(target_os = "macos")]
fn default_socket_path_uses_podman_machine_on_macos() {
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
temp_env::with_vars([("HOME", Some("/Users/testuser"))], || {
let path = PodmanComputeConfig::default_socket_path();
assert_eq!(
path,
PathBuf::from("/Users/testuser/.local/share/containers/podman/machine/podman.sock")
);
});
}
// ── TLS config validation ─────────────────────────────────────────
#[test]
+30 -20
View File
@@ -36,19 +36,17 @@ fn is_selinux_enabled() -> bool {
false
}
/// Label key for the sandbox ID.
pub const LABEL_SANDBOX_ID: &str = "openshell.sandbox-id";
/// Label key for the sandbox name.
pub const LABEL_SANDBOX_NAME: &str = "openshell.sandbox-name";
/// Label key for the sandbox namespace.
pub const LABEL_SANDBOX_NAMESPACE: &str = "openshell.sandbox-namespace";
pub use openshell_core::driver_utils::{
LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, LABEL_SANDBOX_NAMESPACE, LABEL_SANDBOX_WORKSPACE,
};
/// Label applied to all managed containers.
pub const LABEL_MANAGED: &str = "openshell.managed";
/// Label filter string for list/event queries.
pub const LABEL_MANAGED_FILTER: &str = "openshell.managed=true";
/// Container name prefix to avoid collisions with user containers.
const CONTAINER_PREFIX: &str = "openshell-sandbox-";
const CONTAINER_PREFIX: &str = "openshell-";
/// Volume name prefix.
const VOLUME_PREFIX: &str = "openshell-sandbox-";
@@ -144,10 +142,12 @@ fn default_true() -> bool {
true
}
/// Build a Podman container name from the sandbox name.
/// Build a Podman container name from the sandbox workspace, name, and ID.
///
/// Format: `openshell-{workspace}--{name}-{id}`
#[must_use]
pub fn container_name(sandbox_name: &str) -> String {
format!("{CONTAINER_PREFIX}{sandbox_name}")
pub fn container_name(workspace: &str, name: &str, id: &str) -> String {
format!("{CONTAINER_PREFIX}{workspace}--{name}-{id}")
}
/// Build the workspace volume name from the sandbox ID.
@@ -475,6 +475,7 @@ fn build_labels(sandbox: &DriverSandbox) -> BTreeMap<String, String> {
labels.insert(LABEL_SANDBOX_ID.into(), sandbox.id.clone());
labels.insert(LABEL_SANDBOX_NAME.into(), sandbox.name.clone());
labels.insert(LABEL_SANDBOX_NAMESPACE.into(), sandbox.namespace.clone());
labels.insert(LABEL_SANDBOX_WORKSPACE.into(), sandbox.workspace.clone());
labels.insert(LABEL_MANAGED.into(), "true".into());
labels
@@ -843,7 +844,7 @@ pub fn build_container_spec_with_token_and_gpu_devices(
gpu_device_ids: Option<&[String]>,
) -> Result<Value, ComputeDriverError> {
let image = resolve_image(sandbox, config);
let name = container_name(&sandbox.name);
let name = container_name(&sandbox.workspace, &sandbox.name, &sandbox.id);
let vol = volume_name(&sandbox.id);
let env = build_env(sandbox, config, image);
@@ -1258,8 +1259,11 @@ mod tests {
}
#[test]
fn container_name_is_prefixed() {
assert_eq!(container_name("my-sandbox"), "openshell-sandbox-my-sandbox");
fn container_name_is_workspace_qualified() {
assert_eq!(
container_name("default", "my-sandbox", "abc-123"),
"openshell-default--my-sandbox-abc-123"
);
}
#[test]
@@ -1680,13 +1684,16 @@ mod tests {
let mut sandbox = test_sandbox("real-id", "real-name");
sandbox.namespace = "real-namespace".to_string();
let mut label_overrides = std::collections::HashMap::new();
label_overrides.insert("openshell.sandbox-id".to_string(), "spoofed-id".to_string());
label_overrides.insert(
"openshell.sandbox-name".to_string(),
"openshell.ai/sandbox-id".to_string(),
"spoofed-id".to_string(),
);
label_overrides.insert(
"openshell.ai/sandbox-name".to_string(),
"spoofed-name".to_string(),
);
label_overrides.insert(
"openshell.sandbox-namespace".to_string(),
"openshell.ai/sandbox-namespace".to_string(),
"spoofed-namespace".to_string(),
);
sandbox.spec = Some(DriverSandboxSpec {
@@ -1704,20 +1711,22 @@ mod tests {
.as_object()
.expect("labels should be an object");
assert_eq!(
labels.get("openshell.sandbox-id").and_then(|v| v.as_str()),
labels
.get("openshell.ai/sandbox-id")
.and_then(|v| v.as_str()),
Some("real-id"),
"openshell.sandbox-id must not be overridden by template labels"
);
assert_eq!(
labels
.get("openshell.sandbox-name")
.get("openshell.ai/sandbox-name")
.and_then(|v| v.as_str()),
Some("real-name"),
"openshell.sandbox-name must not be overridden by template labels"
);
assert_eq!(
labels
.get("openshell.sandbox-namespace")
.get("openshell.ai/sandbox-namespace")
.and_then(|v| v.as_str()),
Some("real-namespace"),
"openshell.sandbox-namespace must not be overridden by template labels"
@@ -1770,6 +1779,7 @@ mod tests {
namespace: String::new(),
spec: None,
status: None,
workspace: String::new(),
}
}
@@ -1806,7 +1816,7 @@ mod tests {
fn test_config() -> PodmanComputeConfig {
PodmanComputeConfig {
socket_path: std::path::PathBuf::from("/tmp/test.sock"),
socket_path: Some(std::path::PathBuf::from("/tmp/test.sock")),
default_image: "test-image:latest".to_string(),
grpc_endpoint: "http://localhost:50051".to_string(),
host_gateway_ip: String::new(),
+170 -131
View File
@@ -19,10 +19,10 @@ use openshell_core::gpu::{
use openshell_core::proto::compute::v1::{
DriverSandbox, GetCapabilitiesResponse, GpuResourceRequirements,
};
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tracing::{info, warn};
use tracing::{debug, info, warn};
impl From<PodmanApiError> for ComputeDriverError {
fn from(value: PodmanApiError) -> Self {
@@ -62,12 +62,12 @@ struct ValidatedPodmanSandbox<'a> {
gpu_requirements: Option<&'a GpuResourceRequirements>,
}
/// Construct and validate a container name from a sandbox name.
/// Construct and validate a container name from a sandbox.
///
/// Combines the prefix with the sandbox name and validates the result
/// against Podman's naming rules before any resources are created.
fn validated_container_name(sandbox_name: &str) -> Result<String, ComputeDriverError> {
let name = container::container_name(sandbox_name);
/// Combines the prefix with workspace, name, and ID, then validates the
/// result against Podman's naming rules before any resources are created.
fn validated_container_name(sandbox: &DriverSandbox) -> Result<String, ComputeDriverError> {
let name = container::container_name(&sandbox.workspace, &sandbox.name, &sandbox.id);
crate::client::validate_name(&name)
.map_err(|e| ComputeDriverError::Precondition(e.to_string()))?;
Ok(name)
@@ -157,22 +157,48 @@ fn podman_gpu_selection_error(err: CdiGpuSelectionError) -> ComputeDriverError {
ComputeDriverError::Precondition(err.to_string())
}
/// Resolve the socket to connect to: explicit configuration wins, otherwise
/// fall back to `detect`. Returns an error if neither resolves.
///
/// Takes `detect` as a parameter (rather than calling
/// [`openshell_core::config::detect_podman_socket`] directly) so tests can
/// exercise the precedence deterministically, without touching real
/// environment variables or the filesystem.
fn resolve_socket_path(
configured: Option<PathBuf>,
detect: impl FnOnce() -> Option<PathBuf>,
) -> Result<PathBuf, PodmanApiError> {
configured.or_else(detect).ok_or_else(|| {
PodmanApiError::InvalidInput(
"no responsive Podman API socket found; set OPENSHELL_PODMAN_SOCKET \
or configure socket_path"
.to_string(),
)
})
}
impl PodmanComputeDriver {
/// Create a new driver, verifying the Podman socket is reachable.
pub async fn new(mut config: PodmanComputeConfig) -> Result<Self, PodmanApiError> {
const MAX_PING_RETRIES: u32 = 5;
const PING_RETRY_DELAY: Duration = Duration::from_secs(2);
if !config.socket_path.exists() {
let socket_path = resolve_socket_path(
config.socket_path.clone(),
openshell_core::config::detect_podman_socket,
)?;
config.socket_path = Some(socket_path.clone());
if !socket_path.exists() {
if cfg!(target_os = "macos") {
warn!(
path = %config.socket_path.display(),
path = %socket_path.display(),
"Podman socket not found; is podman machine running? \
Try `podman machine start` or set OPENSHELL_PODMAN_SOCKET to override."
);
} else {
warn!(
path = %config.socket_path.display(),
path = %socket_path.display(),
"Podman socket not found; is the Podman service running? \
Set OPENSHELL_PODMAN_SOCKET or XDG_RUNTIME_DIR to override."
);
@@ -187,7 +213,7 @@ impl PodmanComputeDriver {
config.resolve_sandbox_identity()?;
config.validate_host_gateway_ip()?;
let client = PodmanClient::new(config.socket_path.clone());
let client = PodmanClient::new(socket_path);
// Verify connectivity, retrying briefly to tolerate transient socket
// unavailability (e.g. podman.socket restarting after a package
@@ -452,7 +478,7 @@ impl PodmanComputeDriver {
// Validate the composed container name early, before creating any
// resources (volume), so we don't leave orphans when the name is
// invalid.
let name = validated_container_name(&sandbox.name)?;
let name = validated_container_name(sandbox)?;
let validated = self.validated_sandbox_create(sandbox).await?;
let vol_name = container::volume_name(&sandbox.id);
@@ -594,75 +620,60 @@ impl PodmanComputeDriver {
Ok(())
}
/// Find the Podman container ID for a sandbox by its sandbox ID using label lookup.
async fn find_container_id(
&self,
sandbox_id: &str,
) -> Result<Option<String>, ComputeDriverError> {
let id_filter = format!("{LABEL_SANDBOX_ID}={sandbox_id}");
let entries = self
.client
.list_containers(&[LABEL_MANAGED_FILTER, &id_filter])
.await
.map_err(ComputeDriverError::from)?;
Ok(entries.first().map(|e| e.id.clone()))
}
/// Stop a sandbox container without deleting it.
pub async fn stop_sandbox(&self, sandbox_name: &str) -> Result<(), ComputeDriverError> {
let name = validated_container_name(sandbox_name)?;
info!(sandbox_name = %sandbox_name, container = %name, "Stopping sandbox container");
pub async fn stop_sandbox(&self, sandbox_id: &str) -> Result<(), ComputeDriverError> {
let container_id = self.find_container_id(sandbox_id).await?.ok_or_else(|| {
ComputeDriverError::Precondition("sandbox container not found".into())
})?;
info!(sandbox_id = %sandbox_id, container = %container_id, "Stopping sandbox container");
self.client
.stop_container(&name, self.config.stop_timeout_secs)
.stop_container(&container_id, self.config.stop_timeout_secs)
.await
.map_err(ComputeDriverError::from)
}
/// Delete a sandbox container and its workspace volume.
pub async fn delete_sandbox(
&self,
sandbox_id: &str,
sandbox_name: &str,
) -> Result<bool, ComputeDriverError> {
pub async fn delete_sandbox(&self, sandbox_id: &str) -> Result<bool, ComputeDriverError> {
if sandbox_id.is_empty() {
return Err(ComputeDriverError::Precondition(
"sandbox id is required".into(),
));
}
let name = validated_container_name(sandbox_name)?;
info!(
sandbox_id = %sandbox_id,
sandbox_name = %sandbox_name,
container = %name,
"Deleting sandbox container"
);
// Use the request's stable sandbox ID as the source of truth for
// cleanup. Inspect is only used as a best-effort cross-check so
// cleanup still works if the container is already gone or mislabeled.
match self.client.inspect_container(&name).await {
Ok(inspect) => match inspect.config.labels.get(LABEL_SANDBOX_ID) {
Some(label_id) if label_id != sandbox_id => {
warn!(
sandbox_id = %sandbox_id,
sandbox_name = %sandbox_name,
container = %name,
label_sandbox_id = %label_id,
"Container label sandbox ID did not match delete request; cleaning up using request sandbox_id"
);
}
None => {
warn!(
sandbox_id = %sandbox_id,
sandbox_name = %sandbox_name,
container = %name,
"Container missing '{}' label; cleaning up using request sandbox_id",
LABEL_SANDBOX_ID,
);
}
Some(_) => {}
},
Err(PodmanApiError::NotFound(_)) => {}
Err(e) => return Err(ComputeDriverError::from(e)),
}
let Some(container_id) = self.find_container_id(sandbox_id).await? else {
debug!(sandbox_id = %sandbox_id, "Sandbox container not found (already deleted)");
let vol = container::volume_name(sandbox_id);
if let Err(e) = self.client.remove_volume(&vol).await {
warn!(sandbox_id = %sandbox_id, volume = %vol, error = %e, "Failed to remove workspace volume");
}
cleanup_sandbox_token_secret(&self.client, &container::token_secret_name(sandbox_id))
.await;
return Ok(false);
};
info!(sandbox_id = %sandbox_id, container = %container_id, "Deleting sandbox container");
// Stop (best-effort).
let _ = self
.client
.stop_container(&name, self.config.stop_timeout_secs)
.stop_container(&container_id, self.config.stop_timeout_secs)
.await;
// Remove container. If NotFound, the container was removed between
// inspect and here (TOCTOU race); proceed with volume cleanup
// since the workspace volume is idempotent to remove.
let container_existed = match self.client.remove_container(&name).await {
let container_existed = match self.client.remove_container(&container_id).await {
Ok(()) => true,
Err(PodmanApiError::NotFound(_)) => false,
Err(e) => return Err(ComputeDriverError::from(e)),
@@ -673,7 +684,6 @@ impl PodmanComputeDriver {
if let Err(e) = self.client.remove_volume(&vol).await {
warn!(
sandbox_id = %sandbox_id,
sandbox_name = %sandbox_name,
volume = %vol,
error = %e,
"Failed to remove workspace volume"
@@ -685,25 +695,40 @@ impl PodmanComputeDriver {
}
/// Check whether a sandbox container exists.
pub async fn sandbox_exists(&self, sandbox_name: &str) -> Result<bool, ComputeDriverError> {
let name = container::container_name(sandbox_name);
match self.client.inspect_container(&name).await {
Ok(_) => Ok(true),
Err(PodmanApiError::NotFound(_)) => Ok(false),
Err(e) => Err(ComputeDriverError::from(e)),
}
pub async fn sandbox_exists(&self, sandbox_id: &str) -> Result<bool, ComputeDriverError> {
let id_filter = format!("{LABEL_SANDBOX_ID}={sandbox_id}");
let entries = self
.client
.list_containers(&[LABEL_MANAGED_FILTER, &id_filter])
.await
.map_err(ComputeDriverError::from)?;
Ok(!entries.is_empty())
}
/// Fetch a single sandbox by name.
/// Fetch a single sandbox by ID.
pub async fn get_sandbox(
&self,
sandbox_name: &str,
sandbox_id: &str,
) -> Result<Option<DriverSandbox>, ComputeDriverError> {
let name = container::container_name(sandbox_name);
match self.client.inspect_container(&name).await {
Ok(inspect) => Ok(driver_sandbox_from_inspect(&inspect)),
Err(PodmanApiError::NotFound(_)) => Ok(None),
Err(e) => Err(ComputeDriverError::from(e)),
let id_filter = format!("{LABEL_SANDBOX_ID}={sandbox_id}");
let entries = self
.client
.list_containers(&[LABEL_MANAGED_FILTER, &id_filter])
.await
.map_err(ComputeDriverError::from)?;
let Some(entry) = entries.first() else {
return Ok(None);
};
if entry.state == "running" {
Ok(self
.client
.inspect_container(&entry.id)
.await
.ok()
.and_then(|inspect| driver_sandbox_from_inspect(&inspect))
.or_else(|| driver_sandbox_from_list_entry(entry)))
} else {
Ok(driver_sandbox_from_list_entry(entry))
}
}
@@ -714,7 +739,7 @@ impl PodmanComputeDriver {
pub async fn list_sandboxes(&self) -> Result<Vec<DriverSandbox>, ComputeDriverError> {
let entries = self
.client
.list_containers(LABEL_MANAGED_FILTER)
.list_containers(&[LABEL_MANAGED_FILTER])
.await
.map_err(ComputeDriverError::from)?;
@@ -775,7 +800,7 @@ impl PodmanComputeDriver {
gpu_inventory: CdiGpuInventory,
allow_all_default_gpu: bool,
) -> Self {
let client = PodmanClient::new(config.socket_path.clone());
let client = PodmanClient::new(config.socket_path.clone().unwrap_or_default());
let refresh_inventory = gpu_inventory.clone();
Self {
client,
@@ -851,6 +876,36 @@ mod tests {
use std::fs;
use std::path::PathBuf;
// ── socket resolution ───────────────────────────────────────────────
//
// These test resolve_socket_path directly with an injected detector, so
// they are deterministic regardless of the host's real environment
// variables or whether a Podman socket happens to be running.
#[test]
fn resolve_socket_path_prefers_explicit_configuration() {
let path = resolve_socket_path(Some(PathBuf::from("/explicit.sock")), || {
Some(PathBuf::from("/detected.sock"))
})
.unwrap();
assert_eq!(path, PathBuf::from("/explicit.sock"));
}
#[test]
fn resolve_socket_path_uses_detected_socket_when_unconfigured() {
let path = resolve_socket_path(None, || Some(PathBuf::from("/detected.sock"))).unwrap();
assert_eq!(path, PathBuf::from("/detected.sock"));
}
#[test]
fn resolve_socket_path_errors_when_neither_source_resolves() {
let err = resolve_socket_path(None, || None).unwrap_err();
assert!(err.to_string().contains("no responsive Podman API socket"));
}
fn cdi_devices_config(device_ids: &[&str]) -> prost_types::Struct {
prost_types::Struct {
fields: std::iter::once((
@@ -1246,7 +1301,7 @@ mod tests {
fn test_driver(socket_path: PathBuf) -> PodmanComputeDriver {
let config = PodmanComputeConfig {
socket_path,
socket_path: Some(socket_path),
stop_timeout_secs: 10,
..PodmanComputeConfig::default()
};
@@ -1284,6 +1339,7 @@ mod tests {
..Default::default()
}),
status: None,
workspace: String::new(),
}
}
@@ -1425,7 +1481,7 @@ mod tests {
)],
);
let config = PodmanComputeConfig {
socket_path: socket_path.clone(),
socket_path: Some(socket_path.clone()),
enable_bind_mounts: true,
..PodmanComputeConfig::default()
};
@@ -1442,24 +1498,22 @@ mod tests {
}
#[tokio::test]
async fn delete_sandbox_cleans_up_with_request_id_when_container_is_already_gone() {
async fn delete_sandbox_cleans_up_volume_when_container_is_already_gone() {
let sandbox_id = "sandbox-123";
let sandbox_name = "demo";
let container_name = container::container_name(sandbox_name);
let volume_name = container::volume_name(sandbox_id);
let (socket_path, request_log, handle) = spawn_podman_stub(
"delete-not-found",
vec![
StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#),
StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#),
StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#),
// list_containers returns empty (container already gone)
StubResponse::new(StatusCode::OK, "[]"),
// remove_volume
StubResponse::new(StatusCode::NO_CONTENT, ""),
],
);
let driver = test_driver(socket_path.clone());
let deleted = driver
.delete_sandbox(sandbox_id, sandbox_name)
.delete_sandbox(sandbox_id)
.await
.expect("delete should succeed");
@@ -1469,67 +1523,49 @@ mod tests {
.lock()
.expect("request log lock should not be poisoned")
.clone();
assert!(requests[0].contains("/libpod/containers/json"));
assert_eq!(
requests,
vec![
format!(
"GET {}",
api_path(&format!("/libpod/containers/{container_name}/json"))
),
format!(
"POST {}",
api_path(&format!(
"/libpod/containers/{container_name}/stop?timeout=10"
))
),
format!(
"DELETE {}",
api_path(&format!(
"/libpod/containers/{container_name}?force=true&v=true"
))
),
format!(
"DELETE {}",
api_path(&format!("/libpod/volumes/{volume_name}"))
),
]
requests[1],
format!(
"DELETE {}",
api_path(&format!("/libpod/volumes/{volume_name}"))
)
);
let _ = fs::remove_file(socket_path);
}
#[tokio::test]
async fn delete_sandbox_uses_request_id_when_container_label_disagrees() {
async fn delete_sandbox_finds_container_by_label_and_removes() {
let sandbox_id = "sandbox-request-id";
let sandbox_name = "demo";
let container_name = container::container_name(sandbox_name);
let container_id = "abc123def456";
let container_name = "openshell-default--demo-sandbox-request-id";
let volume_name = container::volume_name(sandbox_id);
let inspect_body = serde_json::json!({
"Id": "container-id",
"Name": format!("/{container_name}"),
"State": {
"Status": "running",
"Running": true
},
"Config": {
"Labels": {
LABEL_SANDBOX_ID: "sandbox-label-id"
}
let list_body = serde_json::json!([{
"Id": container_id,
"Names": [container_name],
"State": "running",
"Labels": {
LABEL_SANDBOX_ID: sandbox_id
}
})
}])
.to_string();
let (socket_path, request_log, handle) = spawn_podman_stub(
"delete-mismatch",
"delete-label-lookup",
vec![
StubResponse::new(StatusCode::OK, inspect_body),
// list_containers by label
StubResponse::new(StatusCode::OK, list_body),
// stop_container
StubResponse::new(StatusCode::NO_CONTENT, ""),
// remove_container
StubResponse::new(StatusCode::NO_CONTENT, ""),
// remove_volume
StubResponse::new(StatusCode::NO_CONTENT, ""),
],
);
let driver = test_driver(socket_path.clone());
let deleted = driver
.delete_sandbox(sandbox_id, sandbox_name)
.delete_sandbox(sandbox_id)
.await
.expect("delete should succeed");
@@ -1539,12 +1575,15 @@ mod tests {
.lock()
.expect("request log lock should not be poisoned")
.clone();
assert!(requests[0].contains("/libpod/containers/json"));
assert!(requests[1].contains(&format!("/libpod/containers/{container_id}/stop")));
assert!(requests[2].contains(&format!("/libpod/containers/{container_id}")));
assert_eq!(
requests[3..],
[format!(
requests[3],
format!(
"DELETE {}",
api_path(&format!("/libpod/volumes/{volume_name}"))
)]
)
);
let _ = fs::remove_file(socket_path);
}
+18 -64
View File
@@ -60,23 +60,17 @@ impl ComputeDriver for ComputeDriverService {
request: Request<GetSandboxRequest>,
) -> Result<Response<GetSandboxResponse>, Status> {
let request = request.into_inner();
if request.sandbox_name.is_empty() {
return Err(Status::invalid_argument("sandbox_name is required"));
if request.sandbox_id.is_empty() {
return Err(Status::invalid_argument("sandbox_id is required"));
}
let sandbox = self
.driver
.get_sandbox(&request.sandbox_name)
.get_sandbox(&request.sandbox_id)
.await
.map_err(Status::from)?
.ok_or_else(|| Status::not_found("sandbox not found"))?;
if !request.sandbox_id.is_empty() && request.sandbox_id != sandbox.id {
return Err(Status::failed_precondition(
"sandbox_id did not match the fetched sandbox",
));
}
Ok(Response::new(GetSandboxResponse {
sandbox: Some(sandbox),
}))
@@ -110,11 +104,11 @@ impl ComputeDriver for ComputeDriverService {
request: Request<StopSandboxRequest>,
) -> Result<Response<StopSandboxResponse>, Status> {
let request = request.into_inner();
if request.sandbox_name.is_empty() {
return Err(Status::invalid_argument("sandbox_name is required"));
if request.sandbox_id.is_empty() {
return Err(Status::invalid_argument("sandbox_id is required"));
}
self.driver
.stop_sandbox(&request.sandbox_name)
.stop_sandbox(&request.sandbox_id)
.await
.map_err(Status::from)?;
Ok(Response::new(StopSandboxResponse {}))
@@ -128,12 +122,9 @@ impl ComputeDriver for ComputeDriverService {
if request.sandbox_id.is_empty() {
return Err(Status::invalid_argument("sandbox_id is required"));
}
if request.sandbox_name.is_empty() {
return Err(Status::invalid_argument("sandbox_name is required"));
}
let deleted = self
.driver
.delete_sandbox(&request.sandbox_id, &request.sandbox_name)
.delete_sandbox(&request.sandbox_id)
.await
.map_err(Status::from)?;
Ok(Response::new(DeleteSandboxResponse { deleted }))
@@ -179,7 +170,7 @@ mod tests {
fn test_service(socket_path: PathBuf) -> ComputeDriverService {
let config = PodmanComputeConfig {
socket_path,
socket_path: Some(socket_path),
stop_timeout_secs: 10,
..PodmanComputeConfig::default()
};
@@ -190,24 +181,6 @@ mod tests {
format!("/v5.0.0{path}")
}
#[tokio::test]
async fn delete_sandbox_rejects_missing_sandbox_name() {
let service = test_service(unique_socket_path("missing-name"));
let err = ComputeDriver::delete_sandbox(
&service,
Request::new(DeleteSandboxRequest {
sandbox_id: "sandbox-123".to_string(),
sandbox_name: String::new(),
}),
)
.await
.expect_err("missing sandbox_name should fail");
assert_eq!(err.code(), tonic::Code::InvalidArgument);
assert_eq!(err.message(), "sandbox_name is required");
}
#[tokio::test]
async fn delete_sandbox_rejects_missing_sandbox_id() {
let service = test_service(unique_socket_path("missing-id"));
@@ -229,15 +202,13 @@ mod tests {
#[tokio::test]
async fn delete_sandbox_forwards_request_sandbox_id_to_driver_cleanup() {
let sandbox_id = "sandbox-abc";
let sandbox_name = "demo";
let container_name = container::container_name(sandbox_name);
let volume_name = container::volume_name(sandbox_id);
let (socket_path, request_log, handle) = spawn_podman_stub(
"forward-id",
vec![
StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#),
StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#),
StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#),
// list_containers returns empty (container already gone)
StubResponse::new(StatusCode::OK, "[]"),
// remove_volume
StubResponse::new(StatusCode::NO_CONTENT, ""),
],
);
@@ -247,7 +218,7 @@ mod tests {
&service,
Request::new(DeleteSandboxRequest {
sandbox_id: sandbox_id.to_string(),
sandbox_name: sandbox_name.to_string(),
sandbox_name: "demo".to_string(),
}),
)
.await
@@ -263,30 +234,13 @@ mod tests {
.lock()
.expect("request log lock should not be poisoned")
.clone();
assert!(requests[0].contains("/libpod/containers/json"));
assert_eq!(
requests,
vec![
format!(
"GET {}",
api_path(&format!("/libpod/containers/{container_name}/json"))
),
format!(
"POST {}",
api_path(&format!(
"/libpod/containers/{container_name}/stop?timeout=10"
))
),
format!(
"DELETE {}",
api_path(&format!(
"/libpod/containers/{container_name}?force=true&v=true"
))
),
format!(
"DELETE {}",
api_path(&format!("/libpod/volumes/{volume_name}"))
),
]
requests[1],
format!(
"DELETE {}",
api_path(&format!("/libpod/volumes/{volume_name}"))
)
);
let _ = std::fs::remove_file(socket_path);
}
+1 -5
View File
@@ -126,12 +126,8 @@ async fn main() -> Result<()> {
)
.init();
let socket_path = args
.podman_socket
.unwrap_or_else(PodmanComputeConfig::default_socket_path);
let driver = PodmanComputeDriver::new(PodmanComputeConfig {
socket_path,
socket_path: args.podman_socket,
default_image: args.sandbox_image.unwrap_or_default(),
image_pull_policy: args.sandbox_image_pull_policy,
grpc_endpoint: args.grpc_endpoint.unwrap_or_default(),
+22 -2
View File
@@ -7,7 +7,9 @@ use crate::client::{
ContainerInspect, ContainerListEntry, ContainerState, HealthState, PodmanApiError,
PodmanClient, PodmanEvent,
};
use crate::container::{LABEL_MANAGED_FILTER, LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, short_id};
use crate::container::{
LABEL_MANAGED_FILTER, LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, LABEL_SANDBOX_WORKSPACE, short_id,
};
use futures::Stream;
use openshell_core::ComputeDriverError;
use openshell_core::proto::compute::v1::{
@@ -76,7 +78,7 @@ pub async fn start_watch(client: PodmanClient) -> Result<WatchStream, PodmanApiE
let mut event_rx = client.events_stream(LABEL_MANAGED_FILTER).await?;
// 2. List existing containers for initial state sync.
let existing = client.list_containers(LABEL_MANAGED_FILTER).await?;
let existing = client.list_containers(&[LABEL_MANAGED_FILTER]).await?;
for entry in &existing {
// For running containers, use inspect to get full state including
@@ -215,9 +217,15 @@ async fn map_podman_event(
.get(LABEL_SANDBOX_NAME)
.cloned()
.unwrap_or_default();
let workspace = event
.actor
.attributes
.get(LABEL_SANDBOX_WORKSPACE)
.cloned()?;
Some(sandbox_event(build_driver_sandbox(
sandbox_id.clone(),
sandbox_name,
workspace,
String::new(),
short_id(container_id),
DriverCondition {
@@ -247,6 +255,7 @@ async fn map_podman_event(
fn build_driver_sandbox(
sandbox_id: String,
sandbox_name: String,
workspace: String,
instance_name: String,
instance_id: String,
condition: DriverCondition,
@@ -265,6 +274,7 @@ fn build_driver_sandbox(
conditions: vec![condition],
deleting,
}),
workspace,
}
}
@@ -277,6 +287,11 @@ pub fn driver_sandbox_from_inspect(inspect: &ContainerInspect) -> Option<DriverS
.get(LABEL_SANDBOX_NAME)
.cloned()
.unwrap_or_default();
let workspace = inspect
.config
.labels
.get(LABEL_SANDBOX_WORKSPACE)
.cloned()?;
let condition = condition_from_state(&inspect.state);
let deleting = inspect.state.status == "removing";
@@ -284,6 +299,7 @@ pub fn driver_sandbox_from_inspect(inspect: &ContainerInspect) -> Option<DriverS
Some(build_driver_sandbox(
sandbox_id,
sandbox_name,
workspace,
inspect.name.trim_start_matches('/').to_string(),
short_id(&inspect.id),
condition,
@@ -299,6 +315,7 @@ pub fn driver_sandbox_from_list_entry(entry: &ContainerListEntry) -> Option<Driv
.get(LABEL_SANDBOX_NAME)
.cloned()
.unwrap_or_default();
let workspace = entry.labels.get(LABEL_SANDBOX_WORKSPACE).cloned()?;
let (reason, status_str, message) = match entry.state.as_str() {
"running" => (
@@ -316,6 +333,7 @@ pub fn driver_sandbox_from_list_entry(entry: &ContainerListEntry) -> Option<Driv
Some(build_driver_sandbox(
sandbox_id,
sandbox_name,
workspace,
entry.names.first().cloned().unwrap_or_default(),
short_id(&entry.id),
DriverCondition {
@@ -452,6 +470,7 @@ mod tests {
let mut labels = std::collections::HashMap::new();
labels.insert(LABEL_SANDBOX_ID.to_string(), "test-id".to_string());
labels.insert(LABEL_SANDBOX_NAME.to_string(), "test-name".to_string());
labels.insert(LABEL_SANDBOX_WORKSPACE.to_string(), "default".to_string());
let entry = ContainerListEntry {
id: "abc123def456789".to_string(),
@@ -497,6 +516,7 @@ mod tests {
conditions: vec![condition],
deleting: false,
}),
workspace: String::new(),
};
let event = WatchSandboxesEvent {
+39 -23
View File
@@ -3270,10 +3270,9 @@ async fn connect_local_container_engine() -> Option<Docker> {
return Some(docker);
}
let podman_socket = podman_socket_path();
if podman_socket.exists()
&& let Ok(docker) =
Docker::connect_with_unix(podman_socket.to_str()?, 120, bollard::API_DEFAULT_VERSION)
let podman_socket = openshell_core::config::detect_podman_socket()?;
if let Ok(docker) =
Docker::connect_with_unix(podman_socket.to_str()?, 120, bollard::API_DEFAULT_VERSION)
&& docker.ping().await.is_ok()
{
info!(
@@ -3286,25 +3285,6 @@ async fn connect_local_container_engine() -> Option<Docker> {
None
}
/// Podman user socket path for the current platform.
fn podman_socket_path() -> PathBuf {
#[cfg(target_os = "macos")]
{
let home = std::env::var("HOME").unwrap_or_default();
PathBuf::from(home).join(".local/share/containers/podman/machine/podman.sock")
}
#[cfg(target_os = "linux")]
{
std::env::var("XDG_RUNTIME_DIR").map_or_else(
|_| {
let uid = nix::unistd::getuid();
PathBuf::from(format!("/run/user/{uid}/podman/podman.sock"))
},
|xdg| PathBuf::from(xdg).join("podman/podman.sock"),
)
}
}
fn is_openshell_local_build_image_ref(image_ref: &str) -> bool {
image_ref.starts_with("openshell/sandbox-from:")
}
@@ -3558,6 +3538,8 @@ impl VmDriver {
apply_registry_layer_blob(image_ref, rootfs, layer).await?;
}
remove_registry_layer_staging(staging_dir).await?;
Ok(())
}
@@ -3708,6 +3690,16 @@ async fn apply_registry_layer_blob(
})
}
async fn remove_registry_layer_staging(staging_dir: &Path) -> Result<(), Status> {
let layers_dir = staging_dir.join("layers");
tokio::fs::remove_dir_all(&layers_dir).await.map_err(|err| {
Status::internal(format!(
"remove registry layer staging dir '{}' failed: {err}",
layers_dir.display()
))
})
}
async fn download_registry_descriptor_blob_file(
client: &OciClient,
reference: &Reference,
@@ -4982,6 +4974,7 @@ fn sandbox_snapshot(sandbox: &Sandbox, condition: SandboxCondition, deleting: bo
id: sandbox.id.clone(),
name: sandbox.name.clone(),
namespace: sandbox.namespace.clone(),
workspace: sandbox.workspace.clone(),
status: Some(SandboxStatus {
sandbox_name: sandbox.name.clone(),
instance_id: String::new(),
@@ -6634,6 +6627,29 @@ mod tests {
);
}
#[tokio::test]
async fn remove_registry_layer_staging_preserves_merged_rootfs() {
let base = unique_temp_dir();
let layers_dir = base.join("layers");
let rootfs_dir = base.join("rootfs");
fs::create_dir_all(&layers_dir).unwrap();
fs::create_dir_all(&rootfs_dir).unwrap();
fs::write(layers_dir.join("layer.blob"), b"compressed layer").unwrap();
fs::write(rootfs_dir.join("merged.txt"), b"merged rootfs").unwrap();
remove_registry_layer_staging(&base)
.await
.expect("remove layer staging");
assert!(!layers_dir.exists());
assert_eq!(
fs::read(rootfs_dir.join("merged.txt")).unwrap(),
b"merged rootfs"
);
let _ = fs::remove_dir_all(base);
}
#[test]
fn sanitize_image_identity_rewrites_path_separators() {
assert_eq!(
+102 -18
View File
@@ -461,12 +461,19 @@ fn round_up_to_mib(bytes: u64) -> u64 {
bytes.div_ceil(MIB) * MIB
}
enum FormatterAttempt {
Succeeded,
Failed(String),
Unavailable(String),
}
fn format_ext4_image_from_dir(source: &Path, image_path: &Path) -> Result<(), String> {
let mut last_error = None;
for tool in ["mke2fs", "mkfs.ext4"] {
for candidate in e2fs_tool_candidates(tool) {
let candidates = ["mke2fs", "mkfs.ext4"]
.into_iter()
.flat_map(e2fs_tool_candidates);
run_ext4_formatter_candidates(candidates, |candidate| {
let label = candidate.display().to_string();
let output = Command::new(&candidate)
let output = Command::new(candidate)
.arg("-q")
.arg("-F")
.arg("-t")
@@ -478,29 +485,51 @@ fn format_ext4_image_from_dir(source: &Path, image_path: &Path) -> Result<(), St
.arg(image_path)
.output();
match output {
Ok(output) if output.status.success() => return Ok(()),
Ok(output) => {
last_error = Some(format!(
Ok(output) if output.status.success() => FormatterAttempt::Succeeded,
Ok(output) => FormatterAttempt::Failed(format!(
"{label} failed with status {}\nstdout: {}\nstderr: {}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
));
}
)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
last_error = Some(format!("{label} not found"));
}
Err(err) => {
last_error = Some(format!("run {label}: {err}"));
FormatterAttempt::Unavailable(format!("{label} not found"))
}
Err(err) => FormatterAttempt::Failed(format!("run {label}: {err}")),
}
})
.map_err(|details| {
format!(
"failed to create ext4 rootfs image from {}: {details}. Install e2fsprogs (mke2fs/mkfs.ext4) and retry",
source.display()
)
})
}
fn run_ext4_formatter_candidates(
candidates: impl IntoIterator<Item = PathBuf>,
mut run: impl FnMut(&Path) -> FormatterAttempt,
) -> Result<(), String> {
let mut failures = Vec::new();
let mut unavailable = Vec::new();
for candidate in candidates {
match run(&candidate) {
FormatterAttempt::Succeeded => return Ok(()),
FormatterAttempt::Failed(error) => failures.push(error),
FormatterAttempt::Unavailable(error) => unavailable.push(error),
}
}
Err(format!(
"failed to create ext4 rootfs image from {}: {}. Install e2fsprogs (mke2fs/mkfs.ext4) and retry",
source.display(),
last_error.unwrap_or_else(|| "no ext4 formatter found".to_string())
))
if failures.is_empty() {
Err(if unavailable.is_empty() {
"no ext4 formatter candidates configured".to_string()
} else {
unavailable.join("\n")
})
} else {
Err(failures.join("\n"))
}
}
fn ensure_rootfs_image_parent_dirs(image_path: &Path, guest_path: &str) {
@@ -1131,6 +1160,61 @@ mod tests {
assert_eq!(debugfs_quote_argument("/tmp/bad\npath"), None);
}
#[test]
fn formatter_candidates_preserve_executed_failure_over_missing_fallback() {
let candidates = vec![PathBuf::from("mke2fs"), PathBuf::from("missing")];
let err = run_ext4_formatter_candidates(candidates, |candidate| {
if candidate == Path::new("mke2fs") {
FormatterAttempt::Failed(
"mke2fs failed with status 1\nstdout: formatter output\nstderr: no space left"
.to_string(),
)
} else {
FormatterAttempt::Unavailable("missing not found".to_string())
}
})
.expect_err("formatter should fail");
assert!(err.contains("mke2fs failed with status 1"));
assert!(err.contains("no space left"));
assert!(!err.contains("missing not found"));
}
#[test]
fn formatter_candidates_report_all_missing_tools() {
let candidates = vec![PathBuf::from("mke2fs"), PathBuf::from("mkfs.ext4")];
let err = run_ext4_formatter_candidates(candidates, |candidate| {
FormatterAttempt::Unavailable(format!("{} not found", candidate.display()))
})
.expect_err("formatter should be unavailable");
assert!(err.contains("mke2fs not found"));
assert!(err.contains("mkfs.ext4 not found"));
}
#[test]
fn formatter_candidates_accept_successful_fallback() {
let candidates = vec![PathBuf::from("first"), PathBuf::from("second")];
let mut attempted = Vec::new();
run_ext4_formatter_candidates(candidates, |candidate| {
attempted.push(candidate.to_path_buf());
if candidate == Path::new("second") {
FormatterAttempt::Succeeded
} else {
FormatterAttempt::Failed("first failed".to_string())
}
})
.expect("fallback should succeed");
assert_eq!(
attempted,
vec![PathBuf::from("first"), PathBuf::from("second")]
);
}
fn unique_temp_dir() -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
@@ -315,6 +315,7 @@ mod tests {
name: "demo".to_string(),
labels: HashMap::from([("team".to_string(), "agent".to_string())]),
annotations: HashMap::new(),
workspace: String::new(),
};
let bytes = request.encode_to_vec();
let json = codec
@@ -342,6 +343,7 @@ mod tests {
config: HashMap::from([("region".to_string(), "us-west".to_string())]),
..Provider::default()
}),
workspace: String::new(),
};
let encoded = request.encode_to_vec();
@@ -758,6 +758,7 @@ mod tests {
config: HashMap::from([("region".to_string(), "old".to_string())]),
..Provider::default()
}),
workspace: String::new(),
};
let json = codec
.decode_message_to_json("openshell.v1.CreateProviderRequest", &request)
@@ -1063,6 +1064,7 @@ mod tests {
name: "demo".to_string(),
labels: HashMap::new(),
annotations: HashMap::new(),
workspace: String::new(),
};
let bytes = request.encode_to_vec();
+4 -4
View File
@@ -26,11 +26,11 @@ use policy::parse_policy;
use queries::run_all_queries;
use report::{render_compact, render_report};
/// Run the prover end-to-end and return an exit code.
/// Run the prover end-to-end and return a result containing an exit code.
///
/// - `0` — pass (no critical/high findings, or all accepted)
/// - `1` — fail (critical or high findings present)
/// - `2` — input error
/// - `Ok(0)` — pass (no findings, or all accepted)
/// - `Ok(1)` — fail (one or more unaccepted findings present)
/// - `Err(_)` — input or registry loading error
///
/// Binary and API capability registries are embedded at compile time.
/// Pass `registry_dir` to override with a custom filesystem registry.
@@ -120,6 +120,8 @@ mod tests {
discovery: DiscoveryProfile {
credentials: vec!["api_key".to_string(), "secondary".to_string()],
},
source: String::new(),
scope: String::new(),
}
}
+85 -5
View File
@@ -334,6 +334,10 @@ pub struct ProviderTypeProfile {
pub inference_capable: bool,
#[serde(default, skip_serializing_if = "discovery_is_empty")]
pub discovery: DiscoveryProfile,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub source: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub scope: String,
}
// Provider profile import/export is expected to be lossless for the network
@@ -378,6 +382,8 @@ impl ProviderTypeProfile {
.as_ref()
.map(discovery_from_proto)
.unwrap_or_default(),
source: profile.source.clone(),
scope: profile.scope.clone(),
}
}
@@ -513,6 +519,8 @@ impl ProviderTypeProfile {
inference_capable: self.inference_capable,
discovery: (!discovery_is_empty(&self.discovery))
.then(|| discovery_to_proto(&self.discovery)),
source: self.source.clone(),
scope: self.scope.clone(),
}
}
@@ -2039,15 +2047,81 @@ mod tests {
"github profile should include read-only GraphQL endpoint"
);
assert!(
proto
.endpoints
.iter()
.all(|endpoint| endpoint.access == "read-only"),
"github profile endpoints should all be read-only"
proto.endpoints.iter().all(|endpoint| {
// The REST/GraphQL API endpoints stay read-only. The git
// transport endpoint (github.com) carries explicit rules
// instead so it can allow clone/fetch while blocking push.
if endpoint.host == "github.com" {
endpoint.access.is_empty()
} else {
endpoint.access == "read-only"
}
}),
"github API endpoints should be read-only; git transport uses explicit rules"
);
assert_eq!(proto.binaries.len(), 4);
}
#[test]
fn github_git_transport_allows_clone_but_not_push() {
let profile = builtin_profile("github");
let proto = profile.to_proto();
let git_transport = proto
.endpoints
.iter()
.find(|endpoint| endpoint.host == "github.com" && endpoint.port == 443)
.expect("github.com git transport endpoint");
// The git transport carries explicit rules rather than an access preset
// (an empty preset would otherwise expand to GET/HEAD/OPTIONS).
assert!(
git_transport.access.is_empty(),
"git transport must use explicit rules, not an access preset"
);
// Assert the EXACT allowed rule set. Clone/fetch over git smart HTTP
// performs GET */info/refs (ref discovery) followed by POST
// */git-upload-pack. A substring check alone is not enough: a broader or
// additional POST rule (e.g. POST **) would also permit push via
// git-receive-pack while still passing a "some rule allows upload-pack"
// check. Pinning the whole set fails on any such regression. See #1769.
let mut allowed: Vec<(&str, &str)> = git_transport
.rules
.iter()
.map(|rule| {
let allow = rule
.allow
.as_ref()
.expect("git transport rules must be allow rules");
(allow.method.as_str(), allow.path.as_str())
})
.collect();
allowed.sort_unstable();
let mut expected = vec![
("GET", "**"),
("HEAD", "**"),
("OPTIONS", "**"),
("POST", "/**/git-upload-pack"),
];
expected.sort_unstable();
assert_eq!(
allowed, expected,
"git transport allow rules must be exactly the read-only methods \
plus POST */git-upload-pack (clone/fetch); a broader or extra POST \
rule would enable push (git-receive-pack)"
);
// Blocking push must not depend on a deny rule, which could mask an
// over-broad allow and hide a regression.
assert!(
git_transport.deny_rules.is_empty(),
"git transport should block push via its narrow allow set, not deny rules"
);
}
#[test]
fn credential_env_vars_are_deduplicated_in_profile_order() {
let profile = builtin_profile("claude-code");
@@ -2916,6 +2990,8 @@ binaries: ["", /usr/bin/broken]
binaries: Vec::new(),
inference_capable: false,
discovery: DiscoveryProfile::default(),
source: String::new(),
scope: String::new(),
},
),
(
@@ -2932,6 +3008,8 @@ binaries: ["", /usr/bin/broken]
binaries: Vec::new(),
inference_capable: false,
discovery: DiscoveryProfile::default(),
source: String::new(),
scope: String::new(),
},
),
(
@@ -2948,6 +3026,8 @@ binaries: ["", /usr/bin/broken]
binaries: Vec::new(),
inference_capable: false,
discovery: DiscoveryProfile::default(),
source: String::new(),
scope: String::new(),
},
),
];
@@ -12,7 +12,7 @@
use std::collections::HashMap;
use std::future::Future;
use tokio::sync::mpsc;
use tracing::debug;
use tracing::{debug, warn};
pub use openshell_core::activity::ActivityEvent;
@@ -45,10 +45,13 @@ impl ActivityAggregator {
}
}
pub async fn run<F, Fut>(mut self, flush_callback: F)
/// `ready_gate` is checked before each flush. When it returns `false`,
/// the drain is skipped and events stay in the buffer until the next tick.
pub async fn run<F, Fut, G>(mut self, flush_callback: F, ready_gate: G)
where
F: Fn(FlushableActivitySummary) -> Fut + Send + 'static,
Fut: Future<Output = ()> + Send + 'static,
G: Fn() -> bool,
{
let (flush_tx, mut flush_rx) =
mpsc::channel::<FlushableActivitySummary>(ACTIVITY_FLUSH_QUEUE_CAPACITY);
@@ -68,15 +71,26 @@ impl ActivityAggregator {
if let Some(event) = event {
self.ingest(event);
} else {
if let Some(summary) = self.drain() {
queue_flush_summary(&flush_tx, summary);
if self.network_activity_count > 0 {
if ready_gate() {
if let Some(summary) = self.drain() {
queue_flush_summary(&flush_tx, summary);
}
} else {
warn!(
count = self.network_activity_count,
"ActivityAggregator: dropping unflushed events, workspace not yet known"
);
}
}
debug!("ActivityAggregator: channel closed, exiting");
return;
}
}
_ = flush_interval.tick() => {
if let Some(summary) = self.drain() {
if ready_gate()
&& let Some(summary) = self.drain()
{
debug!(
count = summary.network_activity_count,
denied = summary.denied_action_count,
@@ -12,7 +12,7 @@
use std::collections::HashMap;
use std::future::Future;
use tokio::sync::mpsc;
use tracing::debug;
use tracing::{debug, warn};
use openshell_core::denial::DenialEvent;
@@ -65,10 +65,14 @@ impl DenialAggregator {
///
/// `flush_callback` is called periodically with the accumulated summaries.
/// In production this calls `SubmitPolicyAnalysis` on the gateway.
pub async fn run<F, Fut>(mut self, flush_callback: F)
///
/// `ready_gate` is checked before each flush. When it returns `false`,
/// the drain is skipped and events stay in the buffer until the next tick.
pub async fn run<F, Fut, G>(mut self, flush_callback: F, ready_gate: G)
where
F: Fn(Vec<FlushableDenialSummary>) -> Fut,
Fut: Future<Output = ()>,
G: Fn() -> bool,
{
let mut flush_interval =
tokio::time::interval(std::time::Duration::from_secs(self.flush_interval_secs));
@@ -83,15 +87,22 @@ impl DenialAggregator {
} else {
// Channel closed; do a final flush and exit.
if !self.summaries.is_empty() {
let batch = self.drain();
flush_callback(batch).await;
if ready_gate() {
let batch = self.drain();
flush_callback(batch).await;
} else {
warn!(
count = self.summaries.len(),
"DenialAggregator: dropping unflushed summaries, workspace not yet known"
);
}
}
debug!("DenialAggregator: channel closed, exiting");
return;
}
}
_ = flush_interval.tick() => {
if !self.summaries.is_empty() {
if ready_gate() && !self.summaries.is_empty() {
let batch = self.drain();
debug!(count = batch.len(), "DenialAggregator: flushing summaries");
flush_callback(batch).await;
+110 -49
View File
@@ -361,6 +361,11 @@ pub async fn run_sandbox(
#[cfg(not(target_os = "linux"))]
drop(bypass_activity_tx);
// Workspace watch: the policy poll loop learns the workspace from
// GetSandboxConfig and broadcasts it. Flush tasks and the policy.local
// API read the current value so proposals target the correct workspace.
let (workspace_tx, workspace_rx) = tokio::sync::watch::channel(String::new());
let networking = if network_enabled {
#[cfg(target_os = "linux")]
let proxy_bind_ip = netns
@@ -384,6 +389,7 @@ pub async fn run_sandbox(
inference_routes.as_deref(),
denial_tx,
activity_tx,
workspace_rx.clone(),
)
.await?,
)
@@ -482,20 +488,31 @@ pub async fn run_sandbox(
.unwrap_or(10);
let aggregator = denial_aggregator::DenialAggregator::new(rx, flush_interval_secs);
let denial_workspace_gate = workspace_rx.clone();
let denial_workspace_rx = workspace_rx.clone();
tokio::spawn(async move {
aggregator
.run(|summaries| {
let endpoint = agg_endpoint.clone();
let sandbox_name = agg_name.clone();
async move {
if let Err(e) =
flush_proposals_to_gateway(&endpoint, &sandbox_name, summaries).await
{
warn!(error = %e, "Failed to flush denial summaries to gateway");
.run(
|summaries| {
let endpoint = agg_endpoint.clone();
let sandbox_name = agg_name.clone();
let workspace = denial_workspace_rx.borrow().clone();
async move {
if let Err(e) = flush_proposals_to_gateway(
&endpoint,
&sandbox_name,
&workspace,
summaries,
)
.await
{
warn!(error = %e, "Failed to flush denial summaries to gateway");
}
}
}
})
},
move || !denial_workspace_gate.borrow().is_empty(),
)
.await;
});
}
@@ -516,20 +533,31 @@ pub async fn run_sandbox(
);
let aggregator = activity_aggregator::ActivityAggregator::new(rx, flush_interval_secs);
let activity_workspace_gate = workspace_rx.clone();
let activity_workspace_rx = workspace_rx.clone();
tokio::spawn(async move {
aggregator
.run(move |summary| {
let endpoint = agg_endpoint.clone();
let sandbox_name = agg_name.clone();
async move {
if let Err(e) =
flush_activity_to_gateway(&endpoint, &sandbox_name, summary).await
{
warn!(error = %e, "Failed to flush activity summary to gateway");
.run(
move |summary| {
let endpoint = agg_endpoint.clone();
let sandbox_name = agg_name.clone();
let workspace = activity_workspace_rx.borrow().clone();
async move {
if let Err(e) = flush_activity_to_gateway(
&endpoint,
&sandbox_name,
&workspace,
summary,
)
.await
{
warn!(error = %e, "Failed to flush activity summary to gateway");
}
}
}
})
},
move || !activity_workspace_gate.borrow().is_empty(),
)
.await;
});
}
@@ -565,6 +593,7 @@ pub async fn run_sandbox(
policy_local_ctx: poll_policy_local,
middleware_registry_status,
sidecar_control_publisher: sidecar_control_publisher.clone(),
workspace_tx,
};
tokio::spawn(async move {
@@ -981,12 +1010,14 @@ fn process_policy_for_topology(
async fn flush_proposals_to_gateway(
endpoint: &str,
sandbox_name: &str,
workspace: &str,
summaries: Vec<denial_aggregator::FlushableDenialSummary>,
) -> Result<()> {
use openshell_core::grpc_client::CachedOpenShellClient;
use openshell_core::proto::{DenialSummary, L7RequestSample};
let client = CachedOpenShellClient::connect(endpoint).await?;
client.set_workspace(workspace.to_string());
let proto_summaries: Vec<DenialSummary> = summaries
.into_iter()
@@ -1049,12 +1080,14 @@ async fn flush_proposals_to_gateway(
async fn flush_activity_to_gateway(
endpoint: &str,
sandbox_name: &str,
workspace: &str,
summary: activity_aggregator::FlushableActivitySummary,
) -> Result<()> {
use openshell_core::grpc_client::CachedOpenShellClient;
use openshell_core::proto::{DenialGroupCount, NetworkActivitySummary};
let client = CachedOpenShellClient::connect(endpoint).await?;
client.set_workspace(workspace.to_string());
let proto_summary = NetworkActivitySummary {
network_activity_count: summary.network_activity_count,
@@ -1889,12 +1922,14 @@ async fn load_policy(
// Sync and re-fetch over a single connection to avoid extra
// TLS handshakes.
let ws = snapshot.workspace.clone();
snapshot = grpc_retry("Policy discovery sync", || {
openshell_core::grpc_client::sync_policy_and_fetch_snapshot(
endpoint,
id,
sandbox,
&discovered,
&ws,
)
})
.await?;
@@ -1921,6 +1956,7 @@ async fn load_policy(
id,
sandbox_name,
&sync_policy,
&snapshot.workspace,
)
.await
{
@@ -2470,6 +2506,7 @@ struct PolicyPollLoopContext {
policy_local_ctx: Option<Arc<openshell_supervisor_network::policy_local::PolicyLocalContext>>,
middleware_registry_status: MiddlewareRegistryStatus,
sidecar_control_publisher: Option<sidecar_control::Publisher>,
workspace_tx: tokio::sync::watch::Sender<String>,
}
async fn connect_middleware_registry(
@@ -2581,35 +2618,38 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> {
// policy revision the supervisor actually loaded. A mismatched result is
// reconciled below instead of being recorded as already applied.
match client.poll_settings(&ctx.sandbox_id).await {
Ok(result) => match initial_poll_disposition(&ctx.loaded_policy_origin, &result) {
InitialPollDisposition::Acknowledge(candidate) => {
apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings);
current_config_revision = candidate.config_revision;
current_policy_hash.clone_from(&candidate.policy_hash);
current_middleware_services = result.supervisor_middleware_services;
current_settings = result.settings;
enqueue_policy_status(
&status_sender,
PolicyStatusUpdate::initial_loaded(&candidate),
);
debug!(
config_revision = current_config_revision,
"Settings poll: initial policy matches loaded revision"
);
Ok(result) => {
let _ = ctx.workspace_tx.send(client.workspace());
match initial_poll_disposition(&ctx.loaded_policy_origin, &result) {
InitialPollDisposition::Acknowledge(candidate) => {
apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings);
current_config_revision = candidate.config_revision;
current_policy_hash.clone_from(&candidate.policy_hash);
current_middleware_services = result.supervisor_middleware_services;
current_settings = result.settings;
enqueue_policy_status(
&status_sender,
PolicyStatusUpdate::initial_loaded(&candidate),
);
debug!(
config_revision = current_config_revision,
"Settings poll: initial policy matches loaded revision"
);
}
InitialPollDisposition::Reconcile => pending_result = Some(result),
InitialPollDisposition::TrackOnly => {
apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings);
current_config_revision = result.config_revision;
current_policy_hash = result.policy_hash.clone();
current_middleware_services = result.supervisor_middleware_services;
current_settings = result.settings;
debug!(
config_revision = current_config_revision,
"Settings poll: tracking gateway config while preserving local policy override"
);
}
}
InitialPollDisposition::Reconcile => pending_result = Some(result),
InitialPollDisposition::TrackOnly => {
apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings);
current_config_revision = result.config_revision;
current_policy_hash = result.policy_hash.clone();
current_middleware_services = result.supervisor_middleware_services;
current_settings = result.settings;
debug!(
config_revision = current_config_revision,
"Settings poll: tracking gateway config while preserving local policy override"
);
}
},
}
Err(e) => {
warn!(error = %e, "Settings poll: failed to fetch initial version, will retry");
}
@@ -2622,7 +2662,10 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> {
} else {
tokio::time::sleep(interval).await;
match client.poll_settings(&ctx.sandbox_id).await {
Ok(result) => result,
Ok(result) => {
let _ = ctx.workspace_tx.send(client.workspace());
result
}
Err(e) => {
debug!(error = %e, "Settings poll: server unreachable, will retry");
continue;
@@ -3273,6 +3316,7 @@ filesystem_policy:
global_policy_version: 0,
provider_env_revision: 0,
supervisor_middleware_services: Vec::new(),
workspace: String::new(),
}
}
@@ -3551,4 +3595,21 @@ filesystem_policy:
);
}
}
#[test]
fn settings_snapshot_carries_workspace_for_policy_sync() {
let mut snapshot = settings_poll_result(
Some(proto_policy_fixture()),
1,
openshell_core::proto::PolicySource::Sandbox,
);
snapshot.workspace = "beta".to_string();
let revision = LoadedPolicyRevision::from_snapshot(&snapshot);
assert_eq!(revision.version, 1);
assert_eq!(
snapshot.workspace, "beta",
"workspace must survive the snapshot so sync_policy_and_fetch_snapshot receives it"
);
}
}
+291
View File
@@ -16,6 +16,7 @@ use crate::refresh::{RefreshedToken, TokenSource};
use crate::transport;
use crate::types::{
ExecOptions, ExecResult, Health, ListOptions, SandboxPhase, SandboxRef, SandboxSpec,
WorkspaceRef,
};
use futures::StreamExt;
use openshell_core::proto;
@@ -168,6 +169,7 @@ impl OpenShellClient {
.unary(|mut grpc| {
let request = proto::GetSandboxRequest {
name: name.to_string(),
workspace: String::new(),
};
async move { grpc.get_sandbox(request).await }
})
@@ -183,6 +185,8 @@ impl OpenShellClient {
limit: opts.limit,
offset: opts.offset,
label_selector: opts.label_selector.clone().unwrap_or_default(),
workspace: String::new(),
all_workspaces: false,
};
async move { grpc.list_sandboxes(request).await }
})
@@ -205,6 +209,7 @@ impl OpenShellClient {
.unary(|mut grpc| {
let request = proto::DeleteSandboxRequest {
name: name.to_string(),
workspace: String::new(),
};
async move { grpc.delete_sandbox(request).await }
})
@@ -252,6 +257,110 @@ impl OpenShellClient {
}
}
/// Return a workspace-scoped handle for sandbox operations.
///
/// Analogous to `kube::Api::namespaced` — captures the workspace once and
/// injects it into every request.
pub fn workspace(&self, workspace: &str) -> WorkspaceScopedClient {
WorkspaceScopedClient {
client: self.clone(),
workspace: workspace.to_string(),
}
}
/// List sandboxes across all workspaces.
pub async fn list_sandboxes_all_workspaces(
&self,
opts: ListOptions,
) -> Result<Vec<SandboxRef>> {
let response = self
.unary(|mut grpc| {
let request = proto::ListSandboxesRequest {
limit: opts.limit,
offset: opts.offset,
label_selector: opts.label_selector.clone().unwrap_or_default(),
workspace: String::new(),
all_workspaces: true,
};
async move { grpc.list_sandboxes(request).await }
})
.await?;
Ok(response
.sandboxes
.into_iter()
.map(SandboxRef::from_proto)
.collect())
}
/// Create a new workspace.
pub async fn create_workspace(
&self,
name: &str,
labels: HashMap<String, String>,
) -> Result<WorkspaceRef> {
let response = self
.unary(|mut grpc| {
let request = proto::CreateWorkspaceRequest {
name: name.to_string(),
labels: labels.clone(),
};
async move { grpc.create_workspace(request).await }
})
.await?;
response
.workspace
.map(WorkspaceRef::from_proto)
.ok_or_else(|| SdkError::invalid_config("workspace missing from gateway response"))
}
/// Fetch a workspace by name.
pub async fn get_workspace(&self, name: &str) -> Result<WorkspaceRef> {
let response = self
.unary(|mut grpc| {
let request = proto::GetWorkspaceRequest {
name: name.to_string(),
};
async move { grpc.get_workspace(request).await }
})
.await?;
response
.workspace
.map(WorkspaceRef::from_proto)
.ok_or_else(|| SdkError::invalid_config("workspace missing from gateway response"))
}
/// List workspaces.
pub async fn list_workspaces(&self, opts: ListOptions) -> Result<Vec<WorkspaceRef>> {
let response = self
.unary(|mut grpc| {
let request = proto::ListWorkspacesRequest {
limit: opts.limit,
offset: opts.offset,
label_selector: opts.label_selector.clone().unwrap_or_default(),
};
async move { grpc.list_workspaces(request).await }
})
.await?;
Ok(response
.workspaces
.into_iter()
.map(WorkspaceRef::from_proto)
.collect())
}
/// Delete a workspace by name.
pub async fn delete_workspace(&self, name: &str) -> Result<bool> {
let response = self
.unary(|mut grpc| {
let request = proto::DeleteWorkspaceRequest {
name: name.to_string(),
};
async move { grpc.delete_workspace(request).await }
})
.await?;
Ok(response.deleted)
}
/// Run a command inside a sandbox and buffer stdout/stderr to the end.
///
/// For streaming output, drop down to [`OpenShellClient::raw_grpc`] and
@@ -395,6 +504,187 @@ impl OpenShellClient {
}
}
/// Workspace-scoped sandbox operations, analogous to `kube::Api::namespaced`.
///
/// Constructed via [`OpenShellClient::workspace`]. Captures the workspace
/// once and injects it into every request.
#[derive(Clone)]
pub struct WorkspaceScopedClient {
client: OpenShellClient,
workspace: String,
}
impl WorkspaceScopedClient {
/// The workspace this client targets.
pub fn workspace_name(&self) -> &str {
&self.workspace
}
/// Create a new sandbox in this workspace.
pub async fn create_sandbox(&self, spec: SandboxSpec) -> Result<SandboxRef> {
let mut request = create_sandbox_request(spec);
request.workspace = self.workspace.clone();
let response = self
.client
.unary(|mut grpc| {
let request = request.clone();
async move { grpc.create_sandbox(request).await }
})
.await?;
sandbox_from_response(response.sandbox)
}
/// Fetch a sandbox by name in this workspace.
pub async fn get_sandbox(&self, name: &str) -> Result<SandboxRef> {
let response = self
.client
.unary(|mut grpc| {
let request = proto::GetSandboxRequest {
name: name.to_string(),
workspace: self.workspace.clone(),
};
async move { grpc.get_sandbox(request).await }
})
.await?;
sandbox_from_response(response.sandbox)
}
/// List sandboxes in this workspace.
pub async fn list_sandboxes(&self, opts: ListOptions) -> Result<Vec<SandboxRef>> {
let response = self
.client
.unary(|mut grpc| {
let request = proto::ListSandboxesRequest {
limit: opts.limit,
offset: opts.offset,
label_selector: opts.label_selector.clone().unwrap_or_default(),
workspace: self.workspace.clone(),
all_workspaces: false,
};
async move { grpc.list_sandboxes(request).await }
})
.await?;
Ok(response
.sandboxes
.into_iter()
.map(SandboxRef::from_proto)
.collect())
}
/// Delete a sandbox by name in this workspace.
pub async fn delete_sandbox(&self, name: &str) -> Result<bool> {
let response = self
.client
.unary(|mut grpc| {
let request = proto::DeleteSandboxRequest {
name: name.to_string(),
workspace: self.workspace.clone(),
};
async move { grpc.delete_sandbox(request).await }
})
.await?;
Ok(response.deleted)
}
/// Poll until the sandbox reaches [`SandboxPhase::Ready`] or the timeout
/// elapses.
pub async fn wait_ready(&self, name: &str, timeout: Duration) -> Result<SandboxRef> {
let deadline = Instant::now() + timeout;
let mut delay = Duration::from_millis(250);
loop {
let snapshot = self.get_sandbox(name).await?;
match snapshot.phase {
SandboxPhase::Ready => return Ok(snapshot),
SandboxPhase::Error => {
return Err(SdkError::connect(format!(
"sandbox '{name}' entered error phase"
)));
}
_ => {}
}
if Instant::now() >= deadline {
return Err(SdkError::connect(format!(
"timed out waiting for sandbox '{name}'"
)));
}
tokio::time::sleep(delay).await;
delay = (delay * 2).min(Duration::from_secs(2));
}
}
/// Poll until the sandbox is gone (`NotFound`) or the timeout elapses.
pub async fn wait_deleted(&self, name: &str, timeout: Duration) -> Result<()> {
let deadline = Instant::now() + timeout;
let mut delay = Duration::from_millis(250);
loop {
match self.get_sandbox(name).await {
Err(SdkError::NotFound { .. }) => return Ok(()),
Err(other) => return Err(other),
Ok(_) => {}
}
if Instant::now() >= deadline {
return Err(SdkError::connect(format!(
"timed out waiting for sandbox '{name}' to delete"
)));
}
tokio::time::sleep(delay).await;
delay = (delay * 2).min(Duration::from_secs(2));
}
}
/// Run a command inside a sandbox and buffer stdout/stderr.
pub async fn exec(&self, name: &str, cmd: &[String], opts: ExecOptions) -> Result<ExecResult> {
let sandbox = self.get_sandbox(name).await?;
let request = proto::ExecSandboxRequest {
sandbox_id: sandbox.id,
command: cmd.to_vec(),
workdir: opts.workdir.unwrap_or_default(),
environment: opts.environment,
timeout_seconds: opts
.timeout
.map_or(0, |d| u32::try_from(d.as_secs()).unwrap_or(u32::MAX)),
stdin: opts.stdin.unwrap_or_default(),
tty: false,
cols: 0,
rows: 0,
};
let mut stream = self
.client
.unary(|mut grpc| {
let request = request.clone();
async move { grpc.exec_sandbox(request).await }
})
.await?;
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut exit_code: Option<i32> = None;
while let Some(event) = stream.next().await {
let event = event.map_err(map_status)?;
match event.payload {
Some(proto::exec_sandbox_event::Payload::Stdout(chunk)) => {
stdout.extend_from_slice(&chunk.data);
}
Some(proto::exec_sandbox_event::Payload::Stderr(chunk)) => {
stderr.extend_from_slice(&chunk.data);
}
Some(proto::exec_sandbox_event::Payload::Exit(exit)) => {
exit_code = Some(exit.exit_code);
}
None => {}
}
}
Ok(ExecResult {
exit_code: exit_code.unwrap_or(-1),
stdout,
stderr,
})
}
}
fn interceptor_from_config(config: &ClientConfig) -> Result<EdgeAuthInterceptor> {
match &config.auth {
None => Ok(EdgeAuthInterceptor::noop()),
@@ -470,6 +760,7 @@ fn create_sandbox_request(spec: SandboxSpec) -> proto::CreateSandboxRequest {
name: name.unwrap_or_default(),
labels,
annotations: HashMap::new(),
workspace: String::new(),
}
}
+2 -2
View File
@@ -41,11 +41,11 @@ pub mod transport;
pub mod types;
pub use auth::EdgeAuthInterceptor;
pub use client::OpenShellClient;
pub use client::{OpenShellClient, WorkspaceScopedClient};
pub use config::{AuthConfig, ClientConfig};
pub use error::SdkError;
pub use refresh::{Refresh, RefreshError, RefreshedToken, TokenSource};
pub use types::{
ExecOptions, ExecResult, Health, ListOptions, SandboxPhase, SandboxRef, SandboxSpec,
ServiceStatus,
ServiceStatus, WorkspaceRef,
};
+4 -3
View File
@@ -22,10 +22,11 @@ pub use openshell_core::proto;
pub use openshell_core::proto::inference_client::InferenceClient;
pub use openshell_core::proto::open_shell_client::OpenShellClient as GrpcClient;
pub use openshell_core::proto::{
CreateSandboxRequest, DeleteSandboxRequest, ExecSandboxRequest, GetSandboxRequest,
HealthRequest, ListProvidersRequest, ListSandboxesRequest, Sandbox,
CreateSandboxRequest, CreateWorkspaceRequest, DeleteSandboxRequest, DeleteWorkspaceRequest,
ExecSandboxRequest, GetSandboxRequest, GetWorkspaceRequest, HealthRequest,
ListProvidersRequest, ListSandboxesRequest, ListWorkspacesRequest, Sandbox,
SandboxPhase as ProtoSandboxPhase, SandboxSpec as ProtoSandboxSpec, SandboxTemplate,
ServiceStatus as ProtoServiceStatus,
ServiceStatus as ProtoServiceStatus, Workspace,
};
/// Type alias for the gRPC client wrapped in the SDK's auth interceptor.
+30
View File
@@ -111,6 +111,7 @@ pub struct SandboxSpec {
pub struct SandboxRef {
pub id: String,
pub name: String,
pub workspace: String,
pub phase: SandboxPhase,
pub labels: HashMap<String, String>,
pub resource_version: u64,
@@ -123,6 +124,7 @@ impl SandboxRef {
Self {
id: meta.id,
name: meta.name,
workspace: meta.workspace,
phase,
labels: meta.labels,
resource_version: meta.resource_version,
@@ -130,6 +132,34 @@ impl SandboxRef {
}
}
/// Reference to a workspace on the gateway.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct WorkspaceRef {
pub name: String,
pub phase: String,
pub labels: HashMap<String, String>,
}
impl WorkspaceRef {
pub(crate) fn from_proto(workspace: proto::Workspace) -> Self {
let meta = workspace.metadata.unwrap_or_default();
let phase = workspace
.status
.and_then(|s| proto::datamodel::v1::WorkspacePhase::try_from(s.phase).ok())
.map_or("Unknown", |p| match p {
proto::datamodel::v1::WorkspacePhase::Unspecified => "Unspecified",
proto::datamodel::v1::WorkspacePhase::Active => "Active",
proto::datamodel::v1::WorkspacePhase::Terminating => "Terminating",
});
Self {
name: meta.name,
phase: phase.to_string(),
labels: meta.labels,
}
}
}
/// Options for listing sandboxes.
#[derive(Clone, Debug, Default)]
pub struct ListOptions {
+265 -3
View File
@@ -27,10 +27,13 @@ use tonic::{Response, Status};
#[derive(Default)]
struct MockState {
last_get_name: Mutex<Option<String>>,
last_get_workspace: Mutex<Option<String>>,
last_create: Mutex<Option<proto::CreateSandboxRequest>>,
last_delete_name: Mutex<Option<String>>,
last_delete_workspace: Mutex<Option<String>>,
last_list_request: Mutex<Option<proto::ListSandboxesRequest>>,
last_exec_request: Mutex<Option<proto::ExecSandboxRequest>>,
last_workspace_request: Mutex<Option<String>>,
get_calls: AtomicU32,
phase_sequence: Vec<proto::SandboxPhase>,
get_returns_not_found: bool,
@@ -48,6 +51,14 @@ struct TestOpenShell {
}
fn sandbox_with_phase(name: &str, phase: proto::SandboxPhase) -> proto::Sandbox {
sandbox_with_phase_ws(name, phase, "default")
}
fn sandbox_with_phase_ws(
name: &str,
phase: proto::SandboxPhase,
workspace: &str,
) -> proto::Sandbox {
proto::Sandbox {
metadata: Some(proto::datamodel::v1::ObjectMeta {
id: format!("id-{name}"),
@@ -56,6 +67,8 @@ fn sandbox_with_phase(name: &str, phase: proto::SandboxPhase) -> proto::Sandbox
labels: HashMap::new(),
annotations: HashMap::new(),
resource_version: 1,
deletion_timestamp_ms: 0,
workspace: workspace.to_string(),
}),
spec: None,
status: Some(proto::SandboxStatus {
@@ -65,6 +78,24 @@ fn sandbox_with_phase(name: &str, phase: proto::SandboxPhase) -> proto::Sandbox
}
}
fn workspace_proto(name: &str, phase: proto::datamodel::v1::WorkspacePhase) -> proto::Workspace {
proto::Workspace {
metadata: Some(proto::datamodel::v1::ObjectMeta {
id: format!("ws-{name}"),
name: name.to_string(),
created_at_ms: 1_000_000,
labels: HashMap::new(),
annotations: HashMap::new(),
resource_version: 1,
deletion_timestamp_ms: 0,
workspace: String::new(),
}),
status: Some(proto::datamodel::v1::WorkspaceStatus {
phase: phase.into(),
}),
}
}
#[tonic::async_trait]
impl OpenShell for TestOpenShell {
async fn health(
@@ -127,8 +158,10 @@ impl OpenShell for TestOpenShell {
&self,
request: tonic::Request<proto::GetSandboxRequest>,
) -> Result<Response<proto::SandboxResponse>, Status> {
let name = request.into_inner().name;
let req = request.into_inner();
let name = req.name;
*self.state.last_get_name.lock().await = Some(name.clone());
*self.state.last_get_workspace.lock().await = Some(req.workspace.clone());
let count = self.state.get_calls.fetch_add(1, Ordering::SeqCst);
if self.state.get_returns_not_found {
@@ -195,8 +228,9 @@ impl OpenShell for TestOpenShell {
&self,
request: tonic::Request<proto::DeleteSandboxRequest>,
) -> Result<Response<proto::DeleteSandboxResponse>, Status> {
let name = request.into_inner().name;
*self.state.last_delete_name.lock().await = Some(name);
let req = request.into_inner();
*self.state.last_delete_name.lock().await = Some(req.name);
*self.state.last_delete_workspace.lock().await = Some(req.workspace);
Ok(Response::new(proto::DeleteSandboxResponse {
deleted: true,
}))
@@ -576,6 +610,77 @@ impl OpenShell for TestOpenShell {
) -> Result<Response<proto::RefreshSandboxTokenResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn create_workspace(
&self,
request: tonic::Request<proto::CreateWorkspaceRequest>,
) -> Result<Response<proto::CreateWorkspaceResponse>, Status> {
let req = request.into_inner();
*self.state.last_workspace_request.lock().await = Some(req.name.clone());
Ok(Response::new(proto::CreateWorkspaceResponse {
workspace: Some(workspace_proto(
&req.name,
proto::datamodel::v1::WorkspacePhase::Active,
)),
}))
}
async fn get_workspace(
&self,
request: tonic::Request<proto::GetWorkspaceRequest>,
) -> Result<Response<proto::GetWorkspaceResponse>, Status> {
let name = request.into_inner().name;
*self.state.last_workspace_request.lock().await = Some(name.clone());
Ok(Response::new(proto::GetWorkspaceResponse {
workspace: Some(workspace_proto(
&name,
proto::datamodel::v1::WorkspacePhase::Active,
)),
}))
}
async fn list_workspaces(
&self,
_: tonic::Request<proto::ListWorkspacesRequest>,
) -> Result<Response<proto::ListWorkspacesResponse>, Status> {
Ok(Response::new(proto::ListWorkspacesResponse {
workspaces: vec![
workspace_proto("default", proto::datamodel::v1::WorkspacePhase::Active),
workspace_proto("staging", proto::datamodel::v1::WorkspacePhase::Active),
],
}))
}
async fn delete_workspace(
&self,
request: tonic::Request<proto::DeleteWorkspaceRequest>,
) -> Result<Response<proto::DeleteWorkspaceResponse>, Status> {
*self.state.last_workspace_request.lock().await = Some(request.into_inner().name);
Ok(Response::new(proto::DeleteWorkspaceResponse {
deleted: true,
}))
}
async fn add_workspace_member(
&self,
_: tonic::Request<proto::AddWorkspaceMemberRequest>,
) -> Result<Response<proto::AddWorkspaceMemberResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn remove_workspace_member(
&self,
_: tonic::Request<proto::RemoveWorkspaceMemberRequest>,
) -> Result<Response<proto::RemoveWorkspaceMemberResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn list_workspace_members(
&self,
_: tonic::Request<proto::ListWorkspaceMembersRequest>,
) -> Result<Response<proto::ListWorkspaceMembersResponse>, Status> {
Err(Status::unimplemented("unused"))
}
}
/// Spin up the mock gateway, return its endpoint URL.
@@ -892,6 +997,163 @@ async fn unauthenticated_without_refresher_surfaces_error() {
);
}
// ---- Workspace-scoped client tests ----
#[tokio::test]
async fn workspace_scoped_create_passes_workspace() {
let state = Arc::new(MockState::default());
let endpoint = start_mock(state.clone()).await;
let client = connect(&endpoint).await;
let ws = client.workspace("staging");
let spec = SandboxSpec {
name: Some("my-box".to_string()),
..Default::default()
};
let result = ws.create_sandbox(spec).await.unwrap();
assert_eq!(result.name, "my-box");
let observed = state.last_create.lock().await.clone().unwrap();
assert_eq!(observed.workspace, "staging");
}
#[tokio::test]
async fn workspace_scoped_get_passes_workspace() {
let state = Arc::new(MockState {
phase_sequence: vec![proto::SandboxPhase::Ready],
..Default::default()
});
let endpoint = start_mock(state.clone()).await;
let client = connect(&endpoint).await;
let ws = client.workspace("production");
let sandbox = ws.get_sandbox("my-box").await.unwrap();
assert_eq!(sandbox.name, "my-box");
let observed_ws = state.last_get_workspace.lock().await.clone();
assert_eq!(observed_ws.as_deref(), Some("production"));
}
#[tokio::test]
async fn workspace_scoped_list_passes_workspace() {
let state = Arc::new(MockState::default());
let endpoint = start_mock(state.clone()).await;
let client = connect(&endpoint).await;
let ws = client.workspace("dev");
let items = ws.list_sandboxes(ListOptions::default()).await.unwrap();
assert_eq!(items.len(), 2);
let observed = state.last_list_request.lock().await.clone().unwrap();
assert_eq!(observed.workspace, "dev");
assert!(!observed.all_workspaces);
}
#[tokio::test]
async fn workspace_scoped_delete_passes_workspace() {
let state = Arc::new(MockState::default());
let endpoint = start_mock(state.clone()).await;
let client = connect(&endpoint).await;
let ws = client.workspace("staging");
let deleted = ws.delete_sandbox("doomed").await.unwrap();
assert!(deleted);
let observed_ws = state.last_delete_workspace.lock().await.clone();
assert_eq!(observed_ws.as_deref(), Some("staging"));
}
#[tokio::test]
async fn list_sandboxes_all_workspaces_sets_flag() {
let state = Arc::new(MockState::default());
let endpoint = start_mock(state.clone()).await;
let client = connect(&endpoint).await;
let items = client
.list_sandboxes_all_workspaces(ListOptions::default())
.await
.unwrap();
assert_eq!(items.len(), 2);
let observed = state.last_list_request.lock().await.clone().unwrap();
assert!(observed.all_workspaces);
assert!(observed.workspace.is_empty());
}
// ---- Workspace CRUD tests ----
#[tokio::test]
async fn create_workspace_returns_ref() {
let state = Arc::new(MockState::default());
let endpoint = start_mock(state.clone()).await;
let client = connect(&endpoint).await;
let ws = client
.create_workspace("staging", HashMap::new())
.await
.unwrap();
assert_eq!(ws.name, "staging");
assert_eq!(ws.phase, "Active");
let observed = state.last_workspace_request.lock().await.clone();
assert_eq!(observed.as_deref(), Some("staging"));
}
#[tokio::test]
async fn get_workspace_returns_ref() {
let state = Arc::new(MockState::default());
let endpoint = start_mock(state.clone()).await;
let client = connect(&endpoint).await;
let ws = client.get_workspace("production").await.unwrap();
assert_eq!(ws.name, "production");
assert_eq!(ws.phase, "Active");
let observed = state.last_workspace_request.lock().await.clone();
assert_eq!(observed.as_deref(), Some("production"));
}
#[tokio::test]
async fn list_workspaces_returns_all() {
let state = Arc::new(MockState::default());
let endpoint = start_mock(state.clone()).await;
let client = connect(&endpoint).await;
let workspaces = client
.list_workspaces(ListOptions::default())
.await
.unwrap();
assert_eq!(workspaces.len(), 2);
assert_eq!(workspaces[0].name, "default");
assert_eq!(workspaces[1].name, "staging");
}
#[tokio::test]
async fn delete_workspace_returns_ack() {
let state = Arc::new(MockState::default());
let endpoint = start_mock(state.clone()).await;
let client = connect(&endpoint).await;
let deleted = client.delete_workspace("doomed").await.unwrap();
assert!(deleted);
let observed = state.last_workspace_request.lock().await.clone();
assert_eq!(observed.as_deref(), Some("doomed"));
}
#[tokio::test]
async fn sandbox_ref_includes_workspace_field() {
let state = Arc::new(MockState {
phase_sequence: vec![proto::SandboxPhase::Ready],
..Default::default()
});
let endpoint = start_mock(state.clone()).await;
let client = connect(&endpoint).await;
let sandbox = client.get_sandbox("my-box").await.unwrap();
assert_eq!(sandbox.workspace, "default");
}
/// Regression (P1): raw RPCs do not drive refresh. A call through the plain
/// `raw_grpc()` accessor keeps sending the seeded token, while
/// `raw_grpc_fresh()` proactively refreshes a near-expiry token into the
@@ -0,0 +1,15 @@
-- Add workspace column for multi-tenant isolation.
ALTER TABLE objects ADD COLUMN workspace TEXT NOT NULL DEFAULT '';
-- Backfill workspace-scoped object types to 'default'.
-- stored_provider_profile is intentionally omitted: profiles use workspace=""
-- for platform scope and are created with an explicit workspace when scoped.
UPDATE objects SET workspace = 'default'
WHERE workspace = '' AND name IS NOT NULL
AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session', 'provider_credential_refresh_state', 'sandbox_policy', 'draft_policy_chunk', 'sandbox_settings');
-- Replace global name uniqueness with workspace-scoped uniqueness.
DROP INDEX IF EXISTS objects_name_uq;
CREATE UNIQUE INDEX objects_name_uq
ON objects (object_type, workspace, name)
WHERE name IS NOT NULL;
@@ -0,0 +1,15 @@
-- Add workspace column for multi-tenant isolation.
ALTER TABLE objects ADD COLUMN workspace TEXT NOT NULL DEFAULT '';
-- Backfill workspace-scoped object types to 'default'.
-- stored_provider_profile is intentionally omitted: profiles use workspace=""
-- for platform scope and are created with an explicit workspace when scoped.
UPDATE objects SET workspace = 'default'
WHERE workspace = '' AND name IS NOT NULL
AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session', 'provider_credential_refresh_state', 'sandbox_policy', 'draft_policy_chunk', 'sandbox_settings');
-- Replace global name uniqueness with workspace-scoped uniqueness.
DROP INDEX IF EXISTS objects_name_uq;
CREATE UNIQUE INDEX objects_name_uq
ON objects (object_type, workspace, name)
WHERE name IS NOT NULL;

Some files were not shown because too many files have changed in this diff Show More