mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-20 06:00:06 +08:00
chore: merge main into supervisor middleware SDK branch
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: build-from-issue
|
||||
description: Given a GitHub issue number, plan and implement the work described in the issue. Supports direct user requests and unattended queue processing through the `agent:*` workflow labels. Includes tests, documentation updates, and PR creation. Trigger keywords - build from issue, implement issue, work on issue, build issue, start issue.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Build From Issue
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: build-openshell-mxc-windows
|
||||
description: Maintain and validate OpenShell's build-only Windows MSVC lane for x64 and ARM64. Use when working on Windows compilation, `windows:*` mise tasks, unsupported Windows compute-driver contracts, or Windows build reports. This skill does not implement Docker, Kubernetes, Podman, VM, MXC driver, policy translation, MSI, service, or supervisor runtime support on Windows.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Build OpenShell-MXC for Windows
|
||||
@@ -231,7 +233,7 @@ crypto dependency builds.
|
||||
| `windows:build:arm64` | Release-builds `openshell-gateway.exe` and `openshell.exe` for ARM64. |
|
||||
| `windows:test:x64` | Runs native x64 workspace tests with `--no-fail-fast`, excluding unsupported Windows packages as top-level workspace targets. |
|
||||
| `windows:test:arm64` | Runs native ARM64 workspace tests with `--no-fail-fast` and the same package exclusions. Rejects non-ARM64 hosts. |
|
||||
| `windows:test:unsupported:x64` | Re-runs focused `openshell-server` tests for unsupported Windows driver behavior. |
|
||||
| `windows:test:unsupported:x64` | Re-runs focused `openshell-gateway` tests for unsupported Windows driver behavior. |
|
||||
| `windows:test:unsupported:arm64` | Re-runs the same focused contracts natively on ARM64. Rejects non-ARM64 hosts. |
|
||||
| `windows:artifacts` | Reports size and SHA256 for release artifacts that exist. |
|
||||
| `windows:ci` | Runs the full ordered x64-host Windows CI lane, plus ARM64 check/build when not skipped. |
|
||||
@@ -248,10 +250,10 @@ Windows must continue to reject unsupported compute drivers clearly.
|
||||
|
||||
| Driver | Windows build behavior | Runtime behavior |
|
||||
|---|---|---|
|
||||
| Docker | Driver crate excluded; server config contract retained. | Gateway construction returns unsupported. |
|
||||
| Kubernetes | Driver crate excluded; server config contract retained. | Gateway construction returns unsupported. |
|
||||
| Podman | Driver crate excluded; server config contract retained. | Gateway construction returns unsupported. |
|
||||
| VM | Driver crate excluded from workspace validation. | Gateway construction returns unsupported. |
|
||||
| Docker | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. |
|
||||
| Kubernetes | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. |
|
||||
| Podman | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. |
|
||||
| VM | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. |
|
||||
|
||||
The focused contract tasks for either native architecture run:
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ Windows is a build target only. These runtimes remain unsupported:
|
||||
|
||||
Rules:
|
||||
|
||||
- Keep config/library stubs where the gateway needs them.
|
||||
- Keep registration stubs in the gateway composition crate where the gateway needs them.
|
||||
- Return clear unsupported errors at runtime.
|
||||
- Do not build standalone Windows driver binaries.
|
||||
- Do not add Docker Desktop, WSL, Hyper-V, Podman machine, Podman Desktop, or
|
||||
@@ -180,7 +180,7 @@ blocked dependency.
|
||||
### Focused tests report many filtered-out tests
|
||||
|
||||
This is expected for `windows:test:unsupported:x64`. Cargo runs one named test
|
||||
and filters the other `openshell-server` tests. Report these as filtered, not
|
||||
and filters the other `openshell-gateway` tests. Report these as filtered, not
|
||||
ignored.
|
||||
|
||||
## Reporting Counts
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: create-github-issue
|
||||
description: Create GitHub issues using the gh CLI. Use when the user wants to create a new issue, report a bug, request a feature, or create a task in GitHub. Trigger keywords - create issue, new issue, file bug, report bug, feature request, github issue.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Create GitHub Issue
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: create-github-pr
|
||||
description: Create GitHub pull requests using the gh CLI. Use when the user wants to create a new PR, submit code for review, or open a pull request. Trigger keywords - create PR, pull request, new PR, submit for review, code review.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Create GitHub Pull Request
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: create-rfc
|
||||
description: Create OpenShell RFC proposals in rfc/ from a design request. Use when the user asks to write, draft, start, create, or update an RFC, Request for Comments, architecture proposal, API proposal, process proposal, or cross-cutting design proposal that should follow the OpenShell RFC process and template.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Create RFC
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: create-spike
|
||||
description: Investigate a plain-language problem description by deeply exploring the codebase, then create a structured GitHub issue with technical findings. Prequel to build-from-issue — maps vague ideas to concrete, buildable issues. Trigger keywords - spike, investigate, explore, research issue, technical investigation, create spike, new spike, feasibility, codebase exploration.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Create Spike
|
||||
|
||||
@@ -1,408 +0,0 @@
|
||||
---
|
||||
name: debug-inference
|
||||
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
|
||||
|
||||
Diagnose why OpenShell inference is failing and recommend exact fix commands.
|
||||
|
||||
Use `openshell` CLI commands to inspect the active gateway, provider records, managed inference config, and sandbox behavior. Use a short sandbox probe when needed to confirm end-to-end routing.
|
||||
|
||||
## Overview
|
||||
|
||||
OpenShell supports three inference paths. Diagnose the correct one first.
|
||||
|
||||
1. **Managed inference** through `https://inference.local`
|
||||
- Configured by `openshell inference set`
|
||||
- Shared by every sandbox on the active gateway
|
||||
- Credentials and model are injected by OpenShell
|
||||
2. **Direct external inference** to hosts like `api.openai.com`
|
||||
- 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.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `openshell` is on the PATH
|
||||
- The active gateway is running
|
||||
- You know the failing setup, or can infer it from commands and config
|
||||
|
||||
## Tools Available
|
||||
|
||||
Use these commands first:
|
||||
|
||||
```bash
|
||||
# Which gateway is active, and can the CLI reach it?
|
||||
openshell status
|
||||
|
||||
# Show both the user-facing and system inference routes
|
||||
openshell inference get
|
||||
|
||||
# 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
|
||||
openshell gateway info
|
||||
|
||||
# Run a minimal end-to-end probe from a sandbox
|
||||
openshell sandbox create -- curl https://inference.local/v1/chat/completions --json '{"messages":[{"role":"user","content":"hello"}],"max_tokens":10}'
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
When the user asks to debug inference, run diagnostics automatically in this order. Stop and report findings as soon as a root cause is identified.
|
||||
|
||||
### Determine Context
|
||||
|
||||
Establish these facts first:
|
||||
|
||||
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, 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
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
openshell status
|
||||
openshell gateway info
|
||||
```
|
||||
|
||||
Look for:
|
||||
|
||||
- Active gateway name and endpoint
|
||||
- Whether the gateway is local or remote
|
||||
- Whether `host.openshell.internal` would point to the local machine or a remote host
|
||||
|
||||
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 the Relevant Route Is Configured
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
openshell inference get
|
||||
openshell inference get --system
|
||||
```
|
||||
|
||||
Interpretation:
|
||||
|
||||
- `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>
|
||||
```
|
||||
|
||||
- **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
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
openshell provider get <provider-name>
|
||||
```
|
||||
|
||||
Check:
|
||||
|
||||
- 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
|
||||
|
||||
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 --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:
|
||||
|
||||
- Binds to `0.0.0.0`, not only `127.0.0.1`
|
||||
- Runs on the same machine as the gateway
|
||||
- Is reachable through `host.openshell.internal`, the host's LAN IP, or another reachable hostname
|
||||
|
||||
Common mistakes:
|
||||
|
||||
- **Base URL uses `127.0.0.1` or `localhost`**: usually wrong for managed inference. Replace with `host.openshell.internal` or the host's LAN IP.
|
||||
- **Server binds only to loopback**: reconfigure it to bind to `0.0.0.0`.
|
||||
- **Inference engine runs as a system service**: changing the bind address may require updating the service configuration and restarting the service before the new listener becomes reachable.
|
||||
|
||||
### Step 4: Check Request Shape
|
||||
|
||||
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:
|
||||
|
||||
- Use a supported path and provider type
|
||||
- Point OpenAI-compatible SDKs at `https://inference.local/v1`
|
||||
- If the SDK requires an API key, pass any non-empty placeholder such as `test`
|
||||
|
||||
### 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
|
||||
openshell sandbox create -- curl https://inference.local/v1/chat/completions --json '{"messages":[{"role":"user","content":"hello"}],"max_tokens":10}'
|
||||
```
|
||||
|
||||
Interpretation:
|
||||
|
||||
- **`cluster inference is not configured`**: set the managed gateway route with `openshell inference set`
|
||||
- **`connection not allowed by policy`** on `inference.local`: unsupported method or path
|
||||
- **`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, 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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
If the application calls `api.openai.com`, `api.anthropic.com`, or another external host directly, this is not a managed inference issue.
|
||||
|
||||
Check instead:
|
||||
|
||||
1. The application is configured to call the external hostname directly
|
||||
2. A provider with the needed credentials exists
|
||||
3. The sandbox has that provider attached (`openshell sandbox provider list [name]`)
|
||||
4. `network_policies` allow that host, port, and HTTP rules
|
||||
|
||||
If the response reports `credential_endpoint_mismatch`, the provider is attached
|
||||
but its credential profile does not authorize that request recipient. Run
|
||||
`openshell provider get <provider-name>` to identify the provider type, then
|
||||
inspect its profile endpoints with
|
||||
`openshell provider profile export <type> -o yaml`. That export uses the current
|
||||
workspace scope; add `--global` when the provider was created with
|
||||
`--global-profile`. Compare the profile's endpoint host, port, and path with the
|
||||
direct request. Correct the provider selection or profile endpoint when that
|
||||
recipient is intentional. Do not widen the sandbox network policy to work around
|
||||
the mismatch: policy admission and credential endpoint authorization are
|
||||
separate checks, and the provider profile should authorize only intended
|
||||
credential recipients.
|
||||
|
||||
If the response reports `request_authority_mismatch`, compare the HTTP request
|
||||
authority with the CONNECT tunnel endpoint. The host and effective port must
|
||||
match. For a tunnel to `api.example.com:8443`, send
|
||||
`Host: api.example.com:8443`; omitting the non-default port makes the request
|
||||
authority use the transport default and OpenShell rejects it. An absolute-form
|
||||
request target must use the same authority.
|
||||
|
||||
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)
|
||||
|
||||
Use this fix when a sandbox can reach `https://inference.local`, but OpenShell reports an upstream timeout against a host-local backend such as Ollama.
|
||||
|
||||
Example symptom:
|
||||
|
||||
```json
|
||||
{"error":"request to http://host.docker.internal:11434/v1/models timed out"}
|
||||
```
|
||||
|
||||
### When This Happens
|
||||
|
||||
This failure commonly appears on Linux hosts that:
|
||||
|
||||
- Run the OpenShell gateway in Docker
|
||||
- Route `inference.local` to a host-local OpenAI-compatible endpoint such as Ollama
|
||||
- Have a host firewall or networking configuration that denies container-to-host traffic by default
|
||||
|
||||
In this case, OpenShell routing is usually working correctly. The failing hop is container-to-host traffic on the backend port.
|
||||
|
||||
### Why CoreDNS Is Not the Cause
|
||||
|
||||
This is not the same issue as the Colima CoreDNS fix.
|
||||
|
||||
OpenShell injects `host.docker.internal` and `host.openshell.internal` into sandbox workloads when the selected compute platform supports it. That path bypasses runtime DNS lookup. If the request still times out, the usual cause is host firewall or network policy, not DNS.
|
||||
|
||||
### Verify the Problem
|
||||
|
||||
1. Confirm the model server works on the host:
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:11434/v1/models
|
||||
```
|
||||
|
||||
2. Confirm the host gateway address also works on the host:
|
||||
|
||||
```bash
|
||||
curl -sS http://172.17.0.1:11434/v1/models
|
||||
```
|
||||
|
||||
3. Test the same endpoint from a gateway or sandbox container on the Docker network:
|
||||
|
||||
```bash
|
||||
docker ps --filter name=openshell --format '{{.Names}}'
|
||||
docker exec <container-name> wget -qO- -T 5 http://host.docker.internal:11434/v1/models
|
||||
```
|
||||
|
||||
If steps 1 and 2 succeed but step 3 times out, the host firewall or network configuration is blocking the container-to-host path.
|
||||
|
||||
### Fix
|
||||
|
||||
Allow the Docker bridge network used by the OpenShell gateway and sandbox containers to reach the host-local inference port. The exact command depends on your firewall tooling (iptables, nftables, firewalld, UFW, etc.), but the rule should allow:
|
||||
|
||||
- **Source**: the Docker bridge subnet used by OpenShell containers (commonly `172.18.0.0/16`)
|
||||
- **Destination**: the host gateway IP injected into sandbox workloads for `host.docker.internal` (commonly `172.17.0.1`)
|
||||
- **Port**: the inference server port (e.g. `11434/tcp` for Ollama)
|
||||
|
||||
To find the actual values on your system:
|
||||
|
||||
```bash
|
||||
# Docker bridge subnet for the OpenShell network
|
||||
docker network inspect $(docker network ls --filter name=openshell -q) --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'
|
||||
|
||||
# Host gateway IP visible from inside the container
|
||||
docker exec <container-name> cat /etc/hosts | grep host.docker.internal
|
||||
```
|
||||
|
||||
Adjust the source subnet, destination IP, or port to match your local Docker network layout.
|
||||
|
||||
### Verify the Fix
|
||||
|
||||
1. Re-run the container network check:
|
||||
|
||||
```bash
|
||||
docker exec <container-name> wget -qO- -T 5 http://host.docker.internal:11434/v1/models
|
||||
```
|
||||
|
||||
2. Re-test from a sandbox:
|
||||
|
||||
```bash
|
||||
curl -sS https://inference.local/v1/models
|
||||
```
|
||||
|
||||
Both commands should return the upstream model list.
|
||||
|
||||
### If It Still Fails
|
||||
|
||||
- Confirm the backend listens on a host-reachable address: `ss -ltnp | rg ':11434\b'`
|
||||
- Confirm the provider points at the host alias path you expect: `openshell provider get <provider-name>`
|
||||
- Confirm the active inference route: `openshell inference get`
|
||||
- Inspect sandbox logs for upstream timeout details: `openshell logs <sandbox-name> --since 10m`
|
||||
|
||||
## Common Failure Patterns
|
||||
|
||||
| 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 | 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 |
|
||||
| Direct call returns `credential_endpoint_mismatch` | Attached provider profile does not authorize the request host, port, or path | Inspect the provider profile endpoints; select or update the profile only if it intentionally authorizes that recipient |
|
||||
| Direct call returns `request_authority_mismatch` | HTTP authority does not match the CONNECT host and effective port | Include the explicit non-default port in `Host` and use the same authority in absolute-form targets |
|
||||
| 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) |
|
||||
|
||||
## Full Diagnostic Dump
|
||||
|
||||
Run this when you want a compact report before deciding on a fix:
|
||||
|
||||
```bash
|
||||
echo "=== Gateway Status ==="
|
||||
openshell status
|
||||
|
||||
echo "=== Gateway Info ==="
|
||||
openshell gateway info
|
||||
|
||||
echo "=== Managed Inference ==="
|
||||
openshell inference get
|
||||
|
||||
echo "=== System Inference Only ==="
|
||||
openshell inference get --system
|
||||
|
||||
echo "=== Providers ==="
|
||||
openshell provider list
|
||||
|
||||
echo "=== Selected Provider ==="
|
||||
openshell provider get <provider-name>
|
||||
|
||||
echo "=== Sandbox Probe ==="
|
||||
openshell sandbox create -- curl https://inference.local/v1/chat/completions --json '{"messages":[{"role":"user","content":"hello"}],"max_tokens":10}'
|
||||
```
|
||||
|
||||
When you report back, state:
|
||||
|
||||
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
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: fix-security-issue
|
||||
description: Implement a fix for a reviewed security issue. Takes a directly requested issue number or scans for issues labeled `topic:security` and `agent:implementation-requested`. Reads the security review from the issue comments and implements the remediation plan. Trigger keywords - fix security issue, remediate security, implement security fix, patch vulnerability.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Fix Security Issue
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: helm-dev-environment
|
||||
description: Start up, tear down, and configure the local Kubernetes development environment for OpenShell. Uses k3d (Docker-backed k3s) + Skaffold + Helm. Covers cluster lifecycle, optional add-ons (Keycloak OIDC, Envoy Gateway), HA testing, and port mappings. Trigger keywords - local k8s, local cluster, k3d, skaffold, helm dev, start cluster, stop cluster, tear down cluster, delete cluster, create cluster, helm:k3s, helm:skaffold, local dev environment, dev cluster, k8s dev, envoy gateway local, keycloak local, high availability, HA.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Helm Dev Environment
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: launch-openshell-gator
|
||||
description: Launch and supervise OpenShell gator agents. Use when starting gator on issues or PRs, checking gator sandboxes, building the gator sandbox image, restarting stuck gators, inspecting gator logs, or experimenting with gator harness/model overrides. Trigger keywords - launch gator, start gator, run gator, gator sandbox, supervised gator, gator logs, restart gator.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Launch OpenShell Gator
|
||||
@@ -24,7 +26,7 @@ For gator's PR/issue validation policy, load `gator-gate` inside the launched sa
|
||||
|---|---|
|
||||
| `scripts/agents/run.sh` | Manifest-driven OpenShell agent launcher. |
|
||||
| `scripts/agents/gator/agent.yaml` | Gator manifest: immutable payload version, default gateway, harness, providers, runtime, skills, and subagents. |
|
||||
| `scripts/agents/gator/Dockerfile` | Gator sandbox image source. Local launches build this image through OpenShell. |
|
||||
| `scripts/agents/gator/Dockerfile` | Gator sandbox image source. Local launches build it in gateway's Docker or Podman image store. |
|
||||
| `scripts/agents/gator/policy.yaml` | Sandbox policy for the gator agent. |
|
||||
| `scripts/agents/gator/bin/gh` | Gator-specific `gh` wrapper and same-SHA duplicate-post guard. |
|
||||
| `scripts/agents/gator/bin/review-feedback-ledger` | Builds tree-aware review scope, durable findings, convergence telemetry, and review-budget state. |
|
||||
@@ -158,7 +160,7 @@ sandbox_name="gator-pr-${pr_number}-supervised"
|
||||
"Review and monitor PR #${pr_number} through the gator-gate workflow. Scope this invocation only to PR #${pr_number}."
|
||||
```
|
||||
|
||||
The launcher builds the gator sandbox image when needed, stages the immutable payload, imports provider profiles, configures provider credentials and refresh, creates and uploads the sandbox payload, then starts the agent supervisor with `sandbox exec`. It writes a background log under `scripts/agents/gator/logs/`.
|
||||
The launcher queries gateway's selected compute driver, builds gator image in matching Docker or Podman image store, stages immutable payload, imports provider profiles, configures provider credentials and refresh, creates and uploads sandbox payload, then starts agent supervisor with `sandbox exec`. It writes a background log under `scripts/agents/gator/logs/`. `CONTAINER_ENGINE`, when set, must match gateway driver.
|
||||
|
||||
### Launch An Issue Or Issue/PR Pair
|
||||
|
||||
|
||||
@@ -1,623 +0,0 @@
|
||||
# OpenShell CLI Reference
|
||||
|
||||
Quick-reference for the `openshell` command-line interface. For workflow guidance, see [SKILL.md](SKILL.md).
|
||||
|
||||
> **Self-teaching**: If a command or flag is not listed here, use `openshell <command> --help` to discover it. The CLI has comprehensive built-in help at every level.
|
||||
|
||||
## Global Options
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-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` |
|
||||
|
||||
---
|
||||
|
||||
## Complete Command Tree
|
||||
|
||||
```
|
||||
openshell
|
||||
├── gateway
|
||||
│ ├── add <endpoint> [opts]
|
||||
│ ├── login [name]
|
||||
│ ├── logout [name]
|
||||
│ ├── remove [name]
|
||||
│ ├── info [--name]
|
||||
│ ├── list
|
||||
│ └── select [name]
|
||||
├── status
|
||||
├── whoami [--output <format>]
|
||||
├── inference
|
||||
│ ├── set --provider --model
|
||||
│ ├── update [--provider] [--model]
|
||||
│ └── get
|
||||
├── sandbox
|
||||
│ ├── create [opts] [-- CMD...]
|
||||
│ ├── get [name]
|
||||
│ ├── list [opts]
|
||||
│ ├── stop [name]
|
||||
│ ├── start [name]
|
||||
│ ├── delete [name]... [--all]
|
||||
│ ├── exec [--name <name>] [opts] -- CMD...
|
||||
│ ├── connect [name] [--editor <editor>]
|
||||
│ ├── upload <name> <path> [dest]
|
||||
│ ├── download <name> <path> [dest]
|
||||
│ ├── ssh-config [name]
|
||||
│ └── provider
|
||||
│ ├── list [name]
|
||||
│ ├── attach <name> <provider>
|
||||
│ └── detach <name> <provider>
|
||||
├── forward
|
||||
│ ├── 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> [--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]
|
||||
│ ├── 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
|
||||
├── term
|
||||
├── completions <shell>
|
||||
└── ssh-proxy [opts]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gateway Commands
|
||||
|
||||
### `openshell gateway add <ENDPOINT>`
|
||||
|
||||
Register an existing gateway endpoint.
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--name <NAME>` | Gateway name |
|
||||
| `--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]`
|
||||
|
||||
Remove a local gateway registration. This removes CLI metadata and stored auth tokens only; package managers, systemd, Helm, Docker, and other platform tools still own the gateway process.
|
||||
|
||||
### `openshell gateway login [name]`
|
||||
|
||||
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`
|
||||
|
||||
Show gateway details: endpoint, auth mode, and remote host metadata when present.
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--name <NAME>` | Gateway name (defaults to active) |
|
||||
|
||||
### `openshell gateway select [name]`
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Doctor Commands
|
||||
|
||||
### `openshell doctor check`
|
||||
|
||||
Validate local Docker prerequisites for standalone gateway development. For
|
||||
package-managed or Helm gateways, use `systemctl`, `journalctl`, `kubectl`, and
|
||||
`helm` directly.
|
||||
|
||||
---
|
||||
|
||||
## Status Command
|
||||
|
||||
### `openshell status`
|
||||
|
||||
Show server connectivity, authentication status, and version for the active
|
||||
gateway. Connectivity uses the public health RPC; authentication is checked
|
||||
with the protected gateway-info capability query and can fail while the gateway
|
||||
remains connected.
|
||||
|
||||
### `openshell whoami`
|
||||
|
||||
Show the authenticated user identity: subject, display name, roles, scopes, and
|
||||
identity provider. Requires an authenticated gateway connection.
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--output <FORMAT>` | Output format: `table` (default), `json`, or `yaml` |
|
||||
|
||||
---
|
||||
|
||||
## Sandbox Commands
|
||||
|
||||
### `openshell sandbox create [OPTIONS] [-- COMMAND...]`
|
||||
|
||||
Create a sandbox through the selected gateway and launch its canonical main
|
||||
process. By default, the CLI attaches to that retained process after the
|
||||
sandbox becomes ready. A trailing command defines the canonical main process;
|
||||
without one, the default is `/bin/bash -l` with a PTY. Explicit commands remain
|
||||
foreground in non-interactive automation: stdout and stderr stream to the
|
||||
caller and the CLI returns the command's exact status. Exit 0 leaves
|
||||
`Completed`; nonzero leaves `Error/MainProcessFailed`.
|
||||
Starting either retained terminal result invalidates SSH sessions from the
|
||||
previous runtime generation.
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--name <NAME>` | Sandbox name (auto-generated if omitted) |
|
||||
| `--from <SOURCE>` | Community name, Dockerfile path, directory, or image reference (BYOC) |
|
||||
| `--no-keep` | Delete the sandbox after main output and the result drain |
|
||||
| `--detach` | Start the canonical main process without attaching |
|
||||
| `--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`) |
|
||||
|
||||
`--detach` adds no attachment grace period: the sandbox reports the canonical
|
||||
process result immediately when it exits. Foreground creation declares one
|
||||
expected main-process SSH attachment; cleanup finalizes after that connection
|
||||
drains and closes naturally.
|
||||
| `--driver-config-json <JSON>` | Experimental driver-keyed configuration object |
|
||||
| `--provider <NAME>` | Provider to attach (repeatable) |
|
||||
| `--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...]` | Canonical main command (defaults to `/bin/bash -l`) |
|
||||
|
||||
`--upload` cannot be combined with a trailing main command because uploads
|
||||
currently complete after the canonical process starts. Create the default
|
||||
scratch sandbox, upload files, then use `sandbox exec`, or build the files into
|
||||
the image.
|
||||
|
||||
### `openshell sandbox get [name]`
|
||||
|
||||
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 |
|
||||
|
||||
### `openshell sandbox list`
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--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]...`
|
||||
|
||||
Delete one or more named sandboxes, or use `--all`. Deletion stops background port forwards.
|
||||
|
||||
### `openshell sandbox stop [name]`
|
||||
|
||||
Stop sandbox compute while retaining the sandbox and persistent workspace. The
|
||||
name defaults to the last-used sandbox. The command stops background forwards
|
||||
and waits for the `Stopped` phase.
|
||||
|
||||
### `openshell sandbox start [name]`
|
||||
|
||||
Start a stopped, failed, or completed sandbox and wait for `Ready`. This
|
||||
launches a fresh canonical-main instance. The name defaults to the last-used
|
||||
sandbox.
|
||||
|
||||
### `openshell sandbox exec [OPTIONS] -- COMMAND...`
|
||||
|
||||
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]`
|
||||
|
||||
Attach to the sandbox's retained canonical main process. Disconnecting leaves
|
||||
the process running. Reconnecting targets the same process instance and
|
||||
replays recent output. Use `sandbox exec --tty -- /bin/bash -l` when you need a
|
||||
new shell. The name defaults to the last-used sandbox.
|
||||
|
||||
`--editor vscode|cursor` launches a supported remote editor instead of
|
||||
attaching to the canonical main process.
|
||||
|
||||
### `openshell sandbox upload <name> <path> [dest]`
|
||||
|
||||
Upload files using tar-over-SSH. The CLI discovers the canonical remote working directory when the destination is omitted. A named directory merges into an existing directory of the same name, overwriting matching entries without deleting unrelated entries. `.gitignore` filtering is enabled unless `--no-git-ignore` is passed.
|
||||
|
||||
### `openshell sandbox download <name> <path> [dest]`
|
||||
|
||||
Download files using tar-over-SSH. The sandbox source may be relative to the canonical remote working directory or an absolute path within it. The local destination defaults to `.`.
|
||||
|
||||
### `openshell sandbox ssh-config [name]`
|
||||
|
||||
Print an SSH config `Host` block. The name defaults to the last-used sandbox.
|
||||
|
||||
### `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]`
|
||||
|
||||
Start forwarding a local port to a sandbox.
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `<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]`
|
||||
|
||||
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]`
|
||||
|
||||
View sandbox logs. Supports one-shot and streaming.
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `-n <N>` | 200 | Number of log lines |
|
||||
| `--tail` | false | Stream live logs |
|
||||
| `--since <DURATION>` | none | Only show logs from this duration ago (e.g., `5m`, `1h`) |
|
||||
| `--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]`
|
||||
|
||||
Incrementally merge live network policy changes into the current sandbox policy when the selected compute driver supports live updates. Multiple flags in one invocation are applied as one atomic batch and create at most one new revision. MXC rejects live policy merges; delete and recreate an MXC sandbox instead.
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--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 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. |
|
||||
| `--dry-run` | false | Preview the merged policy locally without sending an update to the gateway. |
|
||||
| `--wait` | false | Wait for the sandbox to confirm the new policy revision is loaded. |
|
||||
| `--timeout <SECS>` | 60 | Timeout for `--wait`. |
|
||||
|
||||
Notes:
|
||||
|
||||
- The sandbox name defaults to the last-used sandbox.
|
||||
- `--add-endpoint` options are comma-separated: `allowed-ip=<CIDR-or-IP>`, `websocket-credential-rewrite`, `request-body-credential-rewrite`, and `allow-uninspected-credentials`. The last option is a security-sensitive exception for provider-credentialed L4-only, `tls: skip`, or otherwise uninspectable traffic.
|
||||
- `protocol` accepts `tcp` for explicit L4-only host/port policy. It has the
|
||||
same payload-handling behavior as omitting the protocol, but it requires a
|
||||
valid DNS hostname and rejects hostless `allowed_ips` or literal-IP
|
||||
selectors. It cannot be combined with `access`, `rules`, or L7 enforcement
|
||||
options.
|
||||
- `--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>`
|
||||
|
||||
Replace the full policy on a live sandbox when the selected compute driver supports live updates. Only the dynamic `network_policies` field can be changed at runtime. MXC rejects live policy replacement; delete and recreate an MXC sandbox instead.
|
||||
|
||||
| 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]`
|
||||
|
||||
Show the current effective sandbox policy or stored global policy.
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--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]`
|
||||
|
||||
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. The CLI fetches and submits each proposal's current review token; a changed live candidate remains pending until it is reviewed again. 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
|
||||
|
||||
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>`
|
||||
|
||||
Create a provider configuration.
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--name <NAME>` | Provider name (required) |
|
||||
| `--type <TYPE>` | Provider type (required) |
|
||||
| `--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).
|
||||
|
||||
### `openshell provider list`
|
||||
|
||||
List providers in a table.
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--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>`
|
||||
|
||||
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 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 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 both inference routes. `--system` shows only the system route.
|
||||
|
||||
---
|
||||
|
||||
## Other Commands
|
||||
|
||||
### `openshell term`
|
||||
|
||||
Launch the OpenShell interactive TUI. `--theme auto|dark|light` overrides `OPENSHELL_THEME`.
|
||||
|
||||
### `openshell completions <shell>`
|
||||
|
||||
Generate shell completion scripts. Supported shells: `bash`, `fish`, `zsh`, `powershell`.
|
||||
|
||||
### `openshell ssh-proxy`
|
||||
|
||||
SSH proxy used as a `ProxyCommand`. Not typically invoked directly.
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: review-github-pr
|
||||
description: Review a GitHub pull request by summarizing its diff and key design decisions. Use when the user wants to review a PR, understand changes in a branch, or get a code review summary. Trigger keywords - review PR, review pull request, summarize PR, summarize diff, code review, review branch, PR summary, diff summary.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Review GitHub Pull Request
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: review-security-issue
|
||||
description: Given a GitHub issue, review the issue for security implications. You'll make a determination if the claim in the issue is legitimate and should be addressed or will be a "won't fix." Trigger keywords - security issue, review security ticket, review security issue.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Review Security Issue
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: sbom
|
||||
description: Generate and manage Software Bill of Materials (SBOMs) for the OpenShell project. Covers SBOM generation with Syft, license resolution via public registries, and CSV export for compliance review. Trigger keywords - SBOM, sbom, bill of materials, license audit, license resolution, generate sbom, sbom csv, dependency license, supply chain, license scan.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# SBOM Generation and License Resolution
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: sync-agent-infra
|
||||
description: Detect and fix drift across agent-first infrastructure files. Ensures skill inventories, workflow chains, architecture tables, issue/PR templates, and cross-references stay consistent when skills, crates, or workflows change. Run after adding, removing, or renaming skills or components. Trigger keywords - sync agent infra, sync skills, update agent docs, check agent consistency, agent infra drift, sync contributing, sync agents.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Sync Agent Infrastructure
|
||||
@@ -12,13 +14,13 @@ Detect and fix drift across the agent-first infrastructure files. These files re
|
||||
| `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 |
|
||||
| `CONTRIBUTING.md` issue lifecycle section | Human-facing issue states, roadmap decisions, acceptance signals, and direct-versus-queued agent ownership |
|
||||
| `README.md` | "Built With Agents" section, "Explore with your agent" skill references |
|
||||
| `README.md` | "Use OpenShell with Your Agent" and "Built With Agents" sections |
|
||||
| `.github/ISSUE_TEMPLATE/bug_report.yml` | Skill name references in diagnostic guidance |
|
||||
| `.github/ISSUE_TEMPLATE/feature_request.yml` | Skill name references in investigation guidance |
|
||||
| `.github/ISSUE_TEMPLATE/config.yml` | Contact link text referencing skills |
|
||||
| `.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 |
|
||||
| `skills/*/SKILL.md` | Standalone user instructions and links to documentation, included files, and related skills |
|
||||
| `.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 |
|
||||
@@ -26,7 +28,7 @@ Detect and fix drift across the agent-first infrastructure files. These files re
|
||||
|
||||
## When to Run
|
||||
|
||||
- After adding, removing, or renaming a skill in `.agents/skills/`
|
||||
- After adding, removing, renaming, or moving a skill in `skills/` or `.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
|
||||
@@ -35,7 +37,7 @@ Detect and fix drift across the agent-first infrastructure files. These files re
|
||||
|
||||
## 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.
|
||||
Use this map when product behavior, commands, or development workflows change. It is a routing aid, not an exhaustive dependency list. Search both `skills/` and `.agents/skills/` for the changed command, field, component, or workflow before concluding that no other skill needs an update.
|
||||
|
||||
| Change area | Skills to review |
|
||||
|---|---|
|
||||
@@ -43,7 +45,7 @@ Use this map when product behavior, commands, or development workflows change. I
|
||||
| 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` |
|
||||
| Inference providers, native model endpoints, or migration from `inference.local` | `debug-inference`, `openshell-cli`, `generate-sandbox-policy` |
|
||||
| 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 |
|
||||
@@ -53,7 +55,7 @@ Use this map when product behavior, commands, or development workflows change. I
|
||||
| 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` |
|
||||
| Documentation structure, navigation, or doc-update workflow | `update-docs-from-commits` |
|
||||
| Skills, crates, workflow chains, issue/PR templates, or agent cross-references | `sync-agent-infra` |
|
||||
|
||||
## Prerequisites
|
||||
@@ -66,13 +68,14 @@ Gather the source of truth for each category.
|
||||
|
||||
### Skills
|
||||
|
||||
List all skill directories:
|
||||
List public and contributor skill directories separately:
|
||||
|
||||
```bash
|
||||
ls -1 skills/
|
||||
ls -1 .agents/skills/
|
||||
```
|
||||
|
||||
This is the canonical skill list. Every other file must agree with it.
|
||||
The directories are canonical by audience: `skills/` contains public, installable user/operator skills and `.agents/skills/` contains internal contributor workflows. Every other file must agree with both inventories.
|
||||
|
||||
### Crates
|
||||
|
||||
@@ -96,16 +99,19 @@ For each file in the table above, check for the following inconsistencies:
|
||||
|
||||
### `CONTRIBUTING.md`
|
||||
|
||||
1. **Skills table** — Every skill in `.agents/skills/` must appear in the "Agent Skills for Contributors" table. No skill in the table should reference a directory that doesn't exist.
|
||||
2. **Workflow chains** — Must match `AGENTS.md` workflow chains exactly.
|
||||
3. **Skill references in prose** — Any skill mentioned by name in "Before You Open an Issue", "When to Open an Issue", or "When NOT to Open an Issue" must exist in `.agents/skills/`.
|
||||
1. **Public skills table** — Every skill in `skills/` must appear in "Skills for Using OpenShell" and no contributor skill may appear there.
|
||||
2. **Contributor skills table** — Every skill in `.agents/skills/` must appear in "Agent Skills for Contributors" and no public skill may appear there.
|
||||
3. **Inventory paths** — No skill in either table should reference a directory that does not exist.
|
||||
4. **Workflow chains** — Must match `AGENTS.md` workflow chains exactly.
|
||||
5. **Skill references in prose** — Any named skill must exist in exactly one canonical skill directory.
|
||||
|
||||
### `AGENTS.md`
|
||||
|
||||
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.
|
||||
2. **Skill layout** — The architecture table must contain separate `skills/` and `.agents/skills/` rows with accurate audience descriptions.
|
||||
3. **Workflow chains** — Verify each skill named in a chain exists in exactly one of the two skill directories.
|
||||
4. **Issue/PR conventions** — Verify referenced skills (`create-github-issue`, `create-github-pr`, `build-from-issue`) exist.
|
||||
5. **Skill maintenance pointer** — Verify it still points to `sync-agent-infra` and does not duplicate the maintenance map from this skill.
|
||||
|
||||
### Issue Lifecycle Documentation
|
||||
|
||||
@@ -115,8 +121,8 @@ For each file in the table above, check for the following inconsistencies:
|
||||
|
||||
### `README.md`
|
||||
|
||||
1. **"Explore with your agent"** — Skill names referenced must exist in `.agents/skills/`.
|
||||
2. **"Built With Agents"** — Skill names referenced must exist. Workflow descriptions should be consistent with `AGENTS.md` chains.
|
||||
1. **Public installation guidance** — The README must distinguish `skills/` from `.agents/skills/`, include `npx skills add NVIDIA/OpenShell`, and list only canonical public skills as installable.
|
||||
2. **"Built With Agents"** — Contributor skill names must exist under `.agents/skills/`. Workflow descriptions should be consistent with `AGENTS.md` chains.
|
||||
|
||||
### Issue Templates
|
||||
|
||||
@@ -131,12 +137,23 @@ For each file in the table above, check for the following inconsistencies:
|
||||
### Skill Cross-References
|
||||
|
||||
1. **`triage-issue`** — Skills referenced in gate check and diagnosis steps must exist.
|
||||
2. **`openshell-cli`** — Companion skills table entries must exist.
|
||||
2. **`openshell-cli`** — Companion skills table entries must exist in one canonical location.
|
||||
3. **`build-from-issue`** — Label names must match the project's label taxonomy. Lifecycle and request labels must gate unattended queue pickup, while direct requests warn on workflow discrepancies and continue.
|
||||
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.
|
||||
|
||||
### Skill Layout, Metadata, and Portability
|
||||
|
||||
1. **Placement** — The four public skills (`openshell-cli`, `generate-sandbox-policy`, `debug-inference`, and `debug-openshell-cluster`) must live only in `skills/`. Every other repository skill must live only in `.agents/skills/`.
|
||||
2. **Internal metadata** — Every `.agents/skills/*/SKILL.md` must set `metadata.internal: true`. Public skills must not set internal metadata. Treat this as a discovery filter, not an access-control boundary.
|
||||
3. **Unique names** — Parse the `name` field from every `SKILL.md` under both roots. Every name must be globally unique and match the documented inventory.
|
||||
4. **Local references** — Every relative Markdown link and referenced file in a skill must resolve within that installed skill directory unless the reference is an explicit published URL.
|
||||
5. **Canonical paths** — Contributor skills that name the source location of a public skill must use `skills/<name>/...`, never `.agents/skills/<name>/...`.
|
||||
6. **Public portability** — Public skills must not require repository-relative files under `docs/`, `architecture/`, `crates/`, `deploy/`, or `.agents/`; source builds; `mise`; or repository E2E workflows. Use installed `openshell --help` for command syntax and Markdown endpoints under `https://docs.nvidia.com/openshell/latest/` (URLs ending in `.md`) for product documentation.
|
||||
7. **No canonical documentation copies** — Review public reference files and large command/schema blocks. Remove material that merely copies CLI help, policy schemas, architecture docs, or published operational documentation; retain only skill-specific reasoning and worked interactions.
|
||||
8. **Discovery** — Run `npx -y skills add . --list` from a clean checkout or disposable copy. It must list exactly the four public skills. Remove any generated lock file or installed directory after the check.
|
||||
|
||||
## Step 3: Report Drift
|
||||
|
||||
If any inconsistencies are found, report them in a structured format:
|
||||
@@ -145,9 +162,12 @@ If any inconsistencies are found, report them in a structured format:
|
||||
## Agent Infrastructure Drift Report
|
||||
|
||||
### Skills Inventory
|
||||
- ADDED (exists in .agents/skills/ but missing from CONTRIBUTING.md): <list>
|
||||
- REMOVED (in CONTRIBUTING.md but missing from .agents/skills/): <list>
|
||||
- OK: <count> skills consistent
|
||||
- PUBLIC ADDED (exists in skills/ but missing from CONTRIBUTING.md): <list>
|
||||
- PUBLIC REMOVED (documented as public but missing from skills/): <list>
|
||||
- CONTRIBUTOR ADDED (exists in .agents/skills/ but missing from CONTRIBUTING.md): <list>
|
||||
- CONTRIBUTOR REMOVED (documented as contributor but missing from .agents/skills/): <list>
|
||||
- METADATA/PATH/NAME ERRORS: <list>
|
||||
- OK: <public count> public and <contributor count> contributor skills consistent
|
||||
|
||||
### Architecture Table
|
||||
- ADDED (exists in crates/ but missing from AGENTS.md): <list>
|
||||
@@ -178,6 +198,7 @@ If drift is found, fix it by updating the affected files:
|
||||
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.
|
||||
8. **Audience or portability drift** — Move the skill to its canonical root, fix internal metadata, replace stale public-skill paths, repair local links, and replace copied product documentation with CLI self-discovery or published documentation links.
|
||||
|
||||
After fixing, re-run Step 2 to verify consistency.
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: test-release-canary
|
||||
description: Manually dispatch and iterate on the Release Canary workflow that smoke-tests published OpenShell artifacts (install.sh on macOS/Ubuntu/Fedora, Helm chart on kind) after each Release Dev publish. Use when changing `.github/workflows/release-canary.yml`, validating a release before tagging, debugging a canary failure, or reproducing a canary job locally. Trigger keywords - release canary, release-canary, canary failed, canary dispatch, test release canary, post-release smoke, install.sh canary, helm chart canary, kind canary, dispatch canary.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Test Release Canary
|
||||
@@ -14,6 +16,7 @@ The Release Canary (`.github/workflows/release-canary.yml`) smoke-tests the arti
|
||||
| `macos` | `macos-latest-xlarge` | `install.sh` resolves the Homebrew formula, brew installs the cask, and `openshell status` reaches the brew-services–backed local gateway with the VM driver. |
|
||||
| `ubuntu` | `ubuntu-latest` | `install.sh` installs the Debian package, the post-install systemd user service starts, and `openshell status` reaches the local gateway with the Docker driver. |
|
||||
| `fedora` | `fedora:latest` container | `install.sh` installs the RPM packages, the local gateway starts under Podman, and `openshell status` succeeds. |
|
||||
| `ubuntu-snap` | `ubuntu-latest` | Downloads the Snap artifact from Release Dev, installs it with `--dangerous`, connects the required interfaces, and waits up to 30 seconds for the recovered local gateway. |
|
||||
| `kubernetes` | `ubuntu-latest` + kind | `helm install oci://ghcr.io/nvidia/openshell/helm-chart --version 0.0.0-dev` succeeds in a kind cluster, the gateway pod becomes Ready, port-forward exposes 8080, and the released CLI registers the in-cluster gateway and runs `openshell status` against it. |
|
||||
|
||||
All canary jobs disable anonymous OpenShell telemetry. Host package jobs inject
|
||||
@@ -41,7 +44,7 @@ on:
|
||||
```
|
||||
|
||||
- **Automatic.** Every successful `Release Dev` run (on `main` or a manual dispatch of Release Dev) fires the canary. Each job gates on `github.event.workflow_run.conclusion == 'success'` so a failed Release Dev does not run the canary.
|
||||
- **Manual.** `workflow_dispatch` lets you run the canary on demand against any branch's workflow definition.
|
||||
- **Manual.** `workflow_dispatch` lets you run the canary on demand against any branch's workflow definition. To include `ubuntu-snap`, supply `release-dev-run-id` for a successful Release Dev run whose Snap artifact should be tested; without it, that job is skipped because no artifact is available.
|
||||
|
||||
When dispatched manually, `github.event.workflow_run.head_sha` is empty and the workflow falls back to `github.sha` (the branch tip) for the `install.sh` URL.
|
||||
|
||||
@@ -53,6 +56,13 @@ Run the canary as-is on the current branch:
|
||||
gh workflow run release-canary.yml --ref "$(git branch --show-current)"
|
||||
```
|
||||
|
||||
To exercise the Ubuntu Snap job, pass the successful Release Dev run ID:
|
||||
|
||||
```shell
|
||||
gh workflow run release-canary.yml --ref "$(git branch --show-current)" \
|
||||
-f release-dev-run-id=<release-dev-run-id>
|
||||
```
|
||||
|
||||
Watch the run that starts:
|
||||
|
||||
```shell
|
||||
@@ -122,6 +132,7 @@ Loopback registration auto-derives the gateway name to `openshell` if `--name` i
|
||||
|---|---|---|
|
||||
| `macos`/`ubuntu`/`fedora` job fails on `install.sh` | Latest tagged release missing an asset, checksum mismatch, or `install.sh` regression on this branch. | Job log around the `curl … install.sh \| sh` step. |
|
||||
| `macos`/`ubuntu`/`fedora` job fails on `openshell status` | Local gateway service did not start (systemd/brew/podman). Often a driver issue. | Service logs in the job log; `OPENSHELL_DRIVERS` env in the "Ensure …" step. |
|
||||
| `ubuntu-snap` fails after interface connection | The gateway did not recover after Docker became available, or did not become reachable within the 30-second bound. | Failure diagnostics dump Snap service/connection/change state, gateway and snapd journals, Snap logs, and port 17670 listeners. |
|
||||
| `kubernetes` job fails on `helm install --wait` | Chart did not deploy in 5 min — usually image pull failure or readiness probe failing. | "Diagnostics on failure" step dumps `helm status`, manifest, pod describe, pod logs. |
|
||||
| `kubernetes` job fails on `kubectl wait` | Gateway pod stuck `CrashLoopBackOff` or `ImagePullBackOff`. | Diagnostics dump; check `:dev` image existence at `ghcr.io/nvidia/openshell/gateway`. |
|
||||
| `kubernetes` job fails on `openshell gateway add` or `status` | Port-forward not reachable, or CLI/gateway proto mismatch. | `port-forward.log` and `openshell gateway list` in the diagnostics dump. |
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: triage-issue
|
||||
description: Assess, validate, and route community-filed issues for human disposition and roadmap placement. Takes a specific issue number or processes a confirmed batch of issues labeled state:triage-needed. Investigates reported behavior, separates objective findings from product decisions, and prepares validated issues for a human yes/no decision. Trigger keywords - triage issue, triage, assess issue, review incoming issue, triage issues.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Triage Issue
|
||||
@@ -153,9 +155,9 @@ Based on the sub-agent's analysis, also attempt to validate the report directly:
|
||||
|
||||
- For bug reports: check the relevant code paths, look for the described failure mode
|
||||
- For feature requests: assess feasibility against the existing architecture
|
||||
- For gateway deployment or infrastructure issues: reference the `debug-openshell-cluster` skill's known failure patterns
|
||||
- For inference and provider-topology issues: reference the `debug-inference` skill's known failure patterns
|
||||
- For CLI/usage issues: reference the `openshell-cli` skill's command reference
|
||||
- For gateway deployment or infrastructure issues: reference the known failure patterns in `skills/debug-openshell-cluster/SKILL.md`
|
||||
- For inference and provider-topology issues: reference `skills/debug-inference/SKILL.md`
|
||||
- For CLI/usage issues: reference the workflows in `skills/openshell-cli/SKILL.md` and confirm installed syntax with `openshell --help`
|
||||
|
||||
Record impact signals for the human decision: affected users and scope, regression status, workaround availability, severity evidence, and evidence quality. Do not convert those facts into a roadmap or sequencing recommendation.
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: tui-development
|
||||
description: Guide for developing the OpenShell TUI — a ratatui-based terminal UI for the OpenShell platform. Covers architecture, navigation, data fetching, theming, UX conventions, and development workflow. Trigger keywords - term, TUI, terminal UI, ratatui, openshell-tui, tui development, tui feature, tui bug.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# OpenShell TUI Development Guide
|
||||
@@ -43,8 +45,8 @@ Gateway (discovered via openshell_bootstrap::list_gateways())
|
||||
|
||||
- **Gateways** are discovered from on-disk config via `openshell_bootstrap::list_gateways()`. Each gateway has a name, endpoint, local/remote flag, and source label.
|
||||
- **Workspaces** are fetched via `ListWorkspaces`. The user cycles through workspaces with `[w]`, or views all workspaces at once. The current workspace scopes provider and sandbox lists.
|
||||
- **Provider Profiles** are fetched per-workspace via `ListProviderProfiles` when `providers_v2_enabled` is true. Profiles are cached in a `ProviderProfileCache` keyed by `(workspace, profile_id)` and matched to providers by type. They provide category, credential metadata, endpoint/binary counts, and inference capability.
|
||||
- **Providers** are fetched via `ListProviders` scoped to the current workspace. Each `ProviderListEntry` pairs a provider with its optional cached profile. When `providers_v2_enabled` is true, CRUD operations are read-only in the TUI; when false, the TUI supports create/update/delete.
|
||||
- **Provider Profiles** are fetched per-workspace via `ListProviderProfiles`. Profiles are cached in a `ProviderProfileCache` keyed by `(workspace, profile_id)` and matched to providers by type. They provide category, credential metadata, endpoint/binary counts, and inference capability.
|
||||
- **Providers** are fetched via `ListProviders` scoped to the current workspace. Each `ProviderListEntry` pairs a provider with its optional cached profile. The TUI supports profile-backed create, update, and delete operations.
|
||||
- **Global Settings** are fetched via `GetGatewayConfig` and displayed in a tabbed pane alongside providers on the dashboard. Each setting is a registered key with a typed value (bool/int/string). Platform-admin access is required; `PermissionDenied` disables the pane.
|
||||
- **Sandboxes** belong to the active gateway and workspace. Fetched via `ListSandboxes` with a periodic tick refresh.
|
||||
- **Sandbox Settings** are effective settings returned by `GetSandboxConfig`, each with a scope (sandbox, global, or unset). Globally-managed settings are blocked from sandbox-level edits.
|
||||
@@ -53,7 +55,7 @@ Gateway (discovered via openshell_bootstrap::list_gateways())
|
||||
The **title bar** always reflects this hierarchy, reading left-to-right from general to specific:
|
||||
|
||||
```
|
||||
OpenShell │ Current Gateway: <name> [source] (<status>) │ Workspace: <name|all> │ <screen/context>
|
||||
OpenShell v<version> │ Current Gateway: <name> [source] (<status>) │ Workspace: <name|all> │ <screen/context>
|
||||
```
|
||||
|
||||
## 3. Navigation & Screen Architecture
|
||||
@@ -142,8 +144,8 @@ Every frame renders four vertical regions:
|
||||
|
||||
### Title bar examples
|
||||
|
||||
- Dashboard: ` >_ OpenShell ALPHA | Current Gateway: openshell [local] (Healthy) | Workspace: default | Dashboard`
|
||||
- Sandbox detail: ` >_ OpenShell ALPHA | Current Gateway: openshell [local] (Healthy) | Workspace: team-a | Sandbox: my-sandbox`
|
||||
- Dashboard: ` >_ OpenShell v<version> | Current Gateway: openshell [local] (Healthy) | Workspace: default | Dashboard`
|
||||
- Sandbox detail: ` >_ OpenShell v<version> | Current Gateway: openshell [local] (Healthy) | Workspace: team-a | Sandbox: my-sandbox`
|
||||
|
||||
### Adding a new screen
|
||||
|
||||
@@ -170,7 +172,7 @@ Phase 2: WatchSandbox(follow_logs: true) → live tail → send via Event::L
|
||||
|
||||
**Sandboxes**: Fetched via `ListSandboxes` on a 2-second tick, scoped to the current workspace (or all workspaces).
|
||||
|
||||
**Providers**: Fetched via `ListProviders` on each tick. When `providers_v2_enabled` is true, provider profiles are also fetched per-workspace via `ListProviderProfiles` and cached in a `ProviderProfileCache` keyed by `(workspace, profile_id)`.
|
||||
**Providers**: Fetched via `ListProviders` on each tick. Provider profiles are fetched per-workspace via `ListProviderProfiles` and cached in a `ProviderProfileCache` keyed by `(workspace, profile_id)`.
|
||||
|
||||
**Settings**: Global settings are fetched via `GetGatewayConfig` on each tick. Sandbox settings are fetched alongside the sandbox policy via `GetSandboxConfig` and refreshed on each tick when viewing a sandbox.
|
||||
|
||||
@@ -321,7 +323,7 @@ TUI actions should parallel `openshell` CLI commands so users have familiar ment
|
||||
| `openshell sandbox connect` | `[s]` on sandbox policy view to launch SSH shell |
|
||||
| `openshell logs <name>` | `[l]` on sandbox detail to open log viewer |
|
||||
| `openshell provider list` | Provider table on Dashboard (middle pane) |
|
||||
| `openshell provider create` | `[c]` on provider panel (when not providers_v2) |
|
||||
| `openshell provider create` | `[c]` on provider panel |
|
||||
| `openshell status` | Status in title bar + gateway list |
|
||||
|
||||
When adding new TUI features, check what the CLI offers and maintain consistency.
|
||||
@@ -383,11 +385,8 @@ All actions are accessible via keyboard shortcuts displayed in the nav bar. The
|
||||
**Dashboard (Gateways focus):**
|
||||
`[Tab] Switch Panel [Enter] Select [j/k] Navigate │ [:] Command [q] Quit`
|
||||
|
||||
**Dashboard (Providers focus, providers_v2):**
|
||||
`[Tab] Switch Panel [h/l] Switch Tab [j/k] Navigate [Enter] Detail read-only │ [:] Command [q] Quit`
|
||||
|
||||
**Dashboard (Providers focus, legacy):**
|
||||
`[Tab] Switch Panel [h/l] Switch Tab [j/k] Navigate [Enter] Detail [c] Create [u] Update [d] Delete │ [:] Command [q] Quit`
|
||||
**Dashboard (Providers focus):**
|
||||
`[Tab] Switch Panel [h/l] Switch Tab [j/k] Navigate [Enter] Detail [c] Create [u] Update [d] Delete [w] Workspace │ [:] Command [q] Quit`
|
||||
|
||||
**Dashboard (Global Settings focus):**
|
||||
`[Tab] Switch Panel [h/l] Switch Tab [j/k] Navigate [Enter] Edit [d] Delete │ [:] Command [q] Quit`
|
||||
@@ -412,7 +411,7 @@ All actions are accessible via keyboard shortcuts displayed in the nav bar. The
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `crates/openshell-tui/Cargo.toml` | Crate manifest — dependencies on `openshell-core`, `openshell-bootstrap`, `ratatui`, `crossterm`, `tonic`, `tokio` |
|
||||
| `crates/openshell-tui/src/lib.rs` | Entry point. Event loop, gRPC calls (`refresh_data`, `refresh_providers`, `refresh_global_settings`, `refresh_workspaces`, `refresh_sandboxes`, `spawn_log_stream`, `handle_sandbox_delete`, `fetch_providers_v2_setting`), gateway switching, mTLS channel building, provider CRUD spawners, settings CRUD spawners, draft approval spawners |
|
||||
| `crates/openshell-tui/src/lib.rs` | Entry point. Event loop, gRPC calls (`refresh_data`, `refresh_providers`, `refresh_global_settings`, `refresh_workspaces`, `refresh_sandboxes`, `spawn_log_stream`, `handle_sandbox_delete`), gateway switching, mTLS channel building, provider CRUD spawners, settings CRUD spawners, draft approval spawners |
|
||||
| `crates/openshell-tui/src/app.rs` | `App` state struct, `Screen`/`Focus`/`InputMode`/`LogSourceFilter`/`MiddlePaneTab`/`SandboxPolicyTab` enums, `LogLine`/`GatewayEntry`/`GlobalSettingEntry`/`SandboxSettingEntry`/`ProviderListEntry`/`ProviderDetailView` structs, create sandbox/provider form state, all key handling logic |
|
||||
| `crates/openshell-tui/src/event.rs` | `Event` enum (`Key`, `Mouse`, `Tick`, `Redraw`, `Resize`, `LogLines`, `CreateResult`, `ProviderCreateResult`, `ProviderDetailFetched`, `ProviderUpdateResult`, `ProviderDeleteResult`, `DraftActionResult`, `GlobalSettingsFetched`, `GlobalSettingSetResult`, `GlobalSettingDeleteResult`, `SandboxSettingSetResult`, `SandboxSettingDeleteResult`, `ForwardWarnings`), `EventHandler` with mpsc channels and crossterm polling |
|
||||
| `crates/openshell-tui/src/theme.rs` | `colors` module (NVIDIA_GREEN, EVERGLADE, BG, FG) and `styles` module (all `Style` constants) |
|
||||
@@ -470,14 +469,20 @@ Proto types come from `openshell-core` which generates them from `OUT_DIR` via `
|
||||
|
||||
```rust
|
||||
use openshell_core::proto::openshell_client::OpenShellClient;
|
||||
use openshell_core::proto::{ListSandboxesRequest, GetSandboxLogsRequest, ...};
|
||||
use openshell_core::proto::{
|
||||
all_workspaces_selector, workspace_selector, GetSandboxLogsRequest,
|
||||
ListSandboxesRequest, ...
|
||||
};
|
||||
```
|
||||
|
||||
### Proto field gotchas
|
||||
|
||||
- `DeleteSandboxRequest` uses the `name` field (not `id`):
|
||||
```rust
|
||||
let req = openshell_core::proto::DeleteSandboxRequest { name: sandbox_name };
|
||||
let req = openshell_core::proto::DeleteSandboxRequest {
|
||||
name: sandbox_name,
|
||||
workspace_scope: Some(workspace_selector(workspace)),
|
||||
};
|
||||
```
|
||||
- `WatchSandboxRequest` has extra fields beyond what you might need — always use `..Default::default()`:
|
||||
```rust
|
||||
@@ -491,12 +496,24 @@ use openshell_core::proto::{ListSandboxesRequest, GetSandboxLogsRequest, ...};
|
||||
};
|
||||
```
|
||||
- `SandboxLogLine` proto fields: `sandbox_id`, `timestamp_ms`, `level`, `target`, `message`, `source`, `fields` (HashMap<String, String>).
|
||||
- `GetSandboxLogsRequest` fields: `sandbox_id`, `lines` (u32), `since_ms` (i64), `sources` (Vec<String>), `min_level` (String), `workspace` (String).
|
||||
- `ListSandboxesRequest` fields: `limit` (i64), `offset` (i64), `label_selector` (String), `workspace` (String), `all_workspaces` (bool).
|
||||
- `ListProvidersRequest` fields: `limit` (i64), `offset` (i64), `workspace` (String), `all_workspaces` (bool).
|
||||
- `ListWorkspacesRequest` fields: `limit` (i64), `offset` (i64), `label_selector` (String).
|
||||
- `UpdateConfigRequest` fields: `name` (String, sandbox name or empty for global), `setting_key`, `setting_value`, `delete_setting` (bool), `global` (bool), `workspace`.
|
||||
- Most resource requests include a `workspace` field that scopes the operation to the current workspace.
|
||||
- Workspace-scoped request fields use `workspace_scope: Option<WorkspaceSelector>`.
|
||||
Select one workspace with `Some(workspace_selector(name))`. List requests that
|
||||
explicitly support cross-workspace access also accept
|
||||
`Some(all_workspaces_selector())`; do not use that marker on other requests.
|
||||
- `GetSandboxLogsRequest` fields: `sandbox_id`, `lines` (u32), `since_ms` (i64),
|
||||
`sources` (Vec<String>), `min_level` (String), `workspace_scope`.
|
||||
- `ListSandboxesRequest` fields: `limit` (u32), `offset` (u32),
|
||||
`label_selector` (String), `workspace_scope`.
|
||||
- `ListProvidersRequest` fields: `limit` (u32), `offset` (u32),
|
||||
`workspace_scope`.
|
||||
- `ListWorkspacesRequest` fields: `limit` (u32), `offset` (u32),
|
||||
`label_selector` (String).
|
||||
- `UpdateConfigRequest` fields include `name` (String, sandbox name or empty for
|
||||
global), `setting_key`, `setting_value`, `delete_setting` (bool), `global`
|
||||
(bool), and `workspace_scope`. Sandbox-scoped updates require a named selector;
|
||||
gateway-global updates must leave `workspace_scope` as `None`.
|
||||
- Most resource requests require an explicit named `workspace_scope`, including
|
||||
the `default` workspace. An omitted selector is not an implicit default.
|
||||
|
||||
### gRPC timeouts
|
||||
|
||||
@@ -526,7 +543,6 @@ The connect timeout for gateway switching is 10 seconds with HTTP/2 keepalive at
|
||||
4. On success:
|
||||
- `app.client` is replaced with a new intercepted client
|
||||
- `reset_sandbox_state()` clears all sandbox/log/draft/policy data
|
||||
- `fetch_providers_v2_setting()` probes the new gateway's `GetGatewayConfig` to determine whether providers_v2 mode is enabled, so provider CRUD controls render correctly
|
||||
- `refresh_data()` runs the full capability refresh sequence: `refresh_health` → `refresh_global_settings` → `refresh_workspaces` → `refresh_providers` → `refresh_sandboxes`
|
||||
5. On failure: `status_text` shows the error
|
||||
|
||||
@@ -534,13 +550,12 @@ The connect timeout for gateway switching is 10 seconds with HTTP/2 keepalive at
|
||||
|
||||
On launch, before the event loop starts:
|
||||
|
||||
1. `fetch_providers_v2_setting()` — probe gateway capability
|
||||
2. `refresh_gateway_list()` — discover gateways from disk
|
||||
3. `refresh_data()` — full refresh (health, global settings, workspaces, providers, sandboxes)
|
||||
1. `refresh_gateway_list()` — discover gateways from disk
|
||||
2. `refresh_data()` — full refresh (health, global settings, workspaces, providers, sandboxes)
|
||||
|
||||
### Workspace switching lifecycle
|
||||
|
||||
1. User presses `[w]` on the sandboxes panel → `cycle_workspace()` advances through discovered workspace names, then "all"
|
||||
1. User presses `[w]` on the providers or sandboxes panel → `cycle_workspace()` advances through discovered workspace names, then "all"
|
||||
2. `pending_workspace_refresh = true` is set, cursor indices are reset
|
||||
3. Event loop calls `refresh_providers()` and `refresh_sandboxes()` with the new workspace scope
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: update-docs-from-commits
|
||||
description: Scan recent git commits for changes that affect user-facing behavior, then draft or update the corresponding documentation pages. Use when docs have fallen behind code changes, after a batch of features lands, or when preparing a release. Trigger keywords - update docs, draft docs, docs from commits, sync docs, catch up docs, doc debt, docs behind, docs drift.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Update Docs from Commits
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: watch-github-actions
|
||||
description: Watch and monitor GitHub Actions workflow runs using the gh CLI. Use when the user wants to check workflow status, watch a running workflow, view CI/CD jobs, or monitor build progress. Trigger keywords - watch pipeline, pipeline status, CI status, check build, monitor CI, view pipeline, pipeline progress, workflow status, actions status.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Watch GitHub Actions
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
[profile.ci]
|
||||
fail-fast = false
|
||||
status-level = "fail"
|
||||
status-level = "slow"
|
||||
final-status-level = "fail"
|
||||
failure-output = "immediate-final"
|
||||
# Print slow test names after 30 seconds and terminate them after two minutes.
|
||||
slow-timeout = { period = "30s", terminate-after = 4 }
|
||||
|
||||
@@ -7,11 +7,10 @@ contact_links:
|
||||
Vouch Request discussion describing what you want to work on. A
|
||||
maintainer will approve you with /vouch.
|
||||
- name: Have a question?
|
||||
url: https://github.com/NVIDIA/OpenShell/blob/main/CONTRIBUTING.md#agent-skills-for-contributors
|
||||
url: https://github.com/NVIDIA/OpenShell/blob/main/CONTRIBUTING.md#skills-for-using-openshell
|
||||
about: >
|
||||
Point your agent at the repo. It has skills for CLI usage, cluster and
|
||||
inference debugging, policy generation, and more. See CONTRIBUTING.md
|
||||
for the full skills table.
|
||||
Install the public OpenShell skills for CLI usage, cluster and inference
|
||||
debugging, and policy generation. See CONTRIBUTING.md for details.
|
||||
- name: Security vulnerability?
|
||||
url: https://github.com/NVIDIA/OpenShell/blob/main/SECURITY.md
|
||||
about: >
|
||||
|
||||
@@ -14,9 +14,6 @@ inputs:
|
||||
triple:
|
||||
description: Rust target triple
|
||||
required: true
|
||||
dev-shell:
|
||||
description: Nix development shell used to build the binary
|
||||
required: true
|
||||
cargo-version:
|
||||
description: Cargo package version embedded in the binary
|
||||
required: true
|
||||
@@ -32,10 +29,6 @@ inputs:
|
||||
description: Additional flags passed to cargo build
|
||||
required: false
|
||||
default: ""
|
||||
interpreter:
|
||||
description: ELF interpreter for a dynamically linked Linux binary
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -43,9 +36,9 @@ runs:
|
||||
- name: Hash development shell
|
||||
id: dev-shell
|
||||
shell: bash
|
||||
env:
|
||||
DEV_SHELL: ${{ inputs.dev-shell }}
|
||||
run: echo "hash=$(nix hash file --type sha256 --base16 "$(nix eval --raw "${DEV_SHELL}.drvPath")")" >> "$GITHUB_OUTPUT"
|
||||
run: |
|
||||
shell_drv=$(nix eval --raw --impure .#devShells --apply 'shells: shells.${builtins.currentSystem}.default.drvPath')
|
||||
echo "hash=$(nix hash file --type sha256 --base16 "$shell_drv")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Rust artifacts
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
@@ -54,90 +47,41 @@ runs:
|
||||
cache-on-failure: "true"
|
||||
cache-workspace-crates: "true"
|
||||
cache-bin: "false"
|
||||
cmd-format: nix develop ${{ inputs.dev-shell }} -c {0}
|
||||
cmd-format: nix develop -c {0}
|
||||
|
||||
- name: Set version
|
||||
shell: nix develop ${{ inputs.dev-shell }} -c bash -euo pipefail {0}
|
||||
shell: nix develop -c bash -euo pipefail {0}
|
||||
env:
|
||||
INPUTS_CARGO_VERSION: ${{ inputs.cargo-version }}
|
||||
run: sed -i "s/^version = \"0\\.0\\.0\"$/version = \"${INPUTS_CARGO_VERSION}\"/" Cargo.toml
|
||||
|
||||
- name: Build ${{ inputs.binary }}
|
||||
shell: nix develop ${{ inputs.dev-shell }} -c bash -euo pipefail {0}
|
||||
shell: nix develop -c bash -euo pipefail {0}
|
||||
env:
|
||||
GIT_DIR: /nonexistent
|
||||
OPENSHELL_IMAGE_TAG: ${{ inputs.image-tag }}
|
||||
INPUTS_PACKAGE: ${{ inputs.package }}
|
||||
INPUTS_BINARY: ${{ inputs.binary }}
|
||||
INPUTS_TRIPLE: ${{ inputs.triple }}
|
||||
INPUTS_EXTRA_CARGO_FLAGS: ${{ inputs.extra-cargo-flags }}
|
||||
run: GIT_DIR=/nonexistent cargo auditable build --release --package "${INPUTS_PACKAGE}" --bin "${INPUTS_BINARY}" ${INPUTS_EXTRA_CARGO_FLAGS}
|
||||
|
||||
- name: Normalize Darwin dynamic binary
|
||||
if: endsWith(inputs.triple, '-apple-darwin')
|
||||
shell: bash -euo pipefail {0}
|
||||
env:
|
||||
INPUTS_BINARY: ${{ inputs.binary }}
|
||||
run: |
|
||||
binary=target/release/${INPUTS_BINARY}
|
||||
nix_iconv_paths=$(otool -L "$binary" | awk '$1 ~ "^/nix/store/.*/lib/libiconv\\.2\\.dylib$" { print $1 }')
|
||||
if [[ -n "$nix_iconv_paths" ]]; then
|
||||
while IFS= read -r nix_iconv_path; do
|
||||
install_name_tool -change "$nix_iconv_path" /usr/lib/libiconv.2.dylib "$binary"
|
||||
done <<< "$nix_iconv_paths"
|
||||
codesign --force --sign - "$binary"
|
||||
fi
|
||||
|
||||
- name: Verify Darwin portability
|
||||
if: endsWith(inputs.triple, '-apple-darwin')
|
||||
shell: bash -euo pipefail {0}
|
||||
env:
|
||||
INPUTS_BINARY: ${{ inputs.binary }}
|
||||
run: |
|
||||
binary=target/release/${INPUTS_BINARY}
|
||||
nix_dependencies=$(otool -L "$binary" | awk '$1 ~ "^/nix/store/" { print $1 }')
|
||||
if [[ -n "$nix_dependencies" ]]; then
|
||||
echo "error: $binary has non-portable Nix store dependencies:" >&2
|
||||
printf ' %s\n' "$nix_dependencies" >&2
|
||||
exit 1
|
||||
fi
|
||||
codesign --verify --verbose=2 "$binary"
|
||||
run: cargo auditable build --release --target "${INPUTS_TRIPLE}" --package "${INPUTS_PACKAGE}" --bin "${INPUTS_BINARY}" ${INPUTS_EXTRA_CARGO_FLAGS}
|
||||
|
||||
- name: Verify ${{ inputs.binary }}
|
||||
shell: nix develop ${{ inputs.dev-shell }} -c bash -euo pipefail {0}
|
||||
shell: nix develop -c bash -euo pipefail {0}
|
||||
env:
|
||||
INPUTS_BINARY: ${{ inputs.binary }}
|
||||
INPUTS_TRIPLE: ${{ inputs.triple }}
|
||||
run: |
|
||||
# Confirm the binary runs and reports the expected name.
|
||||
target/release/${INPUTS_BINARY} --version | grep -q "^${INPUTS_BINARY} "
|
||||
target/${INPUTS_TRIPLE}/release/${INPUTS_BINARY} --version | grep -q "^${INPUTS_BINARY} "
|
||||
# Confirm Syft can decode the embedded cargo-auditable metadata.
|
||||
SYFT_CHECK_FOR_APP_UPDATE=false syft file:target/release/${INPUTS_BINARY} -o cyclonedx-json | grep 'pkg:cargo/' > /dev/null
|
||||
|
||||
- name: Verify static linkage
|
||||
if: endsWith(inputs.triple, '-linux-musl')
|
||||
shell: nix develop ${{ inputs.dev-shell }} -c bash -euo pipefail {0}
|
||||
env:
|
||||
INPUTS_BINARY: ${{ inputs.binary }}
|
||||
run: tasks/scripts/verify-static-binary.sh target/release/${INPUTS_BINARY}
|
||||
|
||||
- name: Normalize Linux dynamic binary
|
||||
if: endsWith(inputs.triple, '-linux-gnu')
|
||||
shell: nix develop ${{ inputs.dev-shell }} -c bash -euo pipefail {0}
|
||||
env:
|
||||
INPUTS_BINARY: ${{ inputs.binary }}
|
||||
INPUTS_INTERPRETER: ${{ inputs.interpreter }}
|
||||
run: |
|
||||
binary=target/release/${INPUTS_BINARY}
|
||||
# Remove Nix store paths so the binary can run on other distributions.
|
||||
patchelf --set-interpreter "${INPUTS_INTERPRETER}" --remove-rpath "$binary"
|
||||
# Z3 must be embedded instead of loaded from the target system.
|
||||
test -z "$(patchelf --print-needed "$binary" | grep '^libz3')"
|
||||
# Reject symbols introduced after glibc 2.28.
|
||||
tasks/scripts/verify-glibc-symbols.sh 2.28 "$binary"
|
||||
SYFT_CHECK_FOR_APP_UPDATE=false syft file:target/${INPUTS_TRIPLE}/release/${INPUTS_BINARY} -o cyclonedx-json | grep 'pkg:cargo/' > /dev/null
|
||||
|
||||
- name: Upload ${{ inputs.binary }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ${{ inputs.artifact-name || format('{0}-{1}', inputs.binary, inputs.triple) }}
|
||||
path: target/release/${{ inputs.binary }}
|
||||
path: target/${{ inputs.triple }}/release/${{ inputs.binary }}
|
||||
compression-level: 0
|
||||
retention-days: 5
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
name: Release Helm OCI
|
||||
description: >
|
||||
Patch chart version/appVersion, refuse duplicate OCI versions on public
|
||||
releases, package the chart, and push to GHCR OCI.
|
||||
releases, package the gateway and workspace charts, and push them to GHCR OCI.
|
||||
|
||||
inputs:
|
||||
chart-version:
|
||||
@@ -51,11 +51,16 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CHART_DIR="${RUNNER_TEMP}/chart-build"
|
||||
cp -a deploy/helm/openshell/. "${CHART_DIR}"
|
||||
sed -i "s/^version:.*/version: ${CHART_VERSION}/" "${CHART_DIR}/Chart.yaml"
|
||||
sed -i "s/^appVersion:.*/appVersion: \"${APP_VERSION}\"/" "${CHART_DIR}/Chart.yaml"
|
||||
echo "chart_dir=${CHART_DIR}" >> "$GITHUB_OUTPUT"
|
||||
GATEWAY_CHART_DIR="${RUNNER_TEMP}/gateway-chart-build"
|
||||
WORKSPACE_CHART_DIR="${RUNNER_TEMP}/workspace-chart-build"
|
||||
cp -a deploy/helm/openshell/. "${GATEWAY_CHART_DIR}"
|
||||
cp -a deploy/helm/openshell-workspace/. "${WORKSPACE_CHART_DIR}"
|
||||
for chart_dir in "${GATEWAY_CHART_DIR}" "${WORKSPACE_CHART_DIR}"; do
|
||||
sed -i "s/^version:.*/version: ${CHART_VERSION}/" "${chart_dir}/Chart.yaml"
|
||||
sed -i "s/^appVersion:.*/appVersion: \"${APP_VERSION}\"/" "${chart_dir}/Chart.yaml"
|
||||
done
|
||||
echo "gateway_chart_dir=${GATEWAY_CHART_DIR}" >> "$GITHUB_OUTPUT"
|
||||
echo "workspace_chart_dir=${WORKSPACE_CHART_DIR}" >> "$GITHUB_OUTPUT"
|
||||
echo "chart_version=${CHART_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Refuse duplicate chart version
|
||||
@@ -65,38 +70,52 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
OCI_CHART="oci://ghcr.io/nvidia/openshell/helm-chart"
|
||||
if helm show chart "${OCI_CHART}" --version "${CHART_VERSION}" >/dev/null 2>&1; then
|
||||
echo "::error::Chart ${CHART_VERSION} is already published. Use a new tag or delete the existing package first."
|
||||
exit 1
|
||||
fi
|
||||
for chart in helm-chart openshell-workspace; do
|
||||
OCI_CHART="oci://ghcr.io/nvidia/openshell/${chart}"
|
||||
if helm show chart "${OCI_CHART}" --version "${CHART_VERSION}" >/dev/null 2>&1; then
|
||||
echo "::error::Chart ${chart}:${CHART_VERSION} is already published. Use a new tag or delete the existing package first."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Package Helm chart
|
||||
env:
|
||||
CHART_DIR: ${{ steps.prep.outputs.chart_dir }}
|
||||
GATEWAY_CHART_DIR: ${{ steps.prep.outputs.gateway_chart_dir }}
|
||||
WORKSPACE_CHART_DIR: ${{ steps.prep.outputs.workspace_chart_dir }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
helm package "${CHART_DIR}" --destination /tmp
|
||||
ls /tmp/helm-chart-*.tgz
|
||||
mkdir -p /tmp/helm-charts
|
||||
helm package "${GATEWAY_CHART_DIR}" --destination /tmp/helm-charts
|
||||
helm package "${WORKSPACE_CHART_DIR}" --destination /tmp/helm-charts
|
||||
ls /tmp/helm-charts/*.tgz
|
||||
|
||||
- name: Push Helm chart to GHCR OCI
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
helm push /tmp/helm-chart-*.tgz oci://ghcr.io/nvidia/openshell
|
||||
for archive in /tmp/helm-charts/*.tgz; do
|
||||
helm push "${archive}" oci://ghcr.io/nvidia/openshell
|
||||
done
|
||||
|
||||
- name: Push SHA-pinned chart
|
||||
if: inputs.pin-sha != ''
|
||||
env:
|
||||
PIN_SHA: ${{ inputs.pin-sha }}
|
||||
CHART_DIR: ${{ steps.prep.outputs.chart_dir }}
|
||||
GATEWAY_CHART_DIR: ${{ steps.prep.outputs.gateway_chart_dir }}
|
||||
WORKSPACE_CHART_DIR: ${{ steps.prep.outputs.workspace_chart_dir }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHA_CHART_DIR="${RUNNER_TEMP}/chart-build-sha"
|
||||
cp -a "${CHART_DIR}/." "${SHA_CHART_DIR}"
|
||||
sed -i "s/^version:.*/version: 0.0.0-dev.${PIN_SHA}/" "${SHA_CHART_DIR}/Chart.yaml"
|
||||
sed -i "s/^appVersion:.*/appVersion: \"${PIN_SHA}\"/" "${SHA_CHART_DIR}/Chart.yaml"
|
||||
helm package "${SHA_CHART_DIR}" --destination /tmp/sha-pin
|
||||
helm push /tmp/sha-pin/helm-chart-*.tgz oci://ghcr.io/nvidia/openshell
|
||||
mkdir -p /tmp/sha-pin
|
||||
for source_dir in "${GATEWAY_CHART_DIR}" "${WORKSPACE_CHART_DIR}"; do
|
||||
chart_name="$(basename "${source_dir}")"
|
||||
sha_chart_dir="${RUNNER_TEMP}/${chart_name}-sha"
|
||||
cp -a "${source_dir}/." "${sha_chart_dir}"
|
||||
sed -i "s/^version:.*/version: 0.0.0-dev.${PIN_SHA}/" "${sha_chart_dir}/Chart.yaml"
|
||||
sed -i "s/^appVersion:.*/appVersion: \"${PIN_SHA}\"/" "${sha_chart_dir}/Chart.yaml"
|
||||
helm package "${sha_chart_dir}" --destination /tmp/sha-pin
|
||||
done
|
||||
for archive in /tmp/sha-pin/*.tgz; do
|
||||
helm push "${archive}" oci://ghcr.io/nvidia/openshell
|
||||
done
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
name: Setup E2E CLI
|
||||
description: Download an architecture-matched prebuilt OpenShell CLI for E2E tests
|
||||
description: Download architecture-matched prebuilt OpenShell host CLIs for E2E tests
|
||||
|
||||
inputs:
|
||||
conformance-artifact-prefix:
|
||||
description: Optional conformance CLI artifact name prefix; linux-<arch> is appended automatically
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -17,3 +23,27 @@ runs:
|
||||
chmod +x "$cli"
|
||||
"$cli" --version
|
||||
echo "OPENSHELL_BIN=$cli" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download prebuilt conformance CLI
|
||||
if: inputs.conformance-artifact-prefix != ''
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: ${{ format('{0}-{1}', inputs.conformance-artifact-prefix, runner.arch == 'X64' && 'x86_64-unknown-linux-musl' || 'aarch64-unknown-linux-musl') }}
|
||||
path: .e2e/prebuilt-conformance
|
||||
|
||||
- name: Configure prebuilt conformance CLI
|
||||
if: inputs.conformance-artifact-prefix != ''
|
||||
shell: bash
|
||||
run: | # zizmor: ignore[github-env] validated filename under the trusted workspace path
|
||||
set -euo pipefail
|
||||
conformance="$GITHUB_WORKSPACE/.e2e/prebuilt-conformance/openshell-conformance"
|
||||
if [[ ! -f "$conformance" ]]; then
|
||||
echo "downloaded artifact is missing $conformance" >&2
|
||||
exit 1
|
||||
fi
|
||||
# TODO: Remove this temporary mode diagnostic after CI confirms artifact permission handling.
|
||||
echo "conformance binary mode before chmod: $(ls -l "$conformance")"
|
||||
chmod +x "$conformance"
|
||||
echo "conformance binary mode after chmod: $(ls -l "$conformance")"
|
||||
"$conformance" list --output json >/dev/null
|
||||
echo "OPENSHELL_CONFORMANCE_BIN=$conformance" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -41,6 +41,7 @@ jobs:
|
||||
needs: pr_metadata
|
||||
if: needs.pr_metadata.outputs.should_run == 'true'
|
||||
runs-on: linux-amd64-cpu8
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: ghcr.io/nvidia/openshell/ci:latest
|
||||
credentials:
|
||||
@@ -65,6 +66,7 @@ jobs:
|
||||
needs: pr_metadata
|
||||
if: needs.pr_metadata.outputs.should_run == 'true'
|
||||
runs-on: linux-amd64-cpu8
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: ghcr.io/nvidia/openshell/ci:latest
|
||||
credentials:
|
||||
@@ -84,6 +86,7 @@ jobs:
|
||||
needs: pr_metadata
|
||||
if: needs.pr_metadata.outputs.should_run == 'true'
|
||||
runs-on: linux-amd64-cpu8
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
shell: nix develop .#devShells.x86_64-linux.default -c bash -euo pipefail {0}
|
||||
@@ -117,6 +120,7 @@ jobs:
|
||||
- runner: macos-15-xlarge
|
||||
system: aarch64-darwin
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 90
|
||||
defaults:
|
||||
run:
|
||||
shell: nix develop .#devShells.${{ matrix.system }}.default -c bash -euo pipefail {0}
|
||||
@@ -154,12 +158,14 @@ jobs:
|
||||
cargo fmt --all -- --check
|
||||
cargo fmt --manifest-path e2e/rust/Cargo.toml --all -- --check
|
||||
cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all -- --check
|
||||
cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all -- --check
|
||||
|
||||
- name: Lint
|
||||
run: |
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo clippy --manifest-path e2e/rust/Cargo.toml --all-targets -- -D warnings
|
||||
cargo check --manifest-path examples/governance-interceptor/Cargo.toml --all-targets
|
||||
cargo check --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all-targets
|
||||
|
||||
- name: Test
|
||||
env:
|
||||
@@ -167,6 +173,30 @@ jobs:
|
||||
run: |
|
||||
cargo nextest run --profile ci --workspace --features openshell-server/test-support
|
||||
|
||||
- name: Verify telemetry can be compiled out
|
||||
run: |
|
||||
cargo build -p openshell-gateway --bin openshell-gateway
|
||||
tasks/scripts/verify-telemetry-compiled-out.sh present target/debug/openshell-gateway
|
||||
cargo build -p openshell-gateway --bin openshell-gateway --no-default-features --features defaults-without-telemetry
|
||||
tasks/scripts/verify-telemetry-compiled-out.sh absent target/debug/openshell-gateway
|
||||
cargo build -p openshell-sandbox --bin openshell-sandbox --no-default-features --features defaults-without-telemetry
|
||||
tasks/scripts/verify-telemetry-compiled-out.sh absent target/debug/openshell-sandbox
|
||||
|
||||
- name: Verify the defaults-without-telemetry feature alias tracks the default feature set
|
||||
run: tasks/scripts/verify-defaults-without-telemetry.sh
|
||||
|
||||
- name: Verify system CA roots build mode compiles and excludes bundled Mozilla roots
|
||||
run: |
|
||||
cargo check -p openshell-sandbox --all-targets --no-default-features --features system-ca-roots
|
||||
if cargo tree -p openshell-sandbox -i webpki-roots --no-default-features --features system-ca-roots 2>/dev/null | grep -q webpki-roots; then
|
||||
echo "ERROR: webpki-roots found in system CA roots build" >&2
|
||||
exit 1
|
||||
fi
|
||||
if cargo tree -p openshell-sandbox -i webpki-root-certs --no-default-features --features system-ca-roots 2>/dev/null | grep -q webpki-root-certs; then
|
||||
echo "ERROR: webpki-root-certs found in system CA roots build" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python:
|
||||
name: Python (${{ matrix.runner }})
|
||||
needs: pr_metadata
|
||||
@@ -176,6 +206,7 @@ jobs:
|
||||
matrix:
|
||||
runner: [linux-amd64-cpu8, linux-arm64-cpu8]
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: ghcr.io/nvidia/openshell/ci:latest
|
||||
credentials:
|
||||
@@ -207,6 +238,7 @@ jobs:
|
||||
needs: pr_metadata
|
||||
if: needs.pr_metadata.outputs.should_run == 'true'
|
||||
runs-on: linux-amd64-cpu8
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: ghcr.io/nvidia/openshell/ci:latest
|
||||
credentials:
|
||||
@@ -226,6 +258,7 @@ jobs:
|
||||
needs: pr_metadata
|
||||
if: needs.pr_metadata.outputs.should_run == 'true'
|
||||
runs-on: linux-amd64-cpu8
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: ghcr.io/nvidia/openshell/ci:latest
|
||||
credentials:
|
||||
@@ -245,6 +278,7 @@ jobs:
|
||||
needs: pr_metadata
|
||||
if: needs.pr_metadata.outputs.should_run == 'true'
|
||||
runs-on: linux-amd64-cpu8
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: ghcr.io/nvidia/openshell/ci:latest
|
||||
credentials:
|
||||
|
||||
@@ -105,6 +105,28 @@ jobs:
|
||||
cargo-version: ${{ needs.version.outputs.cargo }}
|
||||
secrets: inherit
|
||||
|
||||
build-conformance:
|
||||
needs: [pr_metadata, version]
|
||||
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_any_e2e == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- triple: x86_64-unknown-linux-musl
|
||||
runner: linux-amd64-cpu8
|
||||
- triple: aarch64-unknown-linux-musl
|
||||
runner: linux-arm64-cpu8
|
||||
uses: ./.github/workflows/build-binaries.yml
|
||||
with:
|
||||
package: openshell-conformance-cli
|
||||
binary: openshell-conformance
|
||||
artifact-name: openshell-conformance-${{ matrix.triple }}
|
||||
triple: ${{ matrix.triple }}
|
||||
runner: ${{ matrix.runner }}
|
||||
cargo-version: ${{ needs.version.outputs.cargo }}
|
||||
secrets: inherit
|
||||
|
||||
build-sandbox:
|
||||
needs: version
|
||||
permissions:
|
||||
@@ -133,23 +155,17 @@ jobs:
|
||||
include:
|
||||
- triple: x86_64-unknown-linux-gnu
|
||||
runner: linux-amd64-cpu8
|
||||
dev_shell: .#devShells.x86_64-linux.glibc-2-28
|
||||
interpreter: /lib64/ld-linux-x86-64.so.2
|
||||
- triple: aarch64-unknown-linux-gnu
|
||||
runner: linux-arm64-cpu8
|
||||
dev_shell: .#devShells.aarch64-linux.glibc-2-28
|
||||
interpreter: /lib/ld-linux-aarch64.so.1
|
||||
uses: ./.github/workflows/build-binaries.yml
|
||||
with:
|
||||
package: openshell-server
|
||||
package: openshell-gateway
|
||||
binary: openshell-gateway
|
||||
artifact-name: openshell-gateway-plain-${{ matrix.triple }}
|
||||
triple: ${{ matrix.triple }}
|
||||
runner: ${{ matrix.runner }}
|
||||
dev-shell: ${{ matrix.dev_shell }}
|
||||
cargo-version: ${{ needs.version.outputs.cargo }}
|
||||
extra-cargo-flags: --no-default-features
|
||||
interpreter: ${{ matrix.interpreter }}
|
||||
secrets: inherit
|
||||
|
||||
build-driver-docker:
|
||||
@@ -163,9 +179,7 @@ jobs:
|
||||
binary: openshell-driver-docker
|
||||
triple: aarch64-unknown-linux-gnu
|
||||
runner: linux-arm64-cpu8
|
||||
dev-shell: .#devShells.aarch64-linux.glibc-2-28
|
||||
cargo-version: ${{ needs.version.outputs.cargo }}
|
||||
interpreter: /lib/ld-linux-aarch64.so.1
|
||||
secrets: inherit
|
||||
|
||||
build-driver-podman:
|
||||
@@ -179,9 +193,7 @@ jobs:
|
||||
binary: openshell-driver-podman
|
||||
triple: x86_64-unknown-linux-gnu
|
||||
runner: linux-amd64-cpu8
|
||||
dev-shell: .#devShells.x86_64-linux.glibc-2-28
|
||||
cargo-version: ${{ needs.version.outputs.cargo }}
|
||||
interpreter: /lib64/ld-linux-x86-64.so.2
|
||||
secrets: inherit
|
||||
|
||||
build-driver-kubernetes:
|
||||
@@ -195,9 +207,7 @@ jobs:
|
||||
binary: openshell-driver-kubernetes
|
||||
triple: x86_64-unknown-linux-gnu
|
||||
runner: linux-amd64-cpu8
|
||||
dev-shell: .#devShells.x86_64-linux.glibc-2-28
|
||||
cargo-version: ${{ needs.version.outputs.cargo }}
|
||||
interpreter: /lib64/ld-linux-x86-64.so.2
|
||||
secrets: inherit
|
||||
|
||||
build-vm-driver:
|
||||
@@ -241,7 +251,7 @@ jobs:
|
||||
secrets: inherit
|
||||
|
||||
docker-e2e:
|
||||
needs: [pr_metadata, build-cli, build-gateway, build-supervisor-image]
|
||||
needs: [pr_metadata, build-cli, build-conformance, build-gateway, build-supervisor-image]
|
||||
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true'
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -251,9 +261,10 @@ jobs:
|
||||
with:
|
||||
image-tag: ${{ github.sha }}
|
||||
runner: linux-arm64-cpu8
|
||||
conformance-artifact-prefix: openshell-conformance
|
||||
|
||||
podman-e2e:
|
||||
needs: [pr_metadata, build-cli, build-gateway, build-supervisor-image]
|
||||
needs: [pr_metadata, build-cli, build-conformance, build-gateway, build-supervisor-image]
|
||||
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true'
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -262,18 +273,21 @@ jobs:
|
||||
uses: ./.github/workflows/e2e-podman-test.yml
|
||||
with:
|
||||
image-tag: ${{ github.sha }}
|
||||
conformance-artifact-prefix: openshell-conformance
|
||||
|
||||
vm-e2e:
|
||||
needs: [pr_metadata, build-cli, build-gateway, build-vm-driver]
|
||||
needs: [pr_metadata, build-cli, build-conformance, build-gateway, build-vm-driver]
|
||||
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-vm-test.yml
|
||||
with:
|
||||
conformance-artifact-prefix: openshell-conformance
|
||||
|
||||
docker-external-driver-e2e:
|
||||
needs: [pr_metadata, build-cli, build-gateway-plain, build-driver-docker, build-supervisor-image]
|
||||
needs: [pr_metadata, build-cli, build-conformance, build-gateway-plain, build-driver-docker, build-supervisor-image]
|
||||
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true'
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -285,11 +299,12 @@ jobs:
|
||||
runner: linux-arm64-cpu8
|
||||
gateway-artifact: openshell-gateway-plain-aarch64-unknown-linux-gnu
|
||||
external-driver-binary: openshell-driver-docker
|
||||
conformance-artifact-prefix: openshell-conformance
|
||||
suite-matrix: >-
|
||||
[{"suite":"external-driver","cmd":"mise run --no-deps --skip-deps e2e:docker:external-driver","apt_packages":"openssh-client","python_proto":false,"mcp":false}]
|
||||
|
||||
podman-external-driver-e2e:
|
||||
needs: [pr_metadata, build-cli, build-gateway-plain, build-driver-podman, build-supervisor-image]
|
||||
needs: [pr_metadata, build-cli, build-conformance, build-gateway-plain, build-driver-podman, build-supervisor-image]
|
||||
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true'
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -300,11 +315,12 @@ jobs:
|
||||
image-tag: ${{ github.sha }}
|
||||
gateway-artifact: openshell-gateway-plain-x86_64-unknown-linux-gnu
|
||||
external-driver-binary: openshell-driver-podman
|
||||
conformance-artifact-prefix: openshell-conformance
|
||||
suite-matrix: >-
|
||||
[{"suite":"external-driver","runner":"ubuntu-26.04","podman_major":"5","podman_package_version":"5.7.0+ds2-3build1","conmon_package_version":"2.1.13+ds1-2","cmd":"mise run --no-deps --skip-deps e2e:podman:external-driver"}]
|
||||
|
||||
vm-external-driver-e2e:
|
||||
needs: [pr_metadata, build-cli, build-gateway-plain, build-vm-driver]
|
||||
needs: [pr_metadata, build-cli, build-conformance, build-gateway-plain, build-vm-driver]
|
||||
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true'
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -315,9 +331,10 @@ jobs:
|
||||
gateway-artifact: openshell-gateway-plain-x86_64-unknown-linux-gnu
|
||||
suite-name: external-driver
|
||||
e2e-task: e2e:vm:external-driver
|
||||
conformance-artifact-prefix: openshell-conformance
|
||||
|
||||
gpu-e2e:
|
||||
needs: [pr_metadata, build-cli, build-gateway, build-supervisor-image]
|
||||
needs: [pr_metadata, build-cli, build-conformance, build-gateway, build-supervisor-image]
|
||||
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_gpu_e2e == 'true'
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -326,9 +343,10 @@ jobs:
|
||||
uses: ./.github/workflows/e2e-gpu-test.yaml
|
||||
with:
|
||||
image-tag: ${{ github.sha }}
|
||||
conformance-artifact-prefix: openshell-conformance
|
||||
|
||||
kubernetes-e2e:
|
||||
needs: [pr_metadata, build-cli, build-gateway-image, build-supervisor-image]
|
||||
needs: [pr_metadata, build-cli, build-conformance, build-gateway-image, build-supervisor-image]
|
||||
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -356,9 +374,10 @@ 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 }}
|
||||
conformance-artifact-prefix: openshell-conformance
|
||||
|
||||
kubernetes-workspace-managed-e2e:
|
||||
needs: [pr_metadata, build-cli, build-gateway-image, build-supervisor-image]
|
||||
needs: [pr_metadata, build-cli, build-conformance, build-gateway-image, build-supervisor-image]
|
||||
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true'
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -369,9 +388,10 @@ jobs:
|
||||
image-tag: ${{ github.sha }}
|
||||
job-name: Kubernetes E2E (workspace managed mode)
|
||||
e2e-task: e2e:kubernetes:workspace-managed
|
||||
conformance-artifact-prefix: openshell-conformance
|
||||
|
||||
kubernetes-external-driver-e2e:
|
||||
needs: [pr_metadata, build-cli, build-gateway-plain, build-driver-kubernetes, build-supervisor-image]
|
||||
needs: [pr_metadata, build-cli, build-conformance, build-gateway-plain, build-driver-kubernetes, build-supervisor-image]
|
||||
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true'
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -385,9 +405,10 @@ jobs:
|
||||
gateway-artifact: openshell-gateway-plain-x86_64-unknown-linux-gnu
|
||||
external-driver-binary: openshell-driver-kubernetes
|
||||
cluster-images: supervisor
|
||||
conformance-artifact-prefix: openshell-conformance
|
||||
|
||||
kubernetes-workspace-operator-e2e:
|
||||
needs: [pr_metadata, build-cli, build-gateway-image, build-supervisor-image]
|
||||
needs: [pr_metadata, build-cli, build-conformance, build-gateway-image, build-supervisor-image]
|
||||
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true'
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -398,9 +419,10 @@ jobs:
|
||||
image-tag: ${{ github.sha }}
|
||||
job-name: Kubernetes E2E (workspace operator mode)
|
||||
e2e-task: e2e:kubernetes:workspace-operator
|
||||
conformance-artifact-prefix: openshell-conformance
|
||||
|
||||
kubernetes-ha-e2e:
|
||||
needs: [pr_metadata, build-cli, build-gateway-image, build-supervisor-image]
|
||||
needs: [pr_metadata, build-cli, build-conformance, build-gateway-image, build-supervisor-image]
|
||||
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_kubernetes_ha_e2e == 'true'
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -412,9 +434,10 @@ 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
|
||||
conformance-artifact-prefix: openshell-conformance
|
||||
|
||||
kubernetes-credential-drivers-e2e:
|
||||
needs: [pr_metadata, build-cli, build-gateway-image, build-supervisor-image]
|
||||
needs: [pr_metadata, build-cli, build-conformance, build-gateway-image, build-supervisor-image]
|
||||
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_kubernetes_credential_drivers_e2e == 'true'
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -425,6 +448,7 @@ jobs:
|
||||
image-tag: ${{ github.sha }}
|
||||
job-name: Kubernetes Credential Drivers E2E
|
||||
e2e-task: e2e:kubernetes:credential-drivers
|
||||
conformance-artifact-prefix: openshell-conformance
|
||||
|
||||
core-e2e-result:
|
||||
name: Core E2E result
|
||||
|
||||
@@ -18,9 +18,6 @@ on:
|
||||
runner:
|
||||
required: true
|
||||
type: string
|
||||
dev-shell:
|
||||
required: true
|
||||
type: string
|
||||
cargo-version:
|
||||
required: true
|
||||
type: string
|
||||
@@ -36,10 +33,6 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
interpreter:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
checkout-ref:
|
||||
required: false
|
||||
type: string
|
||||
@@ -70,9 +63,7 @@ jobs:
|
||||
package: ${{ inputs.package }}
|
||||
binary: ${{ inputs.binary }}
|
||||
triple: ${{ inputs.triple }}
|
||||
dev-shell: ${{ inputs['dev-shell'] }}
|
||||
cargo-version: ${{ inputs['cargo-version'] }}
|
||||
image-tag: ${{ inputs['image-tag'] }}
|
||||
artifact-name: ${{ inputs['artifact-name'] }}
|
||||
extra-cargo-flags: ${{ inputs['extra-cargo-flags'] }}
|
||||
interpreter: ${{ inputs.interpreter }}
|
||||
|
||||
@@ -31,20 +31,16 @@ jobs:
|
||||
include:
|
||||
- triple: x86_64-unknown-linux-musl
|
||||
runner: linux-amd64-cpu8
|
||||
dev_shell: .#devShells.x86_64-linux.musl
|
||||
- triple: aarch64-unknown-linux-musl
|
||||
runner: linux-arm64-cpu8
|
||||
dev_shell: .#devShells.aarch64-linux.musl
|
||||
- triple: aarch64-apple-darwin
|
||||
runner: macos-15-xlarge
|
||||
dev_shell: .#devShells.aarch64-darwin.default
|
||||
uses: ./.github/workflows/build-binaries.yml
|
||||
with:
|
||||
package: openshell-cli
|
||||
binary: openshell
|
||||
triple: ${{ matrix.triple }}
|
||||
runner: ${{ matrix.runner }}
|
||||
dev-shell: ${{ matrix.dev_shell }}
|
||||
cargo-version: ${{ inputs.cargo-version }}
|
||||
image-tag: ${{ inputs.image-tag }}
|
||||
checkout-ref: ${{ inputs.checkout-ref }}
|
||||
|
||||
@@ -31,25 +31,17 @@ jobs:
|
||||
include:
|
||||
- triple: x86_64-unknown-linux-gnu
|
||||
runner: linux-amd64-cpu8
|
||||
dev_shell: .#devShells.x86_64-linux.glibc-2-28
|
||||
interpreter: /lib64/ld-linux-x86-64.so.2
|
||||
- triple: aarch64-unknown-linux-gnu
|
||||
runner: linux-arm64-cpu8
|
||||
dev_shell: .#devShells.aarch64-linux.glibc-2-28
|
||||
interpreter: /lib/ld-linux-aarch64.so.1
|
||||
- triple: aarch64-apple-darwin
|
||||
runner: macos-15-xlarge
|
||||
dev_shell: .#devShells.aarch64-darwin.default
|
||||
interpreter: ""
|
||||
uses: ./.github/workflows/build-binaries.yml
|
||||
with:
|
||||
package: openshell-server
|
||||
package: openshell-gateway
|
||||
binary: openshell-gateway
|
||||
triple: ${{ matrix.triple }}
|
||||
runner: ${{ matrix.runner }}
|
||||
dev-shell: ${{ matrix.dev_shell }}
|
||||
cargo-version: ${{ inputs.cargo-version }}
|
||||
image-tag: ${{ inputs.image-tag }}
|
||||
interpreter: ${{ matrix.interpreter }}
|
||||
checkout-ref: ${{ inputs.checkout-ref }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
name: Build RPM
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
checkout-ref:
|
||||
required: true
|
||||
type: string
|
||||
arch:
|
||||
required: true
|
||||
type: string
|
||||
runner:
|
||||
required: true
|
||||
type: string
|
||||
cli-target:
|
||||
required: true
|
||||
type: string
|
||||
gateway-target:
|
||||
required: true
|
||||
type: string
|
||||
rpm-version:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
rpm-release:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
cargo-version:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build RPM Package (Linux ${{ inputs.arch }})
|
||||
runs-on: ${{ inputs.runner }}
|
||||
timeout-minutes: 60
|
||||
container:
|
||||
image: docker.io/library/fedora:44@sha256:43b29f65a41eb9c35e1cd5323e3bdf3b655c2357a9f4f1ff2f9c2798e5045d80
|
||||
steps:
|
||||
- name: Install packaging dependencies
|
||||
run: |
|
||||
dnf install -y \
|
||||
packit rpm-build \
|
||||
cargo cargo-rpm-macros git-core \
|
||||
pandoc python3-devel systemd-rpm-macros
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cache Cargo dependencies
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
shared-key: rpm-vendor-${{ inputs.arch }}
|
||||
cache-targets: "false"
|
||||
cache-bin: "false"
|
||||
cache-on-failure: "true"
|
||||
|
||||
- name: Download CLI artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: openshell-${{ inputs.cli-target }}
|
||||
path: package-binaries/
|
||||
|
||||
- name: Download gateway artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: openshell-gateway-${{ inputs.gateway-target }}
|
||||
path: package-binaries/
|
||||
|
||||
- name: Configure package inputs
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x package-binaries/openshell{,-gateway}
|
||||
ls -lah package-binaries
|
||||
|
||||
- name: Mark workspace safe for git
|
||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
|
||||
- name: Fetch tags
|
||||
run: git fetch --tags --force
|
||||
|
||||
- name: Build RPMs via Packit
|
||||
env:
|
||||
OPENSHELL_RPM_VERSION: ${{ inputs.rpm-version }}
|
||||
OPENSHELL_RPM_RELEASE: ${{ inputs.rpm-release }}
|
||||
OPENSHELL_CARGO_VERSION: ${{ inputs.cargo-version }}
|
||||
OPENSHELL_PREBUILT_BINARIES_DIR: ${{ github.workspace }}/package-binaries
|
||||
run: packit build locally
|
||||
|
||||
- name: Collect RPM artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p artifacts
|
||||
mapfile -t rpms < <(find "$GITHUB_WORKSPACE" -maxdepth 3 -type f -name '*.rpm' ! -name '*.src.rpm' | sort)
|
||||
if [ "${#rpms[@]}" -eq 0 ]; then
|
||||
echo "::error::No RPM artifacts found under $GITHUB_WORKSPACE"
|
||||
find "$GITHUB_WORKSPACE" -maxdepth 3 -type f | sort
|
||||
exit 1
|
||||
fi
|
||||
cp "${rpms[@]}" artifacts/
|
||||
echo "=== Built RPMs ==="
|
||||
ls -lah artifacts/
|
||||
|
||||
- name: Upload RPM artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: rpm-linux-${{ inputs.arch }}
|
||||
path: artifacts/*.rpm
|
||||
retention-days: 5
|
||||
@@ -31,17 +31,14 @@ jobs:
|
||||
include:
|
||||
- triple: x86_64-unknown-linux-musl
|
||||
runner: linux-amd64-cpu8
|
||||
dev_shell: .#devShells.x86_64-linux.musl
|
||||
- triple: aarch64-unknown-linux-musl
|
||||
runner: linux-arm64-cpu8
|
||||
dev_shell: .#devShells.aarch64-linux.musl
|
||||
uses: ./.github/workflows/build-binaries.yml
|
||||
with:
|
||||
package: openshell-sandbox
|
||||
binary: openshell-sandbox
|
||||
triple: ${{ matrix.triple }}
|
||||
runner: ${{ matrix.runner }}
|
||||
dev-shell: ${{ matrix.dev_shell }}
|
||||
cargo-version: ${{ inputs.cargo-version }}
|
||||
image-tag: ${{ inputs.image-tag }}
|
||||
checkout-ref: ${{ inputs.checkout-ref }}
|
||||
|
||||
@@ -33,22 +33,17 @@ jobs:
|
||||
- arch: x86_64
|
||||
triple: x86_64-unknown-linux-gnu
|
||||
runner: linux-amd64-cpu8
|
||||
dev_shell: .#devShells.x86_64-linux.glibc-2-28
|
||||
interpreter: /lib64/ld-linux-x86-64.so.2
|
||||
- arch: aarch64
|
||||
triple: aarch64-unknown-linux-gnu
|
||||
runner: linux-arm64-cpu8
|
||||
dev_shell: .#devShells.aarch64-linux.glibc-2-28
|
||||
interpreter: /lib/ld-linux-aarch64.so.1
|
||||
- arch: aarch64
|
||||
triple: aarch64-apple-darwin
|
||||
runner: macos-15-xlarge
|
||||
dev_shell: .#devShells.aarch64-darwin.default
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 60
|
||||
defaults:
|
||||
run:
|
||||
shell: nix develop ${{ matrix.dev_shell }} -c bash -euo pipefail {0}
|
||||
shell: nix develop -c bash -euo pipefail {0}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -82,7 +77,5 @@ jobs:
|
||||
package: openshell-driver-vm
|
||||
binary: openshell-driver-vm
|
||||
triple: ${{ matrix.triple }}
|
||||
dev-shell: ${{ matrix.dev_shell }}
|
||||
cargo-version: ${{ inputs['cargo-version'] }}
|
||||
image-tag: ${{ inputs['image-tag'] }}
|
||||
interpreter: ${{ matrix.interpreter }}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
name: Codex Security Release Qualification
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*-pre.*"
|
||||
workflow_call:
|
||||
inputs:
|
||||
candidate_ref:
|
||||
description: Pre-release tag to scan (vMAJOR.MINOR.PATCH-pre.N)
|
||||
required: true
|
||||
type: string
|
||||
stable_ref:
|
||||
description: Optional previous stable tag override
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
allow_full_bootstrap:
|
||||
description: Allow a full scan when no previous stable tag exists
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
outputs:
|
||||
base_sha:
|
||||
description: Previous stable commit, empty for a full bootstrap scan
|
||||
value: ${{ jobs.analyze.outputs.base_sha }}
|
||||
candidate_sha:
|
||||
description: Qualified pre-release commit
|
||||
value: ${{ jobs.analyze.outputs.candidate_sha }}
|
||||
category:
|
||||
description: Code Scanning category for the release train
|
||||
value: ${{ jobs.analyze.outputs.category }}
|
||||
train:
|
||||
description: Stable version targeted by the pre-release train
|
||||
value: ${{ jobs.analyze.outputs.train }}
|
||||
secrets:
|
||||
CODEX_SECURITY_API_KEY:
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
candidate_ref:
|
||||
description: Pre-release tag to scan (vMAJOR.MINOR.PATCH-pre.N)
|
||||
required: true
|
||||
type: string
|
||||
stable_ref:
|
||||
description: Optional previous stable tag override
|
||||
required: false
|
||||
type: string
|
||||
upload_sarif:
|
||||
description: Upload manual-run results to Code Scanning
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
allow_full_bootstrap:
|
||||
description: Allow a full scan when no previous stable tag exists
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
concurrency:
|
||||
group: codex-security-release-qualification
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CODEX_SECURITY_MAX_CONCURRENT_THREADS: "8"
|
||||
CODEX_SECURITY_REASONING_EFFORT: medium
|
||||
NVIDIA_INFERENCE_BASE_URL: https://inference-api.nvidia.com/v1
|
||||
NVIDIA_INFERENCE_MODEL: openai/openai/gpt-5.6-sol
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Codex Security (${{ inputs.candidate_ref || github.ref_name }})
|
||||
# The agent executes no shell commands on the repository self-hosted runner,
|
||||
# so its preflight never scopes the diff and it seals no draft.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
outputs:
|
||||
base_sha: ${{ steps.range.outputs.base_sha }}
|
||||
candidate_sha: ${{ steps.range.outputs.candidate_sha }}
|
||||
category: ${{ steps.range.outputs.category }}
|
||||
train: ${{ steps.range.outputs.train }}
|
||||
steps:
|
||||
# Install the trusted scanner before repository-controlled files exist in
|
||||
# the workspace, and invoke it later through its absolute path.
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "26"
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
# Codex confines model-run commands with bubblewrap, which needs
|
||||
# unprivileged user namespaces. Ubuntu 24.04 restricts those through
|
||||
# AppArmor, so bubblewrap cannot set up the sandbox network namespace and
|
||||
# the scan agent executes nothing at all.
|
||||
- name: Allow unprivileged user namespaces
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
fi
|
||||
|
||||
- name: Install Codex Security
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm install \
|
||||
--prefix "$RUNNER_TEMP/codex-security" \
|
||||
--ignore-scripts \
|
||||
--no-audit \
|
||||
--no-fund \
|
||||
@openai/codex-security@0.1.24
|
||||
|
||||
- name: Verify Codex Security
|
||||
env:
|
||||
CODEX_SECURITY_BIN: ${{ runner.temp }}/codex-security/node_modules/.bin/codex-security
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -x "$CODEX_SECURITY_BIN"
|
||||
"$CODEX_SECURITY_BIN" --version
|
||||
|
||||
# The range resolver has to come from the workflow's own revision. A
|
||||
# scanned candidate predates it, and running the resolver from the
|
||||
# revision under scan would let that revision pick its own scan range.
|
||||
- name: Check out the workflow revision
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
path: workflow-revision
|
||||
sparse-checkout: tasks/scripts
|
||||
persist-credentials: false
|
||||
|
||||
- name: Stage the range resolver
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -d -m 700 "$RUNNER_TEMP/range-resolver"
|
||||
cp workflow-revision/tasks/scripts/release.py \
|
||||
workflow-revision/tasks/scripts/codex_security_range.py \
|
||||
"$RUNNER_TEMP/range-resolver/"
|
||||
rm -rf workflow-revision
|
||||
|
||||
- name: Check out the pre-release
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.candidate_ref || github.ref }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve release range
|
||||
id: range
|
||||
env:
|
||||
ALLOW_FULL_BOOTSTRAP: ${{ inputs.allow_full_bootstrap || false }}
|
||||
CANDIDATE_REF: ${{ inputs.candidate_ref || github.ref_name }}
|
||||
STABLE_REF: ${{ inputs.stable_ref || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git show-ref --verify --quiet refs/remotes/origin/main
|
||||
|
||||
args=(
|
||||
--candidate "$CANDIDATE_REF"
|
||||
--main-ref origin/main
|
||||
)
|
||||
if [ -n "$STABLE_REF" ]; then
|
||||
args+=(--stable "$STABLE_REF")
|
||||
fi
|
||||
if [ "$ALLOW_FULL_BOOTSTRAP" = "true" ]; then
|
||||
args+=(--allow-full-bootstrap)
|
||||
fi
|
||||
|
||||
python3 "$RUNNER_TEMP/range-resolver/codex_security_range.py" "${args[@]}"
|
||||
|
||||
- name: Scan changes since the previous stable
|
||||
env:
|
||||
BASE_SHA: ${{ steps.range.outputs.base_sha }}
|
||||
CODEX_SECURITY_BIN: ${{ runner.temp }}/codex-security/node_modules/.bin/codex-security
|
||||
CODEX_SECURITY_STATE_DIR: ${{ runner.temp }}/codex-security-state-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
HEAD_SHA: ${{ steps.range.outputs.candidate_sha }}
|
||||
NVIDIA_INFERENCE_API_KEY: ${{ secrets.CODEX_SECURITY_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.CODEX_SECURITY_API_KEY }}
|
||||
SCAN_DIR: ${{ runner.temp }}/codex-security-results-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
SCAN_SCOPE: ${{ steps.range.outputs.scan_scope }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -d -m 700 "$CODEX_SECURITY_STATE_DIR" "$SCAN_DIR"
|
||||
|
||||
target_args=()
|
||||
if [ "$SCAN_SCOPE" = "diff" ]; then
|
||||
target_args=(--diff "$BASE_SHA" --head "$HEAD_SHA")
|
||||
elif [ "$SCAN_SCOPE" != "full" ]; then
|
||||
echo "::error::Unsupported Codex Security scan scope: $SCAN_SCOPE"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
"$CODEX_SECURITY_BIN" scan . \
|
||||
"${target_args[@]}" \
|
||||
--auth api-key \
|
||||
--model "$NVIDIA_INFERENCE_MODEL" \
|
||||
--effort "$CODEX_SECURITY_REASONING_EFFORT" \
|
||||
--codex 'model_provider="nvidia"' \
|
||||
--codex 'model_providers.nvidia.name="NVIDIA Inference"' \
|
||||
--codex "model_providers.nvidia.base_url=\"$NVIDIA_INFERENCE_BASE_URL\"" \
|
||||
--codex 'model_providers.nvidia.env_key="NVIDIA_INFERENCE_API_KEY"' \
|
||||
--codex 'model_providers.nvidia.wire_api="responses"' \
|
||||
--codex 'model_providers.nvidia.supports_websockets=false' \
|
||||
--codex "features.multi_agent_v2.max_concurrent_threads_per_session=$CODEX_SECURITY_MAX_CONCURRENT_THREADS" \
|
||||
--codex 'approval_policy="never"' \
|
||||
--output-dir "$SCAN_DIR" \
|
||||
--headless > /dev/null
|
||||
|
||||
- name: Export SARIF
|
||||
env:
|
||||
CODEX_SECURITY_BIN: ${{ runner.temp }}/codex-security/node_modules/.bin/codex-security
|
||||
SARIF_FILE: ${{ runner.temp }}/codex-security.sarif
|
||||
SCAN_DIR: ${{ runner.temp }}/codex-security-results-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
"$CODEX_SECURITY_BIN" export "$SCAN_DIR" \
|
||||
--export-format sarif \
|
||||
--source-root "$GITHUB_WORKSPACE" \
|
||||
--output "$SARIF_FILE"
|
||||
|
||||
- name: Summarize findings
|
||||
env:
|
||||
BASE_TAG: ${{ steps.range.outputs.base_tag }}
|
||||
CANDIDATE_TAG: ${{ steps.range.outputs.candidate_tag }}
|
||||
COMMIT_COUNT: ${{ steps.range.outputs.commit_count }}
|
||||
SARIF_FILE: ${{ runner.temp }}/codex-security.sarif
|
||||
SCAN_SCOPE: ${{ steps.range.outputs.scan_scope }}
|
||||
TRAIN: ${{ steps.range.outputs.train }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
finding_count=$(jq '[.runs[]?.results[]?] | length' "$SARIF_FILE")
|
||||
{
|
||||
echo "### Codex Security release qualification"
|
||||
echo
|
||||
echo "- Train: \`$TRAIN\`"
|
||||
echo "- Candidate: \`$CANDIDATE_TAG\`"
|
||||
echo "- Maximum concurrent agent threads: $CODEX_SECURITY_MAX_CONCURRENT_THREADS"
|
||||
if [ "$SCAN_SCOPE" = "diff" ]; then
|
||||
echo "- Previous stable: \`$BASE_TAG\`"
|
||||
echo "- Commits in cumulative diff: $COMMIT_COUNT"
|
||||
else
|
||||
echo "- Scope: approved full bootstrap scan"
|
||||
fi
|
||||
echo "- Coverage: complete"
|
||||
echo "- Findings: $finding_count"
|
||||
echo
|
||||
echo "Findings are informational during the observation phase."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload SARIF to Code Scanning
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_sarif }}
|
||||
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
sarif_file: ${{ runner.temp }}/codex-security.sarif
|
||||
ref: refs/heads/main
|
||||
sha: ${{ steps.range.outputs.candidate_sha }}
|
||||
category: ${{ steps.range.outputs.category }}
|
||||
|
||||
result:
|
||||
name: OpenShell / Codex Security (informational)
|
||||
if: ${{ always() }}
|
||||
needs: analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Evaluate scanner execution
|
||||
env:
|
||||
ANALYZE_RESULT: ${{ needs.analyze.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$ANALYZE_RESULT" != "success" ]; then
|
||||
echo "::error::Codex Security release qualification did not complete successfully."
|
||||
exit 1
|
||||
fi
|
||||
echo "Codex Security completed; findings remain informational."
|
||||
@@ -0,0 +1,179 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
name: Conformance
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pr_metadata:
|
||||
name: Resolve PR metadata
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
should_run: ${{ steps.gate.outputs.should_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- id: gate
|
||||
uses: ./.github/actions/pr-gate
|
||||
|
||||
version:
|
||||
needs: pr_metadata
|
||||
if: needs.pr_metadata.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
cargo: ${{ steps.version.outputs.cargo }}
|
||||
rpm_version: ${{ steps.version.outputs.rpm_version }}
|
||||
rpm_release: ${{ steps.version.outputs.rpm_release }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute versions
|
||||
id: version
|
||||
run: |
|
||||
cargo="$(python3 tasks/scripts/release.py get-version --cargo)"
|
||||
rpm_version="$(python3 tasks/scripts/release.py get-version --rpm-version)"
|
||||
rpm_release="$(python3 tasks/scripts/release.py get-version --rpm-release)"
|
||||
{
|
||||
echo "cargo=$cargo"
|
||||
echo "rpm_version=$rpm_version"
|
||||
echo "rpm_release=$rpm_release"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-cli:
|
||||
needs: version
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/build-binaries.yml
|
||||
with:
|
||||
package: openshell-cli
|
||||
binary: openshell
|
||||
triple: x86_64-unknown-linux-musl
|
||||
runner: linux-amd64-cpu8
|
||||
cargo-version: ${{ needs.version.outputs.cargo }}
|
||||
checkout-ref: ${{ github.sha }}
|
||||
secrets: inherit
|
||||
|
||||
build-conformance:
|
||||
needs: version
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/build-binaries.yml
|
||||
with:
|
||||
package: openshell-conformance-cli
|
||||
binary: openshell-conformance
|
||||
triple: x86_64-unknown-linux-musl
|
||||
runner: linux-amd64-cpu8
|
||||
cargo-version: ${{ needs.version.outputs.cargo }}
|
||||
checkout-ref: ${{ github.sha }}
|
||||
secrets: inherit
|
||||
|
||||
build-gateway:
|
||||
needs: version
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/build-binaries.yml
|
||||
with:
|
||||
package: openshell-gateway
|
||||
binary: openshell-gateway
|
||||
triple: x86_64-unknown-linux-gnu
|
||||
runner: linux-amd64-cpu8
|
||||
cargo-version: ${{ needs.version.outputs.cargo }}
|
||||
image-tag: dev
|
||||
checkout-ref: ${{ github.sha }}
|
||||
secrets: inherit
|
||||
|
||||
build-rpm:
|
||||
needs: [version, build-cli, build-gateway]
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/build-rpm.yml
|
||||
with:
|
||||
checkout-ref: ${{ github.sha }}
|
||||
arch: x86_64
|
||||
runner: linux-amd64-cpu8
|
||||
cli-target: x86_64-unknown-linux-musl
|
||||
gateway-target: x86_64-unknown-linux-gnu
|
||||
cargo-version: ${{ needs.version.outputs.cargo }}
|
||||
rpm-version: ${{ needs.version.outputs.rpm_version }}
|
||||
rpm-release: ${{ needs.version.outputs.rpm_release }}
|
||||
|
||||
fedora:
|
||||
name: Fedora with Rootless Podman
|
||||
needs: [build-conformance, build-rpm]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Enable KVM access
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! -c /dev/kvm ]]; then
|
||||
echo "::error::The runner did not expose /dev/kvm"
|
||||
exit 1
|
||||
fi
|
||||
sudo chmod 0666 /dev/kvm
|
||||
exec 3<>/dev/kvm
|
||||
exec 3>&-
|
||||
|
||||
- uses: ./.github/actions/setup-nix
|
||||
|
||||
- name: Download RPM artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rpm-linux-x86_64
|
||||
path: rpm-input
|
||||
|
||||
- name: Download conformance CLI
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: openshell-conformance-x86_64-unknown-linux-musl
|
||||
path: conformance-input
|
||||
|
||||
- name: Run RPM gateway continuity conformance
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x conformance-input/openshell-conformance
|
||||
shopt -s nullglob
|
||||
candidate_cli_package=(rpm-input/openshell-[0-9]*.rpm)
|
||||
candidate_gateway_package=(rpm-input/openshell-gateway-[0-9]*.rpm)
|
||||
if [[ ${#candidate_cli_package[@]} -ne 1 || ${#candidate_gateway_package[@]} -ne 1 ]]; then
|
||||
echo "expected one candidate CLI and gateway RPM" >&2
|
||||
printf 'RPM artifacts:\n' >&2
|
||||
printf ' %s\n' rpm-input/* >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OPENSHELL_TEST_GUEST_CACHE_DISABLE=1 nix run .#test-guest -- \
|
||||
--distro fedora \
|
||||
--with podman-rootless \
|
||||
--with selinux \
|
||||
--copy "${candidate_cli_package[0]}:/var/lib/openshell-conformance/candidate/openshell.rpm" \
|
||||
--copy "${candidate_gateway_package[0]}:/var/lib/openshell-conformance/candidate/openshell-gateway.rpm" \
|
||||
--copy conformance-input/openshell-conformance:/tmp/openshell-conformance \
|
||||
--copy nix/test-guest/conformance-plans/gateway-upgrade-restart.toml:/tmp/conformance-plan.toml \
|
||||
--provision openshell-rpm-latest-release \
|
||||
--provision gateway-podman \
|
||||
--provision openshell-rpm-gateway-upgrade \
|
||||
-- /tmp/openshell-conformance run --plan /tmp/conformance-plan.toml
|
||||
@@ -25,6 +25,10 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
conformance-artifact-prefix:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
suite-matrix:
|
||||
required: false
|
||||
type: string
|
||||
@@ -66,6 +70,8 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/setup-e2e-cli
|
||||
with:
|
||||
conformance-artifact-prefix: ${{ inputs.conformance-artifact-prefix }}
|
||||
|
||||
- uses: ./.github/actions/setup-e2e-gateway
|
||||
with:
|
||||
|
||||
@@ -7,6 +7,11 @@ on:
|
||||
description: "Image tag to test (typically the commit SHA)"
|
||||
required: true
|
||||
type: string
|
||||
conformance-artifact-prefix:
|
||||
description: "Optional prebuilt conformance CLI artifact prefix (artifact suffix is <triple>)"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -61,6 +66,8 @@ jobs:
|
||||
|
||||
- name: Use prebuilt OpenShell CLI
|
||||
uses: ./.github/actions/setup-e2e-cli
|
||||
with:
|
||||
conformance-artifact-prefix: ${{ inputs.conformance-artifact-prefix }}
|
||||
|
||||
- name: Use prebuilt OpenShell gateway
|
||||
uses: ./.github/actions/setup-e2e-gateway
|
||||
|
||||
@@ -42,6 +42,16 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: "e2e:kubernetes"
|
||||
test-name:
|
||||
description: "Rust e2e test target to run (sets OPENSHELL_E2E_KUBE_TEST)"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
kubernetes-features:
|
||||
description: "Cargo feature list for the Kubernetes e2e crate (uses script defaults when empty)"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
mise-version:
|
||||
description: "mise version to install on the bare Kubernetes e2e runner"
|
||||
required: false
|
||||
@@ -52,6 +62,11 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
conformance-artifact-prefix:
|
||||
description: "Optional prebuilt conformance CLI artifact prefix (artifact suffix is <triple>)"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
external-driver-binary:
|
||||
description: "Optional standalone compute driver binary used to compose a local test image"
|
||||
required: false
|
||||
@@ -92,6 +107,8 @@ jobs:
|
||||
|
||||
- name: Use prebuilt OpenShell CLI
|
||||
uses: ./.github/actions/setup-e2e-cli
|
||||
with:
|
||||
conformance-artifact-prefix: ${{ inputs.conformance-artifact-prefix }}
|
||||
|
||||
- name: Use prebuilt OpenShell gateway
|
||||
if: inputs.gateway-artifact != ''
|
||||
@@ -121,6 +138,8 @@ jobs:
|
||||
OPENSHELL_E2E_KUBE_CONTEXT: kind-${{ env.KIND_CLUSTER_NAME }}
|
||||
OPENSHELL_E2E_KUBE_EXTRA_VALUES: ${{ inputs.extra-helm-values }}
|
||||
OPENSHELL_E2E_KUBE_EXTERNAL_POSTGRES_SECRET: ${{ inputs.external-postgres-secret }}
|
||||
OPENSHELL_E2E_KUBE_TEST: ${{ inputs.test-name }}
|
||||
OPENSHELL_E2E_KUBERNETES_FEATURES: ${{ inputs.kubernetes-features }}
|
||||
IMAGE_TAG: ${{ inputs.image-tag }}
|
||||
OPENSHELL_REGISTRY: ghcr.io/nvidia/openshell
|
||||
E2E_TASK: ${{ inputs.e2e-task }}
|
||||
|
||||
@@ -21,6 +21,10 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
conformance-artifact-prefix:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
suite-matrix:
|
||||
required: false
|
||||
type: string
|
||||
@@ -53,6 +57,8 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/setup-e2e-cli
|
||||
with:
|
||||
conformance-artifact-prefix: ${{ inputs.conformance-artifact-prefix }}
|
||||
|
||||
- uses: ./.github/actions/setup-e2e-gateway
|
||||
with:
|
||||
|
||||
@@ -22,6 +22,10 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: e2e:vm
|
||||
conformance-artifact-prefix:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -43,6 +47,8 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/setup-e2e-cli
|
||||
with:
|
||||
conformance-artifact-prefix: ${{ inputs.conformance-artifact-prefix }}
|
||||
|
||||
- uses: ./.github/actions/setup-e2e-gateway
|
||||
with:
|
||||
|
||||
@@ -25,6 +25,7 @@ permissions:
|
||||
concurrency:
|
||||
group: docs-website
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
defaults:
|
||||
run:
|
||||
|
||||
@@ -2,6 +2,11 @@ name: Release Canary
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release-dev-run-id:
|
||||
description: "Successful Release Dev run ID whose Snap artifact to test"
|
||||
required: false
|
||||
type: string
|
||||
workflow_run:
|
||||
workflows: ["Release Dev"]
|
||||
types: [completed]
|
||||
@@ -154,7 +159,7 @@ jobs:
|
||||
|
||||
ubuntu-snap:
|
||||
name: Ubuntu Snap
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' || inputs.release-dev-run-id != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -176,7 +181,7 @@ jobs:
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
run-id: ${{ inputs.release-dev-run-id || github.event.workflow_run.id }}
|
||||
pattern: snap-linux-amd64
|
||||
path: release/
|
||||
merge-multiple: true
|
||||
@@ -202,7 +207,27 @@ jobs:
|
||||
sudo snap services openshell
|
||||
openshell gateway add http://127.0.0.1:17670 --local --name snap-docker
|
||||
openshell gateway select snap-docker
|
||||
openshell status
|
||||
for _ in $(seq 1 30); do
|
||||
if openshell status; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Gateway did not become ready within 30 seconds" >&2
|
||||
exit 1
|
||||
|
||||
- name: Collect Snap diagnostics
|
||||
if: failure()
|
||||
run: |
|
||||
set +e
|
||||
sudo snap services openshell
|
||||
sudo snap connections openshell
|
||||
sudo snap changes
|
||||
sudo systemctl status snap.openshell.gateway.service --no-pager
|
||||
sudo journalctl -b -u snap.openshell.gateway.service --no-pager -n 300
|
||||
sudo journalctl -b -u snapd.service --no-pager -n 300
|
||||
sudo snap logs openshell.gateway -n=300
|
||||
sudo ss -ltnp '( sport = :17670 )'
|
||||
|
||||
kubernetes:
|
||||
name: Kubernetes Helm (kind)
|
||||
|
||||
@@ -28,6 +28,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
outputs:
|
||||
python_version: ${{ steps.v.outputs.python }}
|
||||
docs_version: ${{ steps.v.outputs.docs_version }}
|
||||
cargo_version: ${{ steps.v.outputs.cargo }}
|
||||
deb_version: ${{ steps.v.outputs.deb }}
|
||||
rpm_version: ${{ steps.v.outputs.rpm_version }}
|
||||
@@ -47,11 +48,15 @@ jobs:
|
||||
id: v
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "python=$(uv run python tasks/scripts/release.py get-version --python)" >> "$GITHUB_OUTPUT"
|
||||
echo "cargo=$(uv run python tasks/scripts/release.py get-version --cargo)" >> "$GITHUB_OUTPUT"
|
||||
echo "deb=$(uv run python tasks/scripts/release.py get-version --deb)" >> "$GITHUB_OUTPUT"
|
||||
echo "rpm_version=$(uv run python tasks/scripts/release.py get-version --rpm-version)" >> "$GITHUB_OUTPUT"
|
||||
echo "rpm_release=$(uv run python tasks/scripts/release.py get-version --rpm-release)" >> "$GITHUB_OUTPUT"
|
||||
python_version=$(uv run python tasks/scripts/release.py get-version --dev --python)
|
||||
{
|
||||
echo "python=${python_version}"
|
||||
echo "docs_version=${python_version%%+*}"
|
||||
echo "cargo=$(uv run python tasks/scripts/release.py get-version --dev --cargo)"
|
||||
echo "deb=$(uv run python tasks/scripts/release.py get-version --dev --deb)"
|
||||
echo "rpm_version=$(uv run python tasks/scripts/release.py get-version --dev --rpm-version)"
|
||||
echo "rpm_release=$(uv run python tasks/scripts/release.py get-version --dev --rpm-release)"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-cli:
|
||||
needs: compute-versions
|
||||
@@ -63,6 +68,27 @@ jobs:
|
||||
image-tag: dev
|
||||
secrets: inherit
|
||||
|
||||
build-conformance:
|
||||
needs: compute-versions
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- triple: x86_64-unknown-linux-musl
|
||||
runner: linux-amd64-cpu8
|
||||
- triple: aarch64-unknown-linux-musl
|
||||
runner: linux-arm64-cpu8
|
||||
uses: ./.github/workflows/build-binaries.yml
|
||||
with:
|
||||
package: openshell-conformance-cli
|
||||
binary: openshell-conformance
|
||||
artifact-name: openshell-conformance-${{ matrix.triple }}
|
||||
triple: ${{ matrix.triple }}
|
||||
runner: ${{ matrix.runner }}
|
||||
cargo-version: ${{ needs.compute-versions.outputs.cargo_version }}
|
||||
secrets: inherit
|
||||
|
||||
build-gateway:
|
||||
needs: compute-versions
|
||||
permissions:
|
||||
@@ -125,7 +151,7 @@ jobs:
|
||||
secrets: inherit
|
||||
|
||||
docker-e2e:
|
||||
needs: [build-cli, build-gateway, build-supervisor-image]
|
||||
needs: [build-cli, build-conformance, build-gateway, build-supervisor-image]
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
@@ -134,9 +160,10 @@ jobs:
|
||||
with:
|
||||
image-tag: ${{ github.sha }}
|
||||
runner: linux-arm64-cpu8
|
||||
conformance-artifact-prefix: openshell-conformance
|
||||
|
||||
podman-e2e:
|
||||
needs: [build-cli, build-gateway, build-supervisor-image]
|
||||
needs: [build-cli, build-conformance, build-gateway, build-supervisor-image]
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
@@ -144,14 +171,17 @@ jobs:
|
||||
uses: ./.github/workflows/e2e-podman-test.yml
|
||||
with:
|
||||
image-tag: ${{ github.sha }}
|
||||
conformance-artifact-prefix: openshell-conformance
|
||||
|
||||
vm-e2e:
|
||||
needs: [build-cli, build-gateway, build-vm-driver]
|
||||
needs: [build-cli, build-conformance, build-gateway, build-vm-driver]
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
packages: read
|
||||
uses: ./.github/workflows/e2e-vm-test.yml
|
||||
with:
|
||||
conformance-artifact-prefix: openshell-conformance
|
||||
|
||||
tag-ghcr-dev:
|
||||
name: Tag GHCR Images as Dev
|
||||
@@ -600,6 +630,22 @@ jobs:
|
||||
release-kind: dev
|
||||
pin-sha: ${{ github.sha }}
|
||||
|
||||
publish-fern-docs:
|
||||
name: Sync and Publish Fern Docs
|
||||
needs: [compute-versions, release-dev, release-helm, trigger-wheel-publish]
|
||||
permissions:
|
||||
contents: write
|
||||
uses: ./.github/workflows/sync-docs.yml
|
||||
with:
|
||||
operation: sync
|
||||
channel: dev
|
||||
source_ref: ${{ github.sha }}
|
||||
release_version: ${{ needs.compute-versions.outputs.docs_version }}
|
||||
display_name: Dev
|
||||
availability: beta
|
||||
publish: true
|
||||
secrets: inherit
|
||||
|
||||
trigger-wheel-publish:
|
||||
name: Trigger Wheel Publish
|
||||
needs: [compute-versions, release-dev]
|
||||
|
||||
@@ -48,6 +48,7 @@ jobs:
|
||||
semver: ${{ steps.v.outputs.semver }}
|
||||
# Commit resolved from RELEASE_TAG, used for image tags and downstream metadata
|
||||
source_sha: ${{ steps.v.outputs.source_sha }}
|
||||
is_prerelease: ${{ steps.v.outputs.is_prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -64,14 +65,25 @@ jobs:
|
||||
id: v
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "python=$(uv run python tasks/scripts/release.py get-version --python)" >> "$GITHUB_OUTPUT"
|
||||
echo "cargo=$(uv run python tasks/scripts/release.py get-version --cargo)" >> "$GITHUB_OUTPUT"
|
||||
echo "npm=$(uv run python tasks/scripts/release.py get-version --npm)" >> "$GITHUB_OUTPUT"
|
||||
echo "deb=$(uv run python tasks/scripts/release.py get-version --deb)" >> "$GITHUB_OUTPUT"
|
||||
echo "rpm_version=$(uv run python tasks/scripts/release.py get-version --rpm-version)" >> "$GITHUB_OUTPUT"
|
||||
echo "rpm_release=$(uv run python tasks/scripts/release.py get-version --rpm-release)" >> "$GITHUB_OUTPUT"
|
||||
echo "semver=${RELEASE_TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
echo "source_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
if [[ "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-pre\.[1-9][0-9]*$ ]]; then
|
||||
is_prerelease=true
|
||||
elif [[ "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
is_prerelease=false
|
||||
else
|
||||
echo "Unsupported release tag: ${RELEASE_TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo "is_prerelease=${is_prerelease}"
|
||||
echo "python=$(uv run python tasks/scripts/release.py get-version --python)"
|
||||
echo "cargo=$(uv run python tasks/scripts/release.py get-version --cargo)"
|
||||
echo "npm=$(uv run python tasks/scripts/release.py get-version --npm)"
|
||||
echo "deb=$(uv run python tasks/scripts/release.py get-version --deb)"
|
||||
echo "rpm_version=$(uv run python tasks/scripts/release.py get-version --rpm-version)"
|
||||
echo "rpm_release=$(uv run python tasks/scripts/release.py get-version --rpm-release)"
|
||||
echo "semver=${RELEASE_TAG#v}"
|
||||
echo "source_sha=$(git rev-parse HEAD)"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-cli:
|
||||
needs: compute-versions
|
||||
@@ -84,6 +96,28 @@ jobs:
|
||||
checkout-ref: ${{ inputs.tag || github.ref }}
|
||||
secrets: inherit
|
||||
|
||||
build-conformance:
|
||||
needs: compute-versions
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- triple: x86_64-unknown-linux-musl
|
||||
runner: linux-amd64-cpu8
|
||||
- triple: aarch64-unknown-linux-musl
|
||||
runner: linux-arm64-cpu8
|
||||
uses: ./.github/workflows/build-binaries.yml
|
||||
with:
|
||||
package: openshell-conformance-cli
|
||||
binary: openshell-conformance
|
||||
artifact-name: openshell-conformance-${{ matrix.triple }}
|
||||
triple: ${{ matrix.triple }}
|
||||
runner: ${{ matrix.runner }}
|
||||
cargo-version: ${{ needs.compute-versions.outputs.cargo_version }}
|
||||
checkout-ref: ${{ inputs.tag || github.ref }}
|
||||
secrets: inherit
|
||||
|
||||
build-gateway:
|
||||
needs: compute-versions
|
||||
permissions:
|
||||
@@ -153,7 +187,7 @@ jobs:
|
||||
secrets: inherit
|
||||
|
||||
docker-e2e:
|
||||
needs: [compute-versions, build-cli, build-gateway, build-supervisor-image]
|
||||
needs: [compute-versions, build-cli, build-conformance, build-gateway, build-supervisor-image]
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
@@ -163,9 +197,10 @@ jobs:
|
||||
image-tag: ${{ needs.compute-versions.outputs.source_sha }}
|
||||
checkout-ref: ${{ inputs.tag || github.ref }}
|
||||
runner: linux-arm64-cpu8
|
||||
conformance-artifact-prefix: openshell-conformance
|
||||
|
||||
podman-e2e:
|
||||
needs: [compute-versions, build-cli, build-gateway, build-supervisor-image]
|
||||
needs: [compute-versions, build-cli, build-conformance, build-gateway, build-supervisor-image]
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
@@ -174,9 +209,10 @@ jobs:
|
||||
with:
|
||||
image-tag: ${{ needs.compute-versions.outputs.source_sha }}
|
||||
checkout-ref: ${{ inputs.tag || github.ref }}
|
||||
conformance-artifact-prefix: openshell-conformance
|
||||
|
||||
vm-e2e:
|
||||
needs: [compute-versions, build-cli, build-gateway, build-vm-driver]
|
||||
needs: [compute-versions, build-cli, build-conformance, build-gateway, build-vm-driver]
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
@@ -184,9 +220,10 @@ jobs:
|
||||
uses: ./.github/workflows/e2e-vm-test.yml
|
||||
with:
|
||||
checkout-ref: ${{ inputs.tag || github.ref }}
|
||||
conformance-artifact-prefix: openshell-conformance
|
||||
|
||||
tag-ghcr-release:
|
||||
name: Tag GHCR Images for Release
|
||||
name: Tag GHCR Images
|
||||
needs: [compute-versions, build-gateway-image, build-supervisor-image, release]
|
||||
runs-on: linux-amd64-cpu8
|
||||
timeout-minutes: 10
|
||||
@@ -194,22 +231,27 @@ jobs:
|
||||
- name: Log in to GHCR
|
||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin
|
||||
|
||||
- name: Tag images with version and latest
|
||||
- name: Tag images with release version
|
||||
env:
|
||||
IS_PRERELEASE: ${{ needs.compute-versions.outputs.is_prerelease }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REGISTRY="ghcr.io/nvidia/openshell"
|
||||
VERSION="${{ needs.compute-versions.outputs.semver }}"
|
||||
SOURCE_TAG="${{ needs.compute-versions.outputs.source_sha }}"
|
||||
for component in gateway supervisor; do
|
||||
echo "Tagging ${REGISTRY}/${component}:${SOURCE_TAG} as ${VERSION} and latest..."
|
||||
echo "Tagging ${REGISTRY}/${component}:${SOURCE_TAG} as ${VERSION}..."
|
||||
docker buildx imagetools create \
|
||||
--prefer-index=false \
|
||||
-t "${REGISTRY}/${component}:${VERSION}" \
|
||||
"${REGISTRY}/${component}:${SOURCE_TAG}"
|
||||
docker buildx imagetools create \
|
||||
--prefer-index=false \
|
||||
-t "${REGISTRY}/${component}:latest" \
|
||||
"${REGISTRY}/${component}:${SOURCE_TAG}"
|
||||
if [[ "${IS_PRERELEASE}" != "true" ]]; then
|
||||
echo "Tagging ${REGISTRY}/${component}:${SOURCE_TAG} as latest..."
|
||||
docker buildx imagetools create \
|
||||
--prefer-index=false \
|
||||
-t "${REGISTRY}/${component}:latest" \
|
||||
"${REGISTRY}/${component}:${SOURCE_TAG}"
|
||||
fi
|
||||
done
|
||||
|
||||
build-python-wheel:
|
||||
@@ -265,8 +307,9 @@ jobs:
|
||||
uses: ./.github/workflows/snap-package.yml
|
||||
with:
|
||||
checkout-ref: ${{ inputs.tag || github.ref }}
|
||||
upload-channel: latest/stable
|
||||
github-environment: latest/stable
|
||||
upload-channel: ${{ needs.compute-versions.outputs.is_prerelease == 'true' && 'latest/edge' || 'latest/stable' }}
|
||||
github-environment: ${{ needs.compute-versions.outputs.is_prerelease == 'true' && 'latest/edge' || 'latest/stable' }}
|
||||
publish: ${{ needs.compute-versions.outputs.is_prerelease != 'true' }}
|
||||
secrets:
|
||||
publish-credentials: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
|
||||
|
||||
@@ -529,6 +572,7 @@ jobs:
|
||||
cat openshell-sandbox-checksums-sha256.txt
|
||||
|
||||
- name: Generate Homebrew formula
|
||||
if: needs.compute-versions.outputs.is_prerelease != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 tasks/scripts/release.py generate-homebrew-formula \
|
||||
@@ -547,6 +591,7 @@ jobs:
|
||||
release/*.whl
|
||||
|
||||
- name: Prune removed VM checksum asset
|
||||
if: needs.compute-versions.outputs.is_prerelease != 'true'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
@@ -569,6 +614,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: needs.compute-versions.outputs.is_prerelease != 'true'
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
with:
|
||||
name: OpenShell ${{ env.RELEASE_TAG }}
|
||||
@@ -605,35 +651,35 @@ jobs:
|
||||
release/openshell-gateway-checksums-sha256.txt
|
||||
release/openshell-sandbox-checksums-sha256.txt
|
||||
|
||||
- name: Upload pre-release artifacts
|
||||
if: needs.compute-versions.outputs.is_prerelease == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: openshell-${{ env.RELEASE_TAG }}
|
||||
path: release/
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
|
||||
publish-fern-docs:
|
||||
name: Publish Fern Docs
|
||||
needs: [release]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Install Fern CLI
|
||||
run: |
|
||||
FERN_VERSION=$(node -p "require('./fern/fern.config.json').version")
|
||||
npm install -g "fern-api@${FERN_VERSION}"
|
||||
|
||||
- name: Publish Fern docs
|
||||
env:
|
||||
FERN_TOKEN: ${{ secrets.FERN_TOKEN }}
|
||||
working-directory: ./fern
|
||||
run: fern generate --docs
|
||||
name: Sync and Publish Fern Docs
|
||||
needs: [compute-versions, release, publish-sdk-typescript, release-helm, trigger-wheel-publish]
|
||||
if: needs.compute-versions.outputs.is_prerelease != 'true'
|
||||
permissions:
|
||||
contents: write
|
||||
uses: ./.github/workflows/sync-docs.yml
|
||||
with:
|
||||
operation: sync
|
||||
channel: latest
|
||||
source_ref: ${{ needs.compute-versions.outputs.source_sha }}
|
||||
release_version: ${{ needs.compute-versions.outputs.semver }}
|
||||
display_name: Latest (v${{ needs.compute-versions.outputs.semver }})
|
||||
publish: true
|
||||
secrets: inherit
|
||||
|
||||
publish-sdk-typescript:
|
||||
name: Publish TypeScript SDK
|
||||
needs: [compute-versions, release]
|
||||
if: needs.compute-versions.outputs.is_prerelease != 'true'
|
||||
runs-on: linux-amd64-cpu8
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
@@ -690,6 +736,7 @@ jobs:
|
||||
trigger-wheel-publish:
|
||||
name: Trigger Wheel Publish
|
||||
needs: [compute-versions, release]
|
||||
if: needs.compute-versions.outputs.is_prerelease != 'true'
|
||||
runs-on: [self-hosted, nv]
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
|
||||
@@ -37,83 +37,20 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- arch: x86_64
|
||||
artifact_arch: amd64
|
||||
runner: linux-amd64-cpu8
|
||||
cli_target: x86_64-unknown-linux-musl
|
||||
gnu_target: x86_64-unknown-linux-gnu
|
||||
gateway_target: x86_64-unknown-linux-gnu
|
||||
- arch: aarch64
|
||||
artifact_arch: arm64
|
||||
runner: linux-arm64-cpu8
|
||||
cli_target: aarch64-unknown-linux-musl
|
||||
gnu_target: aarch64-unknown-linux-gnu
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 60
|
||||
container:
|
||||
image: fedora:latest
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
dnf install -y \
|
||||
packit rpm-build \
|
||||
rust cargo gcc gcc-c++ make cmake pkg-config \
|
||||
clang-devel z3-devel systemd-rpm-macros \
|
||||
pandoc python3-devel git-core \
|
||||
cargo-rpm-macros
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download CLI artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: openshell-${{ matrix.cli_target }}
|
||||
path: package-binaries/
|
||||
|
||||
- name: Download gateway artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: openshell-gateway-${{ matrix.gnu_target }}
|
||||
path: package-binaries/
|
||||
|
||||
- name: Configure package inputs
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x package-binaries/openshell{,-gateway}
|
||||
ls -lah package-binaries
|
||||
|
||||
- name: Mark workspace safe for git
|
||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
|
||||
- name: Fetch tags
|
||||
run: git fetch --tags --force
|
||||
|
||||
- name: Build RPMs via Packit
|
||||
env:
|
||||
OPENSHELL_RPM_VERSION: ${{ inputs['rpm-version'] }}
|
||||
OPENSHELL_RPM_RELEASE: ${{ inputs['rpm-release'] }}
|
||||
OPENSHELL_CARGO_VERSION: ${{ inputs['cargo-version'] }}
|
||||
OPENSHELL_PREBUILT_BINARIES_DIR: ${{ github.workspace }}/package-binaries
|
||||
run: packit build locally
|
||||
|
||||
- name: Collect RPM artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p artifacts
|
||||
mapfile -t rpms < <(find "$GITHUB_WORKSPACE" -maxdepth 3 -type f -name '*.rpm' ! -name '*.src.rpm' | sort)
|
||||
if [ "${#rpms[@]}" -eq 0 ]; then
|
||||
echo "::error::No RPM artifacts found under $GITHUB_WORKSPACE"
|
||||
find "$GITHUB_WORKSPACE" -maxdepth 3 -type f | sort
|
||||
exit 1
|
||||
fi
|
||||
cp "${rpms[@]}" artifacts/
|
||||
echo "=== Built RPMs ==="
|
||||
ls -lah artifacts/
|
||||
|
||||
- name: Upload RPM artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: rpm-linux-${{ matrix.arch }}
|
||||
path: artifacts/*.rpm
|
||||
retention-days: 5
|
||||
gateway_target: aarch64-unknown-linux-gnu
|
||||
uses: ./.github/workflows/build-rpm.yml
|
||||
with:
|
||||
checkout-ref: ${{ inputs.checkout-ref }}
|
||||
arch: ${{ matrix.arch }}
|
||||
runner: ${{ matrix.runner }}
|
||||
cli-target: ${{ matrix.cli_target }}
|
||||
gateway-target: ${{ matrix.gateway_target }}
|
||||
rpm-version: ${{ inputs.rpm-version }}
|
||||
rpm-release: ${{ inputs.rpm-release }}
|
||||
cargo-version: ${{ inputs.cargo-version }}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
name: Rust Cache Seed
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: "0"
|
||||
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
name: Rust (${{ matrix.runner }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
runner: [linux-amd64-cpu8, linux-arm64-cpu8]
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
SCCACHE_GHA_VERSION: branch-checks-rust-${{ matrix.runner }}
|
||||
container:
|
||||
image: ghcr.io/nvidia/openshell/ci:latest
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install tools
|
||||
run: mise install --locked
|
||||
|
||||
- name: Configure GHA sccache backend
|
||||
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
|
||||
|
||||
- name: Cache Rust target and registry
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
shared-key: rust-checks-${{ matrix.runner }}
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Format
|
||||
run: mise run rust:format:check
|
||||
|
||||
- name: Lint
|
||||
run: mise run rust:lint
|
||||
|
||||
- name: Test
|
||||
run: mise run test:rust
|
||||
|
||||
- name: sccache stats
|
||||
if: always()
|
||||
run: |
|
||||
set +e
|
||||
stats_bin="${SCCACHE_PATH:-sccache}"
|
||||
"$stats_bin" --show-stats
|
||||
status=$?
|
||||
if [ "$status" -ne 0 ]; then
|
||||
echo "::warning::sccache stats unavailable (exit $status)"
|
||||
fi
|
||||
exit 0
|
||||
@@ -17,6 +17,11 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
description: "GitHub deployment environment for approval gates (e.g., latest/edge, latest/stable)"
|
||||
publish:
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
description: "Whether to upload the built snap to the Snap Store"
|
||||
|
||||
secrets:
|
||||
publish-credentials:
|
||||
@@ -161,6 +166,7 @@ jobs:
|
||||
retention-days: 5
|
||||
|
||||
- name: Upload snap to Snap Store
|
||||
if: inputs.publish
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.publish-credentials }}
|
||||
INPUTS_UPLOAD_CHANNEL: ${{ inputs.upload-channel }}
|
||||
|
||||
@@ -4,6 +4,49 @@
|
||||
name: Sync Docs Website
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
operation:
|
||||
description: "Whether to sync or remove a docs snapshot"
|
||||
required: true
|
||||
type: string
|
||||
channel:
|
||||
description: "Docs channel to update or remove"
|
||||
required: true
|
||||
type: string
|
||||
source_ref:
|
||||
description: "Source commit SHA, branch, or tag to snapshot when operation=sync"
|
||||
required: false
|
||||
type: string
|
||||
release_version:
|
||||
description: "Release version used to order mutable channels"
|
||||
required: false
|
||||
type: string
|
||||
version_slug:
|
||||
description: "Version slug when channel=version"
|
||||
required: false
|
||||
type: string
|
||||
display_name:
|
||||
description: "Optional version selector display name"
|
||||
required: false
|
||||
type: string
|
||||
availability:
|
||||
description: "Optional Fern availability status"
|
||||
required: false
|
||||
type: string
|
||||
publish:
|
||||
description: "Publish production docs after syncing"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
allow_rollback:
|
||||
description: "Allow an explicitly requested mutable-channel rollback"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
secrets:
|
||||
FERN_TOKEN:
|
||||
required: false
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
operation:
|
||||
@@ -21,19 +64,38 @@ on:
|
||||
options:
|
||||
- dev
|
||||
- latest
|
||||
- stable
|
||||
- version
|
||||
source_ref:
|
||||
description: "Source commit SHA, branch, or tag to snapshot when operation=sync"
|
||||
required: false
|
||||
type: string
|
||||
release_version:
|
||||
description: "Release version, e.g. 0.1.2 or 0.1.3.dev4"
|
||||
required: false
|
||||
type: string
|
||||
version_slug:
|
||||
description: "Version slug when channel=version, e.g. v0.0.36"
|
||||
required: false
|
||||
type: string
|
||||
display_name:
|
||||
description: "Optional version selector display name"
|
||||
description: "Optional selector name, e.g. Dev"
|
||||
required: false
|
||||
type: string
|
||||
availability:
|
||||
description: "Optional Fern status: beta, deprecated, ga, or stable"
|
||||
required: false
|
||||
type: string
|
||||
publish:
|
||||
description: "Publish production docs after syncing"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
allow_rollback:
|
||||
description: "Allow an explicitly requested mutable-channel rollback"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -41,6 +103,7 @@ permissions:
|
||||
concurrency:
|
||||
group: docs-website
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
defaults:
|
||||
run:
|
||||
@@ -65,6 +128,7 @@ jobs:
|
||||
OPERATION: ${{ inputs.operation }}
|
||||
CHANNEL: ${{ inputs.channel }}
|
||||
SOURCE_REF: ${{ inputs.source_ref }}
|
||||
RELEASE_VERSION: ${{ inputs.release_version }}
|
||||
VERSION_SLUG: ${{ inputs.version_slug }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -72,8 +136,12 @@ jobs:
|
||||
echo "source_ref is required when operation=sync" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$CHANNEL" == "version" && -z "$VERSION_SLUG" ]]; then
|
||||
echo "version_slug is required when channel=version" >&2
|
||||
if [[ "$CHANNEL" =~ ^(dev|latest|stable)$ && -z "$RELEASE_VERSION" ]]; then
|
||||
echo "release_version is required for dev, latest, and stable channels" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$CHANNEL" =~ ^(stable|version)$ && -z "$VERSION_SLUG" ]]; then
|
||||
echo "version_slug is required for stable and version channels" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -96,6 +164,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
|
||||
with:
|
||||
version: "0.10.12"
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Update docs snapshot
|
||||
# Inputs flow in as quoted env vars to avoid shell injection; see the
|
||||
@@ -104,17 +173,32 @@ jobs:
|
||||
OPERATION: ${{ inputs.operation }}
|
||||
CHANNEL: ${{ inputs.channel }}
|
||||
SOURCE_REF: ${{ inputs.source_ref }}
|
||||
RELEASE_VERSION: ${{ inputs.release_version }}
|
||||
VERSION_SLUG: ${{ inputs.version_slug }}
|
||||
DISPLAY_NAME: ${{ inputs.display_name }}
|
||||
AVAILABILITY: ${{ inputs.availability }}
|
||||
ALLOW_ROLLBACK: ${{ inputs.allow_rollback }}
|
||||
run: |
|
||||
SOURCE_SHA=""
|
||||
if [[ "$OPERATION" == "sync" ]]; then
|
||||
SOURCE_SHA=$(git -C source rev-parse HEAD)
|
||||
fi
|
||||
rollback_args=()
|
||||
if [[ "$ALLOW_ROLLBACK" == "true" ]]; then
|
||||
rollback_args+=(--allow-rollback)
|
||||
fi
|
||||
uv run automation/tasks/scripts/sync_docs_website.py \
|
||||
--operation "$OPERATION" \
|
||||
--source-root source \
|
||||
--docs-website-root docs-website \
|
||||
--channel "$CHANNEL" \
|
||||
--source-ref "$SOURCE_REF" \
|
||||
--source-sha "$SOURCE_SHA" \
|
||||
--release-version "$RELEASE_VERSION" \
|
||||
--version-slug "$VERSION_SLUG" \
|
||||
--display-name "$DISPLAY_NAME"
|
||||
--display-name "$DISPLAY_NAME" \
|
||||
--availability "$AVAILABILITY" \
|
||||
"${rollback_args[@]}"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
@@ -160,3 +244,10 @@ jobs:
|
||||
git commit -m "docs(website): remove ${target} docs"
|
||||
fi
|
||||
git push origin HEAD:docs-website
|
||||
|
||||
- name: Publish Fern docs
|
||||
if: ${{ inputs.publish }}
|
||||
env:
|
||||
FERN_TOKEN: ${{ secrets.FERN_TOKEN }}
|
||||
working-directory: docs-website/fern
|
||||
run: fern generate --docs
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
name: Trivy Changes
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
base_sha:
|
||||
description: Base commit SHA to compare
|
||||
required: true
|
||||
type: string
|
||||
head_sha:
|
||||
description: Candidate commit SHA to compare
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect deployment configuration changes
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
should_run: ${{ steps.default.outputs.should_run || steps.changed.outputs.any_modified }}
|
||||
steps:
|
||||
- id: default
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- id: changed
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: tj-actions/changed-files@aa08304bd477b800d468db44fe10f6c61f7f7b11 # v42.1.0
|
||||
with:
|
||||
# `any_modified` covers deletions, which `any_changed` omits, and a
|
||||
# failed diff has to fail the job: both otherwise report no relevant
|
||||
# change, and removing the scanner or a value fixture would skip the
|
||||
# scan behind a green status.
|
||||
fail_on_initial_diff_error: true
|
||||
files: |
|
||||
deploy/docker/**
|
||||
deploy/helm/**
|
||||
deploy/kube/**
|
||||
.trivyignore.yaml
|
||||
flake.nix
|
||||
flake.lock
|
||||
tasks/scripts/trivy-scan.sh
|
||||
tasks/scripts/trivy-scan-test.sh
|
||||
tasks/scripts/trivy-config-report.jq
|
||||
.github/workflows/trivy-scan.yml
|
||||
.github/workflows/trivy-changes.yml
|
||||
|
||||
scan:
|
||||
name: Scan changed deployment configuration
|
||||
needs: changes
|
||||
if: needs.changes.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
BASE_REF: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || inputs.base_sha }}
|
||||
HEAD_REF: ${{ inputs.head_sha || github.sha }}
|
||||
defaults:
|
||||
run:
|
||||
shell: nix develop --command bash -euo pipefail {0}
|
||||
steps:
|
||||
- name: Check out candidate
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ env.HEAD_REF }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check out baseline
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ env.BASE_REF }}
|
||||
path: .trivy-base
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Nix
|
||||
uses: ./.github/actions/setup-nix
|
||||
|
||||
- name: Test report comparison
|
||||
run: tasks/scripts/trivy-scan-test.sh
|
||||
|
||||
- name: Validate candidate ignore policy
|
||||
run: tasks/scripts/trivy-scan.sh validate-ignore
|
||||
|
||||
# Ignore-policy changes take effect only after merge. Applying the
|
||||
# baseline policy to both scans prevents a candidate from exempting a new
|
||||
# finding in the same change that introduces it.
|
||||
- name: Prepare baseline ignore policy
|
||||
run: |
|
||||
if [ -f .trivy-base/.trivyignore.yaml ]; then
|
||||
cp .trivy-base/.trivyignore.yaml "$RUNNER_TEMP/trivy-baseline-ignore.yaml"
|
||||
else
|
||||
printf 'misconfigurations: []\n' >"$RUNNER_TEMP/trivy-baseline-ignore.yaml"
|
||||
fi
|
||||
|
||||
- name: Scan baseline
|
||||
env:
|
||||
TRIVY_SOURCE_ROOT: ${{ github.workspace }}/.trivy-base
|
||||
TRIVY_IGNORE_FILE: ${{ runner.temp }}/trivy-baseline-ignore.yaml
|
||||
TRIVY_REPORT_DIR: ${{ runner.temp }}/trivy-base
|
||||
run: |
|
||||
mkdir -p "$TRIVY_REPORT_DIR"
|
||||
"$GITHUB_WORKSPACE/tasks/scripts/trivy-scan.sh" config
|
||||
|
||||
- name: Scan candidate
|
||||
env:
|
||||
TRIVY_IGNORE_FILE: ${{ runner.temp }}/trivy-baseline-ignore.yaml
|
||||
TRIVY_REPORT_DIR: ${{ runner.temp }}/trivy-head
|
||||
run: |
|
||||
mkdir -p "$TRIVY_REPORT_DIR"
|
||||
tasks/scripts/trivy-scan.sh config
|
||||
|
||||
- name: Reject new high or critical findings
|
||||
run: |
|
||||
tasks/scripts/trivy-scan.sh gate-config-diff \
|
||||
"$RUNNER_TEMP/trivy-base" "$RUNNER_TEMP/trivy-head"
|
||||
|
||||
- name: Upload reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: trivy-changes-${{ github.run_id }}
|
||||
path: |
|
||||
${{ runner.temp }}/trivy-base
|
||||
${{ runner.temp }}/trivy-head
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
result:
|
||||
name: OpenShell / Trivy Changes
|
||||
needs: [changes, scan]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check scan result
|
||||
env:
|
||||
CHANGES_RESULT: ${{ needs.changes.result }}
|
||||
SHOULD_RUN: ${{ needs.changes.outputs.should_run }}
|
||||
SCAN_RESULT: ${{ needs.scan.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$CHANGES_RESULT" != "success" ]; then
|
||||
echo "::error::Change detection concluded $CHANGES_RESULT."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$SHOULD_RUN" = "true" ] && [ "$SCAN_RESULT" != "success" ]; then
|
||||
echo "::error::Trivy scan concluded $SCAN_RESULT."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$SHOULD_RUN" != "true" ]; then
|
||||
echo "No Helm or Dockerfile changes to scan."
|
||||
fi
|
||||
@@ -0,0 +1,219 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
name: Trivy Scan
|
||||
|
||||
# Manual or reusable scan of deployment configuration and supplied OCI
|
||||
# artifacts. Findings are informational by default; scanner errors remain fatal.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
images:
|
||||
description: Newline-separated image references to scan
|
||||
type: string
|
||||
default: ""
|
||||
charts:
|
||||
description: Newline-separated packaged Helm chart OCI references
|
||||
type: string
|
||||
default: ""
|
||||
severity:
|
||||
description: Severities that fail the workflow
|
||||
type: string
|
||||
default: HIGH,CRITICAL
|
||||
ignore-unfixed:
|
||||
description: Ignore image vulnerabilities with no upstream fix
|
||||
type: boolean
|
||||
default: true
|
||||
fail-on-findings:
|
||||
description: Fail the run on findings instead of warning
|
||||
type: boolean
|
||||
default: false
|
||||
upload-sarif:
|
||||
description: Upload results to GitHub Code Scanning
|
||||
type: boolean
|
||||
default: true
|
||||
secrets:
|
||||
CACHIX_AUTH_TOKEN:
|
||||
description: Token used to write Nix build outputs to Cachix
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
images:
|
||||
description: Newline-separated image references to scan
|
||||
type: string
|
||||
default: ""
|
||||
charts:
|
||||
description: Newline-separated packaged Helm chart OCI references to scan
|
||||
type: string
|
||||
default: ""
|
||||
severity:
|
||||
description: Severities that fail the workflow
|
||||
type: string
|
||||
default: HIGH,CRITICAL
|
||||
ignore-unfixed:
|
||||
description: Ignore image vulnerabilities with no upstream fix
|
||||
type: boolean
|
||||
default: true
|
||||
fail-on-findings:
|
||||
description: Fail the run on findings instead of warning
|
||||
type: boolean
|
||||
default: false
|
||||
upload-sarif:
|
||||
description: Upload results to GitHub Code Scanning
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: nix develop --command bash -euo pipefail {0}
|
||||
|
||||
env:
|
||||
TRIVY_SEVERITY: ${{ inputs.severity || 'HIGH,CRITICAL' }}
|
||||
TRIVY_REPORT_DIR: reports/trivy
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: OpenShell / Trivy (informational)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
outputs:
|
||||
sarif-batches: ${{ steps.sarif.outputs.batches }}
|
||||
artifact-id: ${{ steps.reports.outputs.artifact-id }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up Nix
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
cachix-auth-token: ${{ github.event_name != 'pull_request' && secrets.CACHIX_AUTH_TOKEN || '' }}
|
||||
|
||||
- name: Test scan reporting
|
||||
run: tasks/scripts/trivy-scan-test.sh
|
||||
|
||||
- name: Scan configuration
|
||||
id: config
|
||||
env:
|
||||
CHARTS: ${{ inputs.charts }}
|
||||
run: |
|
||||
args=()
|
||||
while IFS= read -r ref; do
|
||||
[ -n "$ref" ] || continue
|
||||
args+=(--chart-ref "$ref")
|
||||
done <<<"$CHARTS"
|
||||
tasks/scripts/trivy-scan.sh config "${args[@]}"
|
||||
|
||||
- name: Scan images
|
||||
id: images
|
||||
if: ${{ !cancelled() }}
|
||||
env:
|
||||
IMAGES: ${{ inputs.images }}
|
||||
TRIVY_IGNORE_UNFIXED: ${{ inputs.ignore-unfixed }}
|
||||
run: |
|
||||
refs=()
|
||||
while IFS= read -r ref; do
|
||||
[ -n "$ref" ] || continue
|
||||
refs+=("$ref")
|
||||
done <<<"$IMAGES"
|
||||
if [ "${#refs[@]}" -gt 0 ]; then
|
||||
tasks/scripts/trivy-scan.sh images "${refs[@]}"
|
||||
fi
|
||||
|
||||
- name: Prepare consolidated SARIF uploads
|
||||
id: sarif
|
||||
if: >-
|
||||
${{
|
||||
!cancelled()
|
||||
&& steps.config.conclusion == 'success'
|
||||
&& steps.images.conclusion == 'success'
|
||||
&& inputs.upload-sarif
|
||||
}}
|
||||
run: tasks/scripts/trivy-scan.sh prepare-sarif
|
||||
|
||||
- name: Upload reports
|
||||
id: reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: trivy-${{ github.run_id }}
|
||||
path: reports/trivy
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
- name: Report findings
|
||||
if: >-
|
||||
${{
|
||||
!cancelled()
|
||||
&& steps.config.conclusion == 'success'
|
||||
&& steps.images.conclusion == 'success'
|
||||
}}
|
||||
env:
|
||||
FAIL_ON_FINDINGS: ${{ inputs.fail-on-findings }}
|
||||
run: |
|
||||
set +e
|
||||
tasks/scripts/trivy-scan.sh gate
|
||||
status=$?
|
||||
set -e
|
||||
case "$status" in
|
||||
0) echo "No findings at ${TRIVY_SEVERITY}." ;;
|
||||
10)
|
||||
if [ "$FAIL_ON_FINDINGS" = "true" ]; then
|
||||
echo "::error::Trivy findings at ${TRIVY_SEVERITY}."
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::Trivy findings at ${TRIVY_SEVERITY}; this check is informational."
|
||||
;;
|
||||
*) echo "::error::Trivy could not evaluate the reports (exit $status)."; exit "$status" ;;
|
||||
esac
|
||||
|
||||
upload-sarif:
|
||||
name: Publish Trivy SARIF (${{ matrix.batch }})
|
||||
needs: scan
|
||||
# Findings may fail the scan job after reports are prepared. Publish those
|
||||
# complete results too, but never publish an incomplete/failed scan.
|
||||
if: >-
|
||||
${{
|
||||
!cancelled()
|
||||
&& inputs.upload-sarif
|
||||
&& needs.scan.outputs.sarif-batches != ''
|
||||
&& needs.scan.outputs.artifact-id != ''
|
||||
}}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
batch: ${{ fromJSON(needs.scan.outputs.sarif-batches) }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
artifact-ids: ${{ needs.scan.outputs.artifact-id }}
|
||||
merge-multiple: true
|
||||
path: reports/trivy
|
||||
|
||||
# One configuration run, plus one run per image/platform or packaged
|
||||
# chart. Each batch holds at most GitHub's limit of 20 SARIF runs.
|
||||
- uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
sarif_file: reports/trivy/code-scanning/uploads/${{ matrix.batch }}
|
||||
@@ -67,6 +67,9 @@ pip-delete-this-directory.txt
|
||||
coverage.out
|
||||
coverage/
|
||||
htmlcov/
|
||||
|
||||
# Trivy scan reports (tasks/scripts/trivy-scan-*.sh)
|
||||
/reports/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
|
||||
@@ -11,7 +11,6 @@ specfile_path: openshell.spec
|
||||
|
||||
# Packages needed in the SRPM build environment to create vendor tarball
|
||||
srpm_build_deps:
|
||||
- rust
|
||||
- cargo
|
||||
- git-core
|
||||
|
||||
@@ -45,10 +44,6 @@ actions:
|
||||
# dist-info stays at the RPM Version; dev build identity is carried by
|
||||
# Release so Fedora's Python RPM post-processing can normalize metadata.
|
||||
- 'bash -c "if [ -n \"${OPENSHELL_CARGO_VERSION:-}\" ]; then sed -i -r \"s/^%global openshell_cargo_version .*/%global openshell_cargo_version ${OPENSHELL_CARGO_VERSION}/\" openshell.spec; fi"'
|
||||
# Override image_tag to 'latest' for tagged stable releases.
|
||||
# For PR and commit-to-main builds the spec default ('dev') is kept,
|
||||
# matching the :dev images pushed by release-dev.yml.
|
||||
- 'bash -c "if git describe --exact-match --tags HEAD 2>/dev/null | grep -qE ''^v[0-9]+\.[0-9]+\.[0-9]+$''; then sed -i ''s/^%global image_tag.*/%global image_tag latest/'' openshell.spec; fi"'
|
||||
|
||||
jobs:
|
||||
# Build on every pull request targeting main for CI validation
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# False positives only; unimplemented hardening and accepted risks stay visible.
|
||||
# The script passes this YAML file explicitly and requires each exception to use
|
||||
# `**/<concrete-basename>`, which matches source and packaged-chart paths.
|
||||
# Trivy cannot scope Helm exceptions to one occurrence, so keep IDs and paths
|
||||
# narrow.
|
||||
|
||||
misconfigurations:
|
||||
# The namespace comes from `helm install -n`, not the rendered workload.
|
||||
- id: KSV-0110
|
||||
paths:
|
||||
- "**/statefulset.yaml"
|
||||
- "**/deployment.yaml"
|
||||
statement: >-
|
||||
An artifact of rendering the chart outside a cluster. The namespace is
|
||||
supplied at install time.
|
||||
|
||||
# The ConfigMap stores an external Secret key name, not a credential.
|
||||
- id: KSV-01010
|
||||
paths:
|
||||
- "**/gateway-config.yaml"
|
||||
statement: >-
|
||||
The ConfigMap holds the name of a key in an external Secret, not a
|
||||
credential.
|
||||
|
||||
# Trivy cannot add this project's GHCR namespace to its trusted registries.
|
||||
- id: KSV-0125
|
||||
paths:
|
||||
- "**/statefulset.yaml"
|
||||
- "**/deployment.yaml"
|
||||
statement: >-
|
||||
Images come from ghcr.io/nvidia/openshell, this project's own registry.
|
||||
@@ -10,7 +10,12 @@ OpenShell is built agent-first. We design systems and use agents to implement th
|
||||
|
||||
## Skills
|
||||
|
||||
Agent skills live in `.agents/skills/`. Your harness can discover and load them natively — do not rely on this file for a full inventory. The detailed skills table is in [CONTRIBUTING.md](CONTRIBUTING.md) (for humans).
|
||||
OpenShell has two skill collections:
|
||||
|
||||
- `skills/` contains public, installable skills for using and operating OpenShell. These skills must work outside a source checkout and use installed CLI help plus published documentation as their sources of truth.
|
||||
- `.agents/skills/` contains internal contributor and maintainer workflows for developing OpenShell. Your repository-aware harness can discover and load them natively.
|
||||
|
||||
Do not rely on this file for a full inventory. The detailed public and contributor skill tables are in [CONTRIBUTING.md](CONTRIBUTING.md) (for humans).
|
||||
|
||||
## Workflow Chains
|
||||
|
||||
@@ -30,10 +35,11 @@ These pipelines connect skills into end-to-end workflows. Individual skill files
|
||||
| Path | Components | Purpose |
|
||||
|------|-----------|---------|
|
||||
| `crates/openshell-cli/` | CLI binary | User-facing command-line interface |
|
||||
| `crates/openshell-conformance/` | CLI conformance library | Reusable driver-agnostic scenarios and command runner |
|
||||
| `crates/openshell-conformance-cli/` | Conformance CLI | Distributable `list` and `run` entrypoint for gateway conformance |
|
||||
| `crates/openshell-server/` | Gateway server | Control-plane API, sandbox lifecycle, auth boundary |
|
||||
| `crates/openshell-sandbox/` | Sandbox runtime | Container supervision, policy-enforced egress routing |
|
||||
| `crates/openshell-policy/` | Policy engine | Filesystem, network, process, and inference constraints |
|
||||
| `crates/openshell-router/` | Privacy router | Privacy-aware LLM routing |
|
||||
| `crates/openshell-policy/` | Policy engine | Filesystem, network, and process constraints |
|
||||
| `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.8.0 event types, builders, shorthand/JSONL formatters, tracing layers |
|
||||
@@ -41,6 +47,7 @@ These pipelines connect skills into end-to-end workflows. Individual skill files
|
||||
| `crates/openshell-otel-test-support/` | OpenTelemetry test support | Shared loopback OTLP collector fixture for tracing tests |
|
||||
| `crates/openshell-core/` | Shared core | Common types, configuration, error handling |
|
||||
| `crates/openshell-extension-core/` | Extension core | Shared extension identity, JWT claims, bearer-token rotation, and TLS transport primitives |
|
||||
| `crates/openshell-gateway/` | Gateway binary composition | Links selected first-party compute drivers into the backend-agnostic server registry |
|
||||
| `crates/openshell-sdk/` | Shared client SDK | Async Rust gateway client (gRPC transport, TLS, OIDC refresh, edge tunnel); consumed by CLI, TUI, and `@openshell/sdk` |
|
||||
| `crates/openshell-providers/` | Provider management | Credential provider backends |
|
||||
| `crates/openshell-tui/` | Terminal UI | Ratatui-based dashboard for monitoring |
|
||||
@@ -51,11 +58,12 @@ These pipelines connect skills into end-to-end workflows. Individual skill files
|
||||
| `crates/openshell-driver-docker/` | Docker compute driver | In-process `ComputeDriver` backend for local Docker sandbox containers |
|
||||
| `crates/openshell-driver-podman/` | Podman compute driver | In-process `ComputeDriver` backend for local Podman sandbox containers |
|
||||
| `crates/openshell-driver-vm/` | VM compute driver | Standalone libkrun-backed `ComputeDriver` subprocess (embeds its own rootfs + runtime) |
|
||||
| `crates/openshell-driver-mxc/` | Microsoft MXC compute driver | In-process Windows AppContainer and isolation-session compute backend |
|
||||
| `crates/openshell-prover/` | Policy prover | Policy verification and proof generation |
|
||||
| `crates/openshell-server-macros/` | Server macros | Compile-time helpers for gateway RPC authorization |
|
||||
| `crates/openshell-supervisor-middleware/` | Middleware runtime | Generic middleware registry, remote service integration, and chain execution |
|
||||
| `crates/openshell-supervisor-middleware-builtins/` | Built-in middleware | First-party in-process middleware implementations |
|
||||
| `crates/openshell-supervisor-network/` | Network supervisor | Proxying, L7 enforcement, policy evaluation, and inference routing |
|
||||
| `crates/openshell-supervisor-network/` | Network supervisor | Proxying, L7 enforcement, policy evaluation, and provider credential injection |
|
||||
| `crates/openshell-supervisor-process/` | Process supervisor | Process lifecycle, namespace, and bypass monitoring |
|
||||
| `crates/openshell-vfio/` | VFIO support | PCI and GPU passthrough preparation and lifecycle |
|
||||
| `python/openshell/` | Python SDK | Python bindings and CLI packaging |
|
||||
@@ -64,7 +72,8 @@ These pipelines connect skills into end-to-end workflows. Individual skill files
|
||||
| `deploy/` | Docker, Helm, K8s | Dockerfiles, Helm chart, manifests |
|
||||
| `docs/` | Published docs | MDX pages, navigation, and content assets |
|
||||
| `fern/` | Docs site config | Fern site config, components, and theme assets |
|
||||
| `.agents/skills/` | Agent skills | Workflow automation for development |
|
||||
| `skills/` | Public agent skills | Installable workflows for using and operating OpenShell |
|
||||
| `.agents/skills/` | Contributor agent skills | Repository-aware workflows for developing OpenShell |
|
||||
| `.agents/agents/` | Agent personas | Sub-agent definitions (e.g., reviewer, doc writer) |
|
||||
| `architecture/` | Architecture docs | Design decisions and component documentation |
|
||||
|
||||
@@ -105,7 +114,7 @@ Use an OCSF builder + `ocsf_emit!()` for events that represent **observable sand
|
||||
- SSH authentication (accepted, denied, nonce replay)
|
||||
- Process lifecycle (start, exit, timeout, signal failure)
|
||||
- Security findings (unsafe policy, unavailable controls, replay attacks)
|
||||
- Configuration changes (policy load/reload, TLS setup, inference routes, settings)
|
||||
- Configuration changes (policy load/reload, TLS setup, provider attachments, settings)
|
||||
- Application lifecycle (supervisor start, SSH server ready)
|
||||
|
||||
### When to use plain tracing
|
||||
@@ -127,7 +136,7 @@ Use `info!()`, `debug!()`, `warn!()` for **internal operational plumbing** that
|
||||
| SSH sessions | `SshActivityBuilder` | Authentication, channel operations |
|
||||
| Process start/stop | `ProcessActivityBuilder` | Entrypoint lifecycle, signal failures |
|
||||
| Security alerts | `DetectionFindingBuilder` | Nonce replay, bypass detection, unsafe policy. Dual-emit with the domain event. |
|
||||
| Policy/config changes | `ConfigStateChangeBuilder` | Policy load, Landlock apply, TLS setup, inference routes, settings |
|
||||
| Policy/config changes | `ConfigStateChangeBuilder` | Policy load, Landlock apply, TLS setup, provider attachments, settings |
|
||||
| Supervisor lifecycle | `AppLifecycleBuilder` | Sandbox start, SSH server ready/failed |
|
||||
|
||||
### Severity guidelines
|
||||
@@ -235,7 +244,7 @@ ocsf_emit!(event);
|
||||
|
||||
## Cluster Infrastructure Changes
|
||||
|
||||
- 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.
|
||||
- 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 `skills/debug-openshell-cluster/SKILL.md` to reflect those changes.
|
||||
|
||||
## Skill Maintenance
|
||||
|
||||
@@ -246,10 +255,10 @@ When behavior, commands, or development workflows change, review the related age
|
||||
- When making changes, update the relevant documentation in the `architecture/` directory.
|
||||
- When changes affect user-facing behavior, update the relevant published docs pages under `docs/` and navigation in `docs/index.yml`.
|
||||
- When changing gateway TOML fields, driver-specific config options, config defaults, or Helm rendering of `gateway.toml`, update `docs/reference/gateway-config.mdx` in the same branch.
|
||||
- `fern/` contains the Fern site config, components, preview workflow inputs, and publish settings.
|
||||
- `fern/` contains the Fern site config, components, preview workflow inputs, publish settings, and publishing documentation in `fern/README.md`.
|
||||
- Follow the docs style guide in [docs/CONTRIBUTING.mdx](docs/CONTRIBUTING.mdx): active voice, minimal formatting, no filler introductions, `shell` fences for copyable commands, and no duplicate body H1.
|
||||
- Fern PR previews run through `.github/workflows/branch-docs.yml`, and production publish runs through the `publish-fern-docs` job in `.github/workflows/release-tag.yml`.
|
||||
- Use the `update-docs` skill to scan recent commits and draft doc updates.
|
||||
- Fern PR previews run through `.github/workflows/branch-docs.yml`. Release Dev publishes `dev`, and Release Tag publishes an immutable stable version plus `latest`. Both production paths call `.github/workflows/sync-docs.yml` once.
|
||||
- Use the `update-docs-from-commits` skill to scan recent commits and draft doc updates.
|
||||
|
||||
### Architecture Docs
|
||||
|
||||
|
||||
@@ -24,17 +24,21 @@ Three opt-in labels enable the long-running E2E suites:
|
||||
When multiple labels are present, `Branch E2E Checks` builds each generic multi-architecture artifact set once and fans out enabled suites in parallel. Runtime-specific reusable workflows define the Docker, Podman, VM, and Kubernetes lanes. Composite actions own the replaceable Podman, KVM, kind, and mise setup. Each lane depends only on the artifact categories it consumes: VM does not wait for container-driver artifacts or supervisor images, and GPU does not wait for the gateway image. Docker, Podman, GPU, Rust, Python, MCP, and VM E2E reuse matching prebuilt gateway and CLI binaries instead of compiling debug binaries in test jobs. Standalone-driver lanes additionally reuse driver-free gateway and compute-driver artifacts. Kubernetes managed-driver lanes consume published gateway and supervisor images, while the standalone-driver lane composes its gateway image from prebuilt binaries.
|
||||
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 Kubernetes HA and credential-driver behavior are 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.
|
||||
The GitHub ruleset should require the `OpenShell / ...` statuses published by
|
||||
`Required CI Gates` plus the direct `OpenShell / Trivy Changes` result, not the
|
||||
push-triggered workflow jobs themselves.
|
||||
|
||||
## Informational security reports
|
||||
|
||||
Security analysis that does not need NVIDIA infrastructure runs directly on
|
||||
GitHub-hosted runners. These workflows receive no secrets. The PR-oriented
|
||||
reports run on fork pull requests without waiting for copy-pr-bot. Scanner jobs
|
||||
request `security-events: write` to publish SARIF to Code Scanning. GitHub
|
||||
permits Code Scanning uploads from `pull_request` runs even when fork and
|
||||
Dependabot contexts receive a read-only `GITHUB_TOKEN`, so those scanners upload
|
||||
results directly and also retain report artifacts:
|
||||
Security workflow compute runs directly on GitHub-hosted runners instead of
|
||||
NVIDIA self-hosted runners. The PR-oriented workflows receive no secrets and run
|
||||
on fork pull requests without waiting for copy-pr-bot. Codex Security release
|
||||
qualification is the exception: it runs only for maintainer-created
|
||||
pre-release tags and receives a scoped NVIDIA Inference API key for the scan
|
||||
step. Scanner jobs request `security-events: write` to publish SARIF to Code
|
||||
Scanning. GitHub permits Code Scanning uploads from `pull_request` runs even
|
||||
when fork and Dependabot contexts receive a read-only `GITHUB_TOKEN`, so those
|
||||
scanners upload results directly:
|
||||
|
||||
- `Workflow Security Reports` runs Actionlint and Zizmor. Actionlint reports
|
||||
workflow syntax and expression findings. Zizmor reports only High severity,
|
||||
@@ -50,6 +54,20 @@ results directly and also retain report artifacts:
|
||||
E2E test code are excluded. It runs nightly on `main`, remains manually
|
||||
dispatchable for diagnostics, uploads results to Code Scanning, and retains
|
||||
workflow artifacts.
|
||||
- `Codex Security Release Qualification` analyzes each `vX.Y.Z-pre.N`
|
||||
candidate against the previous stable release. Every candidate in a release
|
||||
train therefore rescans the cumulative stable-to-candidate diff. It calls
|
||||
`https://inference-api.nvidia.com/v1` with
|
||||
`openai/openai/gpt-5.6-sol` at medium reasoning effort. The
|
||||
`CODEX_SECURITY_API_KEY` secret must contain an API key authorized for that
|
||||
NVIDIA endpoint. Results are uploaded against the candidate commit on `main`
|
||||
under a category shared by the train, for example
|
||||
`codex-security/v0.1.1`, so later candidates replace earlier analyses. The
|
||||
slash-qualified NVIDIA model identifier prevents Codex Security 0.1.24 from
|
||||
enforcing `--max-cost`, so the workflow relies on its timeout, serialized
|
||||
concurrency, and the inference account's spend controls. Raw reports are not
|
||||
retained as workflow artifacts. Pre-release creation and stable-promotion
|
||||
enforcement remain part of RFC 0014 and are not implemented by this workflow.
|
||||
|
||||
Findings do not fail these workflows. Tool startup, configuration, build, and
|
||||
analysis failures still fail so a broken scanner cannot appear healthy. The
|
||||
@@ -63,6 +81,66 @@ nix develop --command actionlint -shellcheck= -pyflakes=
|
||||
nix develop --command zizmor --offline --persona=regular --min-severity=high --no-exit-codes .
|
||||
```
|
||||
|
||||
## Artifact scanning
|
||||
|
||||
`Trivy Scan` is a self-contained `workflow_dispatch`/`workflow_call` step. It
|
||||
always scans deployment configuration and optionally scans supplied OCI image
|
||||
and chart references. It is not wired into a release workflow; a future analysis
|
||||
orchestrator can call it directly.
|
||||
|
||||
The scan job checks static deployment files (including Dockerfiles) once, both
|
||||
local charts with default values, the OpenShell `HELM_PROFILES` selected in
|
||||
`tasks/scripts/trivy-scan.sh`, requested packaged charts, and both Linux
|
||||
architectures of each requested image. The profile list includes development
|
||||
and E2E overlays for regression coverage, but excludes `values-spire-stack.yaml`,
|
||||
which belongs to the external SPIRE chart. A new OpenShell values fixture needs
|
||||
an explicit entry in this list to receive Trivy coverage.
|
||||
|
||||
Findings are informational by default, but scanner failures still fail the job
|
||||
and `fail-on-findings` enables enforcement. Reports retain every severity; the
|
||||
gate uses `HIGH,CRITICAL` by default.
|
||||
|
||||
All detailed JSON reports are retained in one artifact. The workflow summary and
|
||||
Code Scanning configuration report deduplicate the same rule, target, resource,
|
||||
and message across profiles, listing the affected profiles in each finding.
|
||||
Images and packaged charts retain separate analyses, keyed by full reference
|
||||
and platform so different registries or versions cannot overwrite each other.
|
||||
SARIF is generated only for publication. The upload job publishes
|
||||
only this consolidated configuration SARIF and the artifact analyses, in batches
|
||||
of at most 20 runs to respect GitHub's per-file limit. It also publishes complete
|
||||
reports when `fail-on-findings` makes the scan job fail; incomplete scans are
|
||||
retained as artifacts but are not published to Code Scanning.
|
||||
|
||||
`.trivyignore.yaml` is reserved for false positives. Every entry must use at
|
||||
least one `**/<concrete-basename>` path; `yq` and `jq` validate this structure
|
||||
before scanning.
|
||||
|
||||
Run the scanner locally with:
|
||||
|
||||
```shell
|
||||
nix develop --command tasks/scripts/trivy-scan.sh config
|
||||
nix develop --command tasks/scripts/trivy-scan.sh images ghcr.io/nvidia/openshell/gateway:dev
|
||||
nix develop --command tasks/scripts/trivy-scan.sh gate
|
||||
nix develop --command tasks/scripts/trivy-scan.sh prepare-sarif
|
||||
```
|
||||
|
||||
Use a fresh `TRIVY_REPORT_DIR` for each scan session. `prepare-sarif` creates
|
||||
`code-scanning/uploads/<batch>/`; only these directories are intended for upload.
|
||||
|
||||
### Pull-request change gate
|
||||
|
||||
`Trivy Changes` scans the base and candidate when a pull request or merge group
|
||||
changes deployment configuration or scanner inputs. It fails only for new
|
||||
`HIGH` or `CRITICAL` misconfigurations and retains both report sets.
|
||||
|
||||
The candidate ignore file is validated, but the baseline policy applies to both
|
||||
scans so a change cannot exempt its own finding. Existing profiles are compared
|
||||
independently using the detailed reports, before any presentation deduplication;
|
||||
a new profile reuses the maximum known occurrence count. A selected fixture
|
||||
missing from the candidate fails the scan; a new fixture absent from the base
|
||||
has no baseline profile. Invalid Trivy reports fail closed. Image package and
|
||||
operating-system CVEs remain the responsibility of the standalone scan.
|
||||
|
||||
## Commit signing
|
||||
|
||||
copy-pr-bot decides whether to mirror a PR automatically based on whether the author is trusted. For org members and collaborators, "trusted" means **all commits in the PR are cryptographically signed**. Unsigned commits, even from an org member, force the bot to wait for a maintainer's `/ok to test <SHA>`.
|
||||
@@ -142,14 +220,18 @@ GitHub merge queue is required for `main`. Repository administrators must enable
|
||||
- `OpenShell / E2E`
|
||||
- `OpenShell / GPU E2E`
|
||||
- `OpenShell / Helm Lint`
|
||||
- `OpenShell / Trivy Changes`
|
||||
|
||||
Do not require the underlying workflow job names directly. `Required CI Gates` publishes stable commit statuses for both PR-head mirror commits and merge-group commits.
|
||||
`Required CI Gates` publishes the stable statuses for mirror-based workflows.
|
||||
`Trivy Changes` runs directly on pull requests and merge groups and publishes
|
||||
its own stable result status.
|
||||
|
||||
Merge-group runs use the `merge_group` event. The event is distinct from `pull_request` and `push`, and GitHub will not report required checks for queued PRs unless the workflows include it. In this repository:
|
||||
|
||||
- `Branch Checks` runs the standard non-E2E gates on the merge-group SHA.
|
||||
- `Branch E2E Checks` runs core E2E and GPU E2E for merge groups. Kubernetes HA E2E remains optional and label-driven on PRs.
|
||||
- `Helm Lint` runs for merge groups without the PR diff optimization, because the merge-group branch is the final integration state.
|
||||
- `Trivy Changes` compares the merge-group configuration with its base and rejects new High or Critical findings.
|
||||
- `Required CI Gates` posts the same `OpenShell / ...` statuses to the merge-group SHA and does not require a `pull-request/<N>` mirror for merge-group events.
|
||||
|
||||
Maintainers should add ready PRs to the queue rather than pressing a direct merge button. GitHub removes a PR from the queue if the merge-group checks fail or time out.
|
||||
@@ -187,6 +269,9 @@ The bot's full administrator documentation is internal to NVIDIA. The only comma
|
||||
| `.github/workflows/workflow-security.yml` | Runs informational Actionlint and High-severity Zizmor reports on GitHub-hosted runners. |
|
||||
| `.github/workflows/dependency-review.yml` | Reports dependency changes when GitHub Dependency Graph is available; otherwise publishes a neutral warning. |
|
||||
| `.github/workflows/codeql.yml` | Runs nightly informational CodeQL analysis on `main` for Rust and the Go, Python, and TypeScript SDKs and retains SARIF artifacts. |
|
||||
| `.github/workflows/codex-security.yml` | Scans the cumulative diff from the previous stable release to each pre-release candidate and publishes train-scoped SARIF on `main`. |
|
||||
| `.github/workflows/trivy-changes.yml` | Blocks pull requests and merge groups that introduce new High or Critical Helm or Dockerfile misconfigurations. |
|
||||
| `.github/workflows/trivy-scan.yml` | Manual or reusable scan of supplied OCI image/chart references and deployment configuration. Findings are informational by default and can be configured to fail the workflow. |
|
||||
|
||||
## Release workflows
|
||||
|
||||
@@ -195,7 +280,7 @@ These workflows run after merge to publish dev/tagged artifacts and verify them.
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `.github/workflows/release-dev.yml` | Publishes the rolling `dev` build on every push to `main`. Builds gateway/supervisor images and binaries, packages, wheels, and pushes the Helm chart as `oci://ghcr.io/nvidia/openshell/helm-chart:0.0.0-dev` (plus an immutable `0.0.0-dev.<sha>` pin). Also dispatchable manually. |
|
||||
| `.github/workflows/release-tag.yml` | Publishes a tagged public release. |
|
||||
| `.github/workflows/release-tag.yml` | Publishes a tagged stable release. Its automatic tag trigger excludes `-pre.*`; manual dispatch remains maintainer-controlled. |
|
||||
| `.github/workflows/release-canary.yml` | Smoke-tests published artifacts on `macos`, `ubuntu`, `fedora`, and `kubernetes` (kind + Helm) runners. Triggers automatically when `Release Dev` succeeds, and via `workflow_dispatch` on any branch (`gh workflow run release-canary.yml --ref <branch>`). The `kubernetes` job pins to `0.0.0-dev` artifacts; the other jobs install the latest tagged release via `install.sh`. See the `test-release-canary` skill for the manual-dispatch playbook and local kind reproduction. |
|
||||
|
||||
## Required status contexts
|
||||
@@ -206,8 +291,13 @@ Require these statuses in the branch ruleset for PR and merge-queue CI:
|
||||
- `OpenShell / E2E`
|
||||
- `OpenShell / GPU E2E`
|
||||
- `OpenShell / Helm Lint`
|
||||
- `OpenShell / Trivy Changes`
|
||||
|
||||
Do not require the underlying workflow jobs directly. PR workflow jobs only appear after copy-pr-bot mirrors trusted code, and merge-group workflow jobs run on temporary queue branches. The stable `OpenShell / ...` contexts prove the expected workflow completed for the commit that GitHub is about to merge.
|
||||
For mirror-based workflows, require the statuses published by
|
||||
`Required CI Gates`, not their underlying jobs. `OpenShell / Trivy Changes` is
|
||||
the stable result job of the direct pull-request workflow. Together these
|
||||
contexts prove the expected checks completed for the commit GitHub is about to
|
||||
merge.
|
||||
|
||||
Do not add the informational Actionlint, Zizmor, Dependency Review, or CodeQL
|
||||
jobs to the required status list while they remain in observation mode.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Code of Conduct
|
||||
|
||||
We follow the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/main/code-of-conduct.md).
|
||||
|
||||
Please contact [openshell-maintainers@ai-openshell.org](mailto:openshell-maintainers@ai-openshell.org) or the [CNCF Code of Conduct Committee](mailto:conduct@cncf.io) to report violations of the Code of Conduct.
|
||||
+53
-21
@@ -48,7 +48,7 @@ Search open and closed issues for the same need. Bug reports and feature request
|
||||
|
||||
Feature requests must also propose a user-facing workflow and describe alternatives considered. Define the externally observable behavior and leave internal implementation choices open. Bug reports instead include minimal reproduction steps, the OpenShell version and relevant environment, and a small, redacted log excerpt when it materially clarifies the behavior.
|
||||
|
||||
The project includes optional [agent skills](#agent-skills-for-contributors) for self-service troubleshooting and exploration. Use them when they help you, but summarize any useful result in your own words rather than pasting a diagnostic transcript.
|
||||
The project includes optional [agent skills](#agent-skills) for using OpenShell and contributing to the repository. Use them when they help you, but summarize any useful result in your own words rather than pasting a diagnostic transcript.
|
||||
|
||||
### When to Open an Issue
|
||||
|
||||
@@ -68,15 +68,29 @@ Do not start substantial issue-backed work until a maintainer has accepted the i
|
||||
|
||||
Use agents and the repository skills as needed to understand the affected code, evaluate tradeoffs, implement the smallest coherent change, and verify it. The pull request should explain what changed and how it was tested; it should not substitute an agent transcript for the contributor's understanding.
|
||||
|
||||
## Agent Skills for Contributors
|
||||
## Agent Skills
|
||||
|
||||
Skills live in `.agents/skills/`. Your agent's harness can discover and load them natively. Here is the full inventory:
|
||||
OpenShell keeps skills for using the product separate from skills for developing the repository.
|
||||
|
||||
### Skills for Using OpenShell
|
||||
|
||||
Public skills live in `skills/` and work without an OpenShell source checkout. Install them with `npx skills add NVIDIA/OpenShell`.
|
||||
|
||||
| Skill | Purpose |
|
||||
| --- | --- |
|
||||
| `openshell-cli` | CLI usage, sandbox lifecycle, provider management, and BYOC workflows |
|
||||
| `debug-openshell-cluster` | Diagnose gateway deployment and health issues |
|
||||
| `debug-inference` | Diagnose attached-provider inference, native endpoints, and migration from `inference.local` |
|
||||
| `generate-sandbox-policy` | Generate YAML sandbox policies from requirements or API documentation |
|
||||
|
||||
Public skills use `openshell --help` for installed command syntax and published OpenShell documentation for product concepts and configuration. They must not depend on repository-relative source or documentation files.
|
||||
|
||||
### Agent Skills for Contributors
|
||||
|
||||
Contributor and maintainer skills live in `.agents/skills/`. They are marked internal so the Agent Skills CLI excludes them from ordinary public discovery, but repository-aware agent harnesses can discover and load them natively. Internal metadata is a discovery filter, not an access-control boundary.
|
||||
|
||||
| Category | Skill | Purpose |
|
||||
| --------------- | ------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Getting Started | `openshell-cli` | CLI usage, sandbox lifecycle, provider management, BYOC workflows |
|
||||
| Getting Started | `debug-openshell-cluster` | Diagnose gateway deployment and health issues |
|
||||
| Getting Started | `debug-inference` | Diagnose `inference.local`, host-backed local inference, and direct external inference setup issues |
|
||||
| Contributing | `create-spike` | Investigate a problem, produce a structured GitHub issue |
|
||||
| Contributing | `create-rfc` | Create RFC proposals from the repository template |
|
||||
| Contributing | `build-from-issue` | Plan and implement work from a GitHub issue (maintainer workflow) |
|
||||
@@ -89,11 +103,10 @@ Skills live in `.agents/skills/`. Your agent's harness can discover and load the
|
||||
| Reviewing | `launch-openshell-gator` | Launch and supervise OpenShell gator agents for issue and PR monitoring |
|
||||
| Reviewing | `test-release-canary` | Dispatch and iterate on the Release Canary workflow that smoke-tests published artifacts |
|
||||
| Triage | `triage-issue` | Assess, classify, and route community-filed issues |
|
||||
| Platform | `generate-sandbox-policy` | Generate YAML sandbox policies from requirements or API docs |
|
||||
| Platform | `helm-dev-environment` | Start and manage the local Kubernetes development environment |
|
||||
| Platform | `tui-development` | Development guide for the ratatui-based terminal UI |
|
||||
| Platform | `build-openshell-mxc-windows` | Maintain and validate the build-only x64 and ARM64 Windows MSVC lane |
|
||||
| Documentation | `update-docs` | Scan recent commits and draft doc updates for user-facing changes |
|
||||
| Documentation | `update-docs-from-commits` | Scan recent commits and draft doc updates for user-facing changes |
|
||||
| Maintenance | `sync-agent-infra` | Detect and fix drift across agent-first infrastructure files |
|
||||
| Reference | `sbom` | Generate SBOMs and resolve dependency licenses |
|
||||
|
||||
@@ -293,14 +306,19 @@ echo 'eval "$(~/.local/bin/mise activate zsh)"' >> ~/.zshrc
|
||||
|
||||
Project requirements:
|
||||
|
||||
- Rust 1.90+
|
||||
- Rust 1.94+
|
||||
- Python 3.11+
|
||||
- Docker (running)
|
||||
- CMake 3.16+ (only required when building with the `bundled-z3` feature)
|
||||
|
||||
### Z3 installation
|
||||
|
||||
The `openshell-prover` crate links against Z3. On macOS and Linux, install the
|
||||
system Z3 development package; `z3-sys` discovers it through `pkg-config`.
|
||||
The `openshell-prover` crate links directly against Z3. The `openshell-server`
|
||||
crate depends on the prover, and the `openshell-gateway` binary crate depends
|
||||
on `openshell-server` in turn; both forward a `bundled-z3` feature down to
|
||||
`openshell-prover/bundled-z3`. The `openshell-cli` crate does not depend on
|
||||
Z3. On macOS and Linux, install the system Z3 development package; `z3-sys`
|
||||
discovers it through `pkg-config`.
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
@@ -314,7 +332,7 @@ sudo dnf install z3-devel
|
||||
```
|
||||
|
||||
If you prefer not to install Z3 system-wide, use the bundled Z3 feature. This
|
||||
compiles Z3 from source during the Rust build:
|
||||
compiles Z3 from source during the Rust build and requires CMake 3.16+:
|
||||
|
||||
```bash
|
||||
cargo build -p openshell-prover --features bundled-z3
|
||||
@@ -328,16 +346,29 @@ For x86-64 Windows MSVC builds, use one of these Z3 paths:
|
||||
is set.
|
||||
- Bundled Z3: pass `--features bundled-z3` so `z3-sys` builds Z3 from source.
|
||||
|
||||
Both Windows paths still require `libclang.dll` for `bindgen`. If LLVM is not on
|
||||
the default search path, set `LIBCLANG_PATH` to the directory containing
|
||||
`libclang.dll`.
|
||||
`openshell-prover` itself has no `bindgen`/`libclang` dependency, so building
|
||||
just this crate does not require `LIBCLANG_PATH`:
|
||||
|
||||
```powershell
|
||||
cargo build -p openshell-prover --target x86_64-pc-windows-msvc --features bundled-z3
|
||||
```
|
||||
|
||||
### Windows full build
|
||||
|
||||
To build the full set of Windows binaries, including `openshell-gateway.exe`
|
||||
and `openshell.exe`, use the `windows:build:x64` mise task instead of a
|
||||
single-crate `cargo build`. It builds Z3 from source (bundled) by default. A
|
||||
full build also compiles crates that use `bindgen` (e.g. the MXC driver on
|
||||
Windows), so it requires `libclang.dll`; if LLVM is not on the default search
|
||||
path, set `LIBCLANG_PATH` to the directory containing `libclang.dll`:
|
||||
|
||||
```powershell
|
||||
$env:LIBCLANG_PATH='C:\Program Files\Microsoft Visual Studio\2022\<Edition>\VC\Tools\Llvm\x64\bin'
|
||||
cargo build -p openshell-cli --target x86_64-pc-windows-msvc --features bundled-z3
|
||||
mise run --skip-tools windows:build:x64
|
||||
```
|
||||
|
||||
To use a local x64 Z3 release with the Windows task wrapper:
|
||||
To use a local x64 Z3 release instead of the bundled build, set
|
||||
`Z3_LIBRARY_PATH_OVERRIDE` and `Z3_SYS_Z3_HEADER` before running the task:
|
||||
|
||||
```powershell
|
||||
$env:Z3_LIBRARY_PATH_OVERRIDE='C:\path\to\z3-4.16.0-x64-win\bin'
|
||||
@@ -429,7 +460,8 @@ These are the primary `mise` tasks for day-to-day development:
|
||||
| `fern/` | Fern site config, components, and theme assets |
|
||||
| `architecture/` | Architecture docs and plans |
|
||||
| `rfc/` | Request for Comments proposals |
|
||||
| `.agents/` | Agent skills and persona definitions |
|
||||
| `skills/` | Public skills for using and operating OpenShell |
|
||||
| `.agents/` | Contributor skills and persona definitions |
|
||||
|
||||
## RFCs
|
||||
|
||||
@@ -439,7 +471,7 @@ New features always start as GitHub issues using the feature request template. F
|
||||
|
||||
If your change affects user-facing behavior (new flags, changed defaults, new features, bug fixes that contradict existing docs), update the relevant pages under `docs/` in the same PR and adjust `docs/index.yml` if navigation changes. For explicit navigation entries, keep `page:` aligned with `sidebar-title` when present and put relative `slug:` values in `docs/index.yml`. Reserve frontmatter `slug` for folder-discovered pages or absolute URL overrides.
|
||||
|
||||
To ensure your doc changes follow NVIDIA documentation style, use the `update-docs` skill.
|
||||
To ensure your doc changes follow NVIDIA documentation style, use the `update-docs-from-commits` skill.
|
||||
It scans commits, identifies doc pages that need updates, and drafts content that follows the style guide in `docs/CONTRIBUTING.mdx`.
|
||||
|
||||
To preview Fern docs locally:
|
||||
@@ -456,9 +488,9 @@ mise run docs
|
||||
|
||||
PRs that touch `docs/**` or `fern/**` are validated by `.github/workflows/branch-docs.yml`, and they get a preview when `FERN_TOKEN` is available to the workflow.
|
||||
|
||||
Fern docs publishing is handled by the `publish-fern-docs` job in `.github/workflows/release-tag.yml` when a release tag is created.
|
||||
Release Dev publishes the `dev` docs version from `main`. Release Tag publishes an immutable stable version and updates `latest`. See [fern/README.md](fern/README.md) for the source layout, version model, and publishing workflows.
|
||||
|
||||
`docs/` is the source-of-truth docs tree. `fern/` contains the site config, components, and theme assets that publish those pages.
|
||||
`docs/` is the source-of-truth docs tree. `fern/` contains the site configuration, components, theme assets, and its README.
|
||||
|
||||
See [docs/CONTRIBUTING.mdx](docs/CONTRIBUTING.mdx) for the current docs authoring guide.
|
||||
|
||||
|
||||
Generated
+450
-466
File diff suppressed because it is too large
Load Diff
+15
-13
@@ -8,7 +8,7 @@ members = ["crates/*"]
|
||||
[workspace.package]
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.90"
|
||||
rust-version = "1.94"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/NVIDIA/OpenShell"
|
||||
|
||||
@@ -35,10 +35,10 @@ http-body = "1.0"
|
||||
http-body-util = "0.1"
|
||||
|
||||
# TLS
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "tls12", "ring"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["std", "logging", "tls12", "ring"] }
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "tls12", "aws_lc_rs"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["std", "logging", "tls12", "aws_lc_rs"] }
|
||||
rustls-pemfile = "2"
|
||||
rcgen = { version = "0.13", features = ["crypto", "pem"] }
|
||||
rcgen = { version = "0.13", default-features = false, features = ["crypto", "pem", "aws_lc_rs"] }
|
||||
webpki-roots = "1"
|
||||
rustls-native-certs = "0.8"
|
||||
|
||||
@@ -80,18 +80,18 @@ socket2 = "0.6"
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yml = "0.0.12"
|
||||
serde_yml = { package = "noyalib", version = "0.0.28", default-features = false, features = ["std", "compat-serde-yaml"] }
|
||||
toml = "0.8"
|
||||
apollo-parser = "0.8.5"
|
||||
tower-mcp-types = "0.12.0"
|
||||
regex = "1"
|
||||
|
||||
# HTTP client
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots"] }
|
||||
reqwest = { version = "0.12.28", default-features = false, features = ["json", "rustls-tls-native-roots-no-provider"] }
|
||||
|
||||
# AWS SDK
|
||||
aws-config = { version = "1", default-features = false, features = ["rustls", "rt-tokio", "behavior-version-latest"] }
|
||||
aws-sdk-sts = { version = "1", default-features = false, features = ["rustls", "rt-tokio", "behavior-version-latest"] }
|
||||
aws-config = { version = "1", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
aws-sdk-sts = { version = "1", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
|
||||
# WebSocket
|
||||
tokio-tungstenite = { version = "0.26", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
|
||||
@@ -104,7 +104,7 @@ sha2 = "0.10"
|
||||
rand = "0.9"
|
||||
jsonwebtoken = { version = "10", features = ["aws_lc_rs"] }
|
||||
getrandom = "0.3"
|
||||
ring = "0.17"
|
||||
aws-lc-rs = "1.16"
|
||||
spiffe = { version = "0.15", default-features = false, features = ["workload-api-jwt", "jwt-verify-rust-crypto", "tracing"] }
|
||||
|
||||
# Filesystem embedding
|
||||
@@ -124,12 +124,14 @@ url = "2"
|
||||
indexmap = "2"
|
||||
|
||||
# Database
|
||||
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-native-roots", "postgres", "sqlite", "migrate", "macros"] }
|
||||
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-aws-lc-rs", "postgres", "sqlite", "migrate", "macros"] }
|
||||
# SQLx's facade couples native roots to ring; select native roots independently.
|
||||
sqlx-core = { version = "0.9", default-features = false, features = ["rustls-native-certs"] }
|
||||
|
||||
# Kubernetes
|
||||
kube = { version = "0.90", default-features = false, features = ["client", "runtime", "derive", "rustls-tls"] }
|
||||
kube-runtime = "0.90"
|
||||
k8s-openapi = { version = "0.21.1", features = ["v1_26"] }
|
||||
kube = { version = "0.99", default-features = false, features = ["client", "runtime", "derive", "rustls-tls", "aws-lc-rs"] }
|
||||
kube-runtime = "0.99"
|
||||
k8s-openapi = { version = "0.24", features = ["v1_29"] }
|
||||
|
||||
# IDs
|
||||
uuid = { version = "1.10", features = ["v4"] }
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
# OpenShell Project Governance
|
||||
|
||||
OpenShell is dedicated to creating safe, private, policy-governed runtimes for autonomous AI agents. This document explains how the project is governed.
|
||||
|
||||
## Values
|
||||
|
||||
OpenShell and its leadership embrace the following values:
|
||||
|
||||
- **Openness:** Communication and decision-making happen in the open and remain discoverable for future reference. As much as possible, discussions and work take place in public forums and repositories.
|
||||
- **Fairness:** All stakeholders may provide feedback and submit contributions. The project considers contributions on their merits.
|
||||
- **Community over product or company:** Sustaining and growing the community takes priority over shipping code or advancing one sponsor's organizational goals. Contributors participate as individuals.
|
||||
- **Vendor neutrality:** No single organization controls project direction or decisions. Maintainer selection, roadmap priorities, and release decisions are based on the interests of the project rather than employer affiliation.
|
||||
- **Inclusivity:** Different perspectives and skills make OpenShell better. The project provides a welcoming and respectful environment for participation.
|
||||
- **Participation:** Responsibilities and privileges are earned through sustained participation, demonstrated judgment, and earned community trust.
|
||||
|
||||
## Contributor Ladder
|
||||
|
||||
OpenShell uses a contributor ladder to provide a path from participation to project leadership. Movement through the ladder reflects increasing trust, responsibility, and project-wide stewardship. Contributions may include code, documentation, design, testing, issue triage, reviews, release work, community support, and other work that advances the project.
|
||||
|
||||
### Contributor
|
||||
|
||||
A Contributor is anyone who participates constructively in the OpenShell community.
|
||||
|
||||
Contributors are responsible for:
|
||||
|
||||
- Following the project Code of Conduct and contribution guidelines.
|
||||
- Taking responsibility for the correctness and provenance of their work, including work created with AI tools or agents.
|
||||
- Engaging constructively with reviewers, users, and other contributors.
|
||||
|
||||
Contributors may:
|
||||
|
||||
- Participate in project discussions.
|
||||
- Submit issues, designs, code, documentation, tests, and reviews.
|
||||
- Help other contributors and users.
|
||||
|
||||
### Reviewer
|
||||
|
||||
A Reviewer is a trusted Contributor with demonstrated knowledge in one or more project areas. Reviewers help maintain quality and provide consistent, actionable feedback, but do not receive project-wide merge or voting rights by virtue of this role.
|
||||
|
||||
Reviewers are responsible for:
|
||||
|
||||
- Regularly reviewing contributions in their areas of knowledge.
|
||||
- Reviewing for correctness, security, maintainability, tests, documentation, and consistency with project direction.
|
||||
- Supporting new and occasional contributors and helping them improve useful contributions.
|
||||
- Escalating decisions that require broader expertise or project-wide input.
|
||||
|
||||
A Reviewer demonstrates:
|
||||
|
||||
- A record of constructive, technically valuable reviews.
|
||||
- A working knowledge of the relevant project areas and their operational context.
|
||||
- Reliable collaboration and follow-through.
|
||||
- Judgment exercised for the good of OpenShell, independent of employer, product, or team interests.
|
||||
|
||||
An existing Maintainer may nominate a Contributor to become a Reviewer. A simple majority vote of the Maintainers approves the nomination. Maintainers may grant the GitHub permissions needed for the Reviewer's responsibilities.
|
||||
|
||||
### Maintainer
|
||||
|
||||
Maintainers have project-wide write, merge, and voting privileges. They are collectively responsible for steering OpenShell in a positive direction and for managing project resources and contributor access. The current Maintainers are listed in [MAINTAINERS.md](MAINTAINERS.md).
|
||||
|
||||
Maintainer status is held by individual humans. AI agents, automated systems, service accounts, companies, and other organizations cannot be Maintainers or exercise Maintainer votes.
|
||||
|
||||
Maintainers are responsible for:
|
||||
|
||||
- Reviewing and merging contributions across the project.
|
||||
- Protecting project quality, security, architectural coherence, and long-term maintainability.
|
||||
- Participating in project-wide technical, governance, roadmap, and release decisions.
|
||||
- Seeking review from the people most knowledgeable about affected areas.
|
||||
- Mentoring Contributors, Reviewers, and prospective Maintainers.
|
||||
- Supporting contributions that benefit the broader community, including work outside their employer's immediate priorities.
|
||||
- Following through on regressions and other issues arising from decisions they approve.
|
||||
- Representing OpenShell and its community responsibly.
|
||||
|
||||
Maintainers may:
|
||||
|
||||
- Approve and merge changes after applicable review and automated checks.
|
||||
- Vote on project matters.
|
||||
- Nominate Contributors for Reviewer or Maintainer roles.
|
||||
- Represent OpenShell in public and communicate with the CNCF on behalf of the project.
|
||||
|
||||
The collective team of Maintainers is the Maintainer Council, the governing body for OpenShell.
|
||||
|
||||
## Becoming a Maintainer
|
||||
|
||||
Maintainer status reflects established trust and readiness to steward the whole project. Activity metrics are an eligibility floor, not a scorecard, an automatic promotion mechanism, or sufficient evidence of readiness.
|
||||
|
||||
A candidate must first meet all of the following minimum participation requirements:
|
||||
|
||||
- At least three months of sustained participation in OpenShell discussions, contributions, reviews, or other project work.
|
||||
- At least three substantial pull requests merged into the project.
|
||||
- Constructive reviews of at least five substantial pull requests.
|
||||
- Demonstrated contribution to the general core product through reviews and feedback on third-party contributions beyond a feature, product, or project primarily needed by the candidate or their organization.
|
||||
|
||||
For these requirements, a substantial pull request is work that requires meaningful technical or project judgment. Mechanical changes, generated volume, lines changed, and a series of closely related pull requests do not by themselves demonstrate substance. Maintainers evaluate the scope, difficulty, quality, impact, and judgment shown by the work in context.
|
||||
|
||||
Meeting the minimum participation requirements only establishes eligibility. A candidate must also demonstrate:
|
||||
|
||||
- Deep understanding of OpenShell in one or more project areas, including how those areas interact with the broader platform.
|
||||
- Sound technical and product judgment, including an ability to identify risk, evaluate tradeoffs, and preserve long-term maintainability.
|
||||
- Consistently high-quality implementation or documentation work and technically useful reviews.
|
||||
- Understanding of project architecture, contribution policies, testing, security expectations, review practices, and development workflows.
|
||||
- Reliable collaboration, respectful communication, and follow-through.
|
||||
- Support for new and occasional contributors.
|
||||
- A community-first perspective and an ability to act independently of the candidate's employer, friends, team, product, or organizational priorities.
|
||||
- Personal accountability for contributions and reviews, including the ability to explain, validate, and take responsibility for work produced with AI tools or agents.
|
||||
- Direct participation in OpenShell community calls, sufficient for the community to know the person behind the contributions and for Maintainers to assess the candidate's understanding, judgment, communication, and accountability.
|
||||
|
||||
The use of AI or agents neither qualifies nor disqualifies a candidate. The Maintainer Council evaluates the candidate's demonstrated understanding, judgment, accountability, community participation, and the value they add to the project's human review and decision-making capacity. Raw contribution velocity, lines of code, and activity counts beyond the eligibility floor are not proxies for those qualities.
|
||||
|
||||
Community call attendance is qualitative evidence, not an attendance quota or substitute for the other requirements. Candidates who cannot reasonably attend the regular call because of time zone, accessibility, or similar constraints may arrange equivalent direct, synchronous participation with community members and Maintainers. Video participation is not required.
|
||||
|
||||
An existing Maintainer must nominate a candidate by sending a message to the [project mailing list](mailto:openshell-maintainers@ai-openshell.org). A simple majority vote of all active Maintainers approves the nomination. Nominations are evaluated without prejudice to employer or demographics and should consider the organizational diversity of the Maintainer Council.
|
||||
|
||||
Approved Maintainers receive the GitHub rights needed for the role, are added to [MAINTAINERS.md](MAINTAINERS.md), and are invited to Maintainer communication channels.
|
||||
|
||||
## Removing a Maintainer
|
||||
|
||||
A Maintainer may resign at any time when they can no longer fulfill the role.
|
||||
|
||||
A Maintainer may also be removed for inactivity, persistent failure to fulfill Maintainer responsibilities, a Code of Conduct violation, behavior detrimental to the project, or other cause. Inactivity means six months of very low or no project activity without a definite plan to resume active participation. Non-code project work counts as activity.
|
||||
|
||||
Removal requires a two-thirds vote of the remaining active Maintainers. A Maintainer whose removal is under consideration may participate in the discussion but does not vote on their own removal.
|
||||
|
||||
### Emeritus Maintainers
|
||||
|
||||
Depending on the circumstances of a resignation or removal, a Maintainer may move to Emeritus status. Emeritus Maintainers are recognized for past contributions and may be consulted on project matters, but they do not have voting rights or merge access. They are listed separately in [MAINTAINERS.md](MAINTAINERS.md).
|
||||
|
||||
An Emeritus Maintainer may return to active status through the standard Maintainer nomination and voting process, provided they meet the current requirements and can commit to active participation.
|
||||
|
||||
## Meetings and Communication
|
||||
|
||||
Project governance ordinarily takes place through public GitHub issues, discussions, and pull requests so decisions are visible and discoverable. Anyone interested in OpenShell may join the [OpenShell Community Google Group](https://groups.google.com/a/ai-openshell.org/g/openshell-community/about) to receive the community meeting invitation and view the community meeting agenda. Consequential decisions made during a meeting must be recorded in a public project artifact.
|
||||
|
||||
Maintainers may meet privately when handling security reports, Code of Conduct reports, personnel matters, or other sensitive information. All Maintainers must be invited except anyone whose participation would create a conflict of interest, including a Maintainer who is the subject of a report.
|
||||
|
||||
## CNCF Resources
|
||||
|
||||
Any Maintainer may propose a request for CNCF resources through the [project mailing list](mailto:openshell-maintainers@ai-openshell.org) or a project meeting. A simple majority vote of active Maintainers approves the request. The Maintainer Council may delegate CNCF coordination to a non-Maintainer community member and arrange the access needed for that work.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
OpenShell follows the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/main/code-of-conduct.md). The Maintainer Council handles reports privately and coordinates with the CNCF Code of Conduct Committee when appropriate. If a Maintainer is directly involved in a report, that Maintainer is excluded from its handling, and the remaining Maintainers designate at least two uninvolved Maintainers to coordinate the response.
|
||||
|
||||
## Security Response
|
||||
|
||||
Security reports are handled according to the project [security policy](SECURITY.md). The Maintainer Council appoints or recognizes the people responsible for coordinating security responses and reviews the response process and membership at least annually.
|
||||
|
||||
## Voting
|
||||
|
||||
OpenShell uses [lazy consensus](https://community.apache.org/committers/decisionMaking.html#lazy-consensus) for most decisions: a proposal proceeds when there is general agreement and no unresolved, reasoned objection. Maintainers should allow time appropriate to the impact and urgency of a decision so that affected contributors across time zones can participate.
|
||||
|
||||
Any Maintainer may call for a formal vote on the [project mailing list](mailto:openshell-maintainers@ai-openshell.org) or at a project meeting. Unless this document states otherwise, a proposal requires a simple majority of all active Maintainers to pass. A two-thirds vote requires support from at least two-thirds of all active Maintainers. Maintainers must disclose relevant conflicts of interest and recuse themselves when they cannot act solely in the project's interest.
|
||||
|
||||
## Evolving This Governance
|
||||
|
||||
The Maintainer Council should revisit this model as OpenShell grows. Signals that the project may need working groups, area-specific approvers, subproject governance, organization-balanced voting, or an elected steering body include:
|
||||
|
||||
- Decisions routinely stall because the Maintainer Council is too large or different project areas need delegated authority.
|
||||
- Contributors cannot find a clear path to greater responsibility.
|
||||
- One organization dominates project decisions or Maintainer membership.
|
||||
- Subprojects develop distinct contributor communities or release cadences.
|
||||
|
||||
Such changes are a sign of project growth rather than governance failure.
|
||||
|
||||
## Modifying This Governance
|
||||
|
||||
Changes to this governance document and its supporting governance documents require a two-thirds vote of all active Maintainers.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Maintainers
|
||||
|
||||
The current maintainers of OpenShell are listed below.
|
||||
|
||||
| Name | GitHub ID | Company/Organization |
|
||||
| --- | --- | --- |
|
||||
| Derek Carr | [@derekwaynecarr](https://github.com/derekwaynecarr) | Red Hat |
|
||||
| Matthew Grossman | [@matthewgrossman](https://github.com/matthewgrossman) | NVIDIA |
|
||||
| Kris Hicks | [@krishicks](https://github.com/krishicks) | NVIDIA |
|
||||
| Seth Jennings | [@sjenning](https://github.com/sjenning) | Red Hat |
|
||||
| Adrien Langou | [@alangou](https://github.com/alangou) | NVIDIA |
|
||||
| Evan Lezar | [@elezar](https://github.com/elezar) | NVIDIA |
|
||||
| Jim Meyer | [@purp](https://github.com/purp) | NVIDIA |
|
||||
| Piotr Mlocek | [@pimlock](https://github.com/pimlock) | NVIDIA |
|
||||
| Taylor Mutch | [@TaylorMutch](https://github.com/TaylorMutch) | NVIDIA |
|
||||
| John T. Myers | [@johntmyers](https://github.com/johntmyers) | NVIDIA |
|
||||
| Drew Newberry | [@drew](https://github.com/drew) | NVIDIA |
|
||||
| Mrunal Patel | [@mrunalp](https://github.com/mrunalp) | Red Hat |
|
||||
| Simon Scatton | [@SDAChess](https://github.com/SDAChess) | NVIDIA |
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
OpenShell is the safe, private runtime for autonomous AI agents. It provides sandboxed execution environments that protect your data, credentials, and infrastructure — governed by declarative YAML policies that prevent unauthorized file access, data exfiltration, and uncontrolled network activity.
|
||||
|
||||
OpenShell is built agent-first. The project ships with agent skills for everything from gateway troubleshooting to policy generation, and we expect contributors to use them.
|
||||
OpenShell is built agent-first. It ships public agent skills for using and operating OpenShell, plus separate repository-aware workflows for contributors and maintainers.
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -108,7 +108,7 @@ bash examples/sandbox-policy-quickstart/demo.sh
|
||||
OpenShell isolates each sandbox in its own container with policy-enforced egress routing. A lightweight gateway coordinates sandbox lifecycle, and every outbound connection is intercepted by the policy engine, which does one of three things:
|
||||
|
||||
- **Allows** — the destination and binary match a policy block.
|
||||
- **Routes for inference** — strips caller credentials, injects backend credentials, and forwards to the managed model.
|
||||
- **Binds credentials to endpoints** — injects provider credentials only after policy admits a request to a profile-authorized endpoint.
|
||||
- **Denies** — blocks the request and logs it.
|
||||
|
||||
| Component | Role |
|
||||
@@ -116,7 +116,7 @@ OpenShell isolates each sandbox in its own container with policy-enforced egress
|
||||
| **Gateway** | Control-plane API that coordinates sandbox lifecycle and acts as the auth boundary. |
|
||||
| **Sandbox** | Isolated runtime with container supervision and policy-enforced egress routing. |
|
||||
| **Policy Engine** | Enforces filesystem, network, and process constraints from application layer down to kernel. |
|
||||
| **Privacy Router** | Privacy-aware LLM routing that keeps sensitive context on sandbox compute. |
|
||||
| **Provider Access** | Profile-defined endpoints, binary policy, and endpoint-bound credential injection for model APIs and other services. |
|
||||
|
||||
OpenShell runs a gateway control plane that manages sandbox lifecycle through a configured compute driver. Supported compute platforms include Docker, Podman, MicroVM, and Kubernetes.
|
||||
|
||||
@@ -129,14 +129,16 @@ OpenShell applies defense in depth across four policy domains:
|
||||
| Filesystem | Prevents reads/writes outside allowed paths. | Locked at sandbox creation. |
|
||||
| Network | Blocks unauthorized outbound connections. | Hot-reloadable at runtime. |
|
||||
| Process | Blocks privilege escalation and dangerous syscalls. | Locked at sandbox creation. |
|
||||
| Inference | Reroutes model API calls to controlled backends. | Hot-reloadable at runtime. |
|
||||
| Providers | Grants endpoint-bound credentials and network access. | Hot-reloadable at runtime. |
|
||||
|
||||
Policies are declarative YAML files. Static sections (filesystem, process) are locked at creation; dynamic sections (network, inference) can be hot-reloaded on a running sandbox with `openshell policy set`.
|
||||
Policies are declarative YAML files. Static sections (filesystem, process) are locked at creation; network policy and provider attachments can be updated on a running sandbox.
|
||||
|
||||
## Providers
|
||||
|
||||
Agents need credentials — API keys, tokens, service accounts. OpenShell manages these as **providers**: named credential bundles that are injected into sandboxes at creation. The CLI auto-discovers credentials for recognized agents (Claude, Codex, OpenCode, Copilot) from your shell environment, or you can create providers explicitly with `openshell provider create`. Credentials never leak into the sandbox filesystem; they are injected as environment variables at runtime.
|
||||
|
||||
Inference access uses the same provider workflow. Attach an inference-capable provider to a sandbox, call the provider's native endpoint, and select the model in the client. Provider profiles contribute the endpoint policy and bind credential placeholders to the authorized destination.
|
||||
|
||||
## GPU Support (Experimental)
|
||||
|
||||
> **Experimental** — GPU passthrough works on supported hosts but is under active development. Expect rough edges and breaking changes.
|
||||
@@ -159,8 +161,8 @@ Docker-backed GPU sandboxes auto-select CDI when available and otherwise fall ba
|
||||
| [OpenCode](https://opencode.ai/) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | Works out of the box. Provider uses `OPENAI_API_KEY` or `OPENROUTER_API_KEY`. |
|
||||
| [Codex](https://developers.openai.com/codex) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | Works out of the box. Provider uses `OPENAI_API_KEY`. |
|
||||
| [GitHub Copilot CLI](https://docs.github.com/en/copilot/github-copilot-in-the-cli) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | Works out of the box. Provider uses `GITHUB_TOKEN` or `COPILOT_GITHUB_TOKEN`. |
|
||||
| [OpenClaw](https://openclaw.ai/) | [NemoClaw](https://github.com/NVIDIA/NemoClaw) | Run OpenClaw more securely inside NVIDIA OpenShell with managed inference using NemoClaw. |
|
||||
| [Hermes Agent](https://github.com/NousResearch/hermes-agent) | [NemoClaw](https://github.com/NVIDIA/NemoClaw) | Run Hermes Agent more securely inside NVIDIA OpenShell with managed inference using NemoClaw. |
|
||||
| [OpenClaw](https://openclaw.ai/) | [NemoClaw](https://github.com/NVIDIA/NemoClaw) | Run OpenClaw more securely inside NVIDIA OpenShell with the NemoClaw blueprint. |
|
||||
| [Hermes Agent](https://github.com/NousResearch/hermes-agent) | [NemoClaw](https://github.com/NVIDIA/NemoClaw) | Run Hermes Agent more securely inside NVIDIA OpenShell with the NemoClaw blueprint. |
|
||||
| [Ollama](https://ollama.com/) | [Community](https://github.com/NVIDIA/OpenShell-Community) | Launch with `openshell sandbox create --from ollama`. |
|
||||
| [Pi](https://pi.dev/) | [Community](https://github.com/NVIDIA/OpenShell-Community) | Launch with `openshell sandbox create --from pi`. |
|
||||
|
||||
@@ -172,9 +174,9 @@ Docker-backed GPU sandboxes auto-select CDI when available and otherwise fall ba
|
||||
| `openshell sandbox connect [name]` | SSH into a running sandbox. |
|
||||
| `openshell sandbox list` | List all sandboxes. |
|
||||
| `openshell provider create --type [type] --from-existing` | Create a credential provider from env vars. |
|
||||
| `openshell sandbox provider attach <sandbox> <provider>` | Attach a provider to a running sandbox. |
|
||||
| `openshell policy set <name> --policy file.yaml` | Apply or update a policy on a running sandbox. |
|
||||
| `openshell policy get <name>` | Show the active policy. |
|
||||
| `openshell inference set --provider <p> --model <m>` | Configure the `inference.local` endpoint. |
|
||||
| `openshell logs [name] --tail` | Stream sandbox logs. |
|
||||
| `openshell term` | Launch the real-time terminal UI for debugging. |
|
||||
|
||||
@@ -196,45 +198,49 @@ The TUI gives you a live, keyboard-driven view of your gateway and sandboxes. Na
|
||||
|
||||
## Community Sandboxes and BYOC
|
||||
|
||||
Use `--from` to create sandboxes from the [OpenShell Community](https://github.com/NVIDIA/OpenShell-Community) catalog, a local directory, or a container image:
|
||||
Use `--from` to create sandboxes from the [OpenShell Community](https://github.com/NVIDIA/OpenShell-Community) catalog or a container image:
|
||||
|
||||
```bash
|
||||
openshell sandbox create --from gemini # community catalog
|
||||
openshell sandbox create --from ./my-sandbox-dir # local Dockerfile
|
||||
docker build -t my-sandbox:latest ./my-sandbox-dir # Docker gateway
|
||||
openshell sandbox create --from my-sandbox:latest # Docker built image
|
||||
podman build -t localhost/my-sandbox:latest ./my-sandbox-dir # Podman gateway
|
||||
openshell sandbox create --from localhost/my-sandbox:latest # Podman built image
|
||||
openshell sandbox create --from registry.io/img:v1 # container image
|
||||
```
|
||||
|
||||
Build with the container engine used by your local gateway. For a remote
|
||||
gateway, push the image to a registry that the gateway can pull from.
|
||||
|
||||
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
|
||||
## Use OpenShell with Your Agent
|
||||
|
||||
Clone the repo and point your coding agent at it. The project includes agent skills that can answer questions, walk you through workflows, and diagnose problems — no issue filing required.
|
||||
OpenShell provides four portable skills for users and operators: CLI workflows (`openshell-cli`), gateway troubleshooting (`debug-openshell-cluster`), inference troubleshooting (`debug-inference`), and policy generation (`generate-sandbox-policy`). Install them with the Agent Skills CLI:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NVIDIA/OpenShell.git # or git@github.com:NVIDIA/OpenShell.git
|
||||
cd OpenShell
|
||||
# Point your agent here — it will discover the skills in .agents/skills/ automatically
|
||||
npx skills add NVIDIA/OpenShell
|
||||
```
|
||||
|
||||
Your agent can load skills for CLI usage (`openshell-cli`), gateway troubleshooting (`debug-openshell-cluster`), inference troubleshooting (`debug-inference`), policy generation (`generate-sandbox-policy`), and more. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full skills table.
|
||||
These public, installable skills live in [`skills/`](skills/) and use the installed CLI help and [published documentation](https://docs.nvidia.com/openshell/latest/index.html) as their sources of truth. They do not require an OpenShell source checkout.
|
||||
|
||||
## Built With Agents
|
||||
|
||||
OpenShell is developed using the same agent-driven workflows it enables. The `.agents/skills/` directory contains workflow automation that powers the project's development cycle:
|
||||
OpenShell is developed using the same agent-driven workflows it enables. Contributor and maintainer skills live separately in [`.agents/skills/`](.agents/skills/); they automate work on the OpenShell repository and are not included when users install the public skills:
|
||||
|
||||
- **Spike and build:** Investigate a problem with `create-spike`; a human accepts it with `state:accepted` or [roadmap](https://github.com/orgs/NVIDIA/projects/233) placement, or declines it. Accepted work can remain human-owned or enter the optional, human-gated `agent:*` planning and implementation workflow.
|
||||
- **Triage and route:** Community issues are assessed with `triage-issue`. Agents establish technical validity and impact; humans decide whether the project should act and where the work sits on the roadmap.
|
||||
- **Security review:** `review-security-issue` produces a severity assessment and remediation plan. `fix-security-issue` implements it.
|
||||
- **Policy authoring:** `generate-sandbox-policy` creates YAML policies from plain-language requirements or API documentation.
|
||||
- **Repository maintenance:** `sync-agent-infra`, `update-docs-from-commits`, and other internal workflows keep code, documentation, and agent infrastructure consistent.
|
||||
|
||||
All agent implementation work is human-gated: maintainers explicitly request a plan, agents propose it, maintainers approve it, and agents build. See [AGENTS.md](AGENTS.md) for the full workflow chain documentation.
|
||||
Agent implementation is human-directed: a user may request a phase directly, or maintainers may use the optional `agent:*` workflow to queue and approve planning and implementation. See [AGENTS.md](AGENTS.md) for the full workflow chain documentation.
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **Questions and discussion:** [GitHub Discussions](https://github.com/NVIDIA/OpenShell/discussions)
|
||||
- **Bug reports:** [GitHub Issues](https://github.com/NVIDIA/OpenShell/issues) — use the bug report template
|
||||
- **Security vulnerabilities:** See [SECURITY.md](SECURITY.md) — do not use GitHub Issues
|
||||
- **Agent-assisted help:** Clone the repo and use the agent skills in `.agents/skills/` for self-service diagnostics
|
||||
- **Agent-assisted help:** Install the public OpenShell skills with `npx skills add NVIDIA/OpenShell`
|
||||
|
||||
## Learn More
|
||||
|
||||
@@ -250,7 +256,7 @@ All agent implementation work is human-gated: maintainers explicitly request a p
|
||||
|
||||
## Contributing
|
||||
|
||||
OpenShell is built agent-first. Issues should include a user story, problem statement, impact, and acceptance criteria. The impact should explain the consequences of the current behavior and why existing workarounds are insufficient. Feature requests also require a workflow-level proposed design and alternatives; bug reports add reproduction steps, environment details, and relevant logs. Once maintainers accept work, contributors should use the skills in `.agents/skills/` to investigate the current code and behavior, implement the change, and verify it. If an issue contains earlier diagnostics, verify them rather than relying on them. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full agent skills table, contribution workflow, and development setup.
|
||||
OpenShell is built agent-first. Issues should include a user story, problem statement, impact, and acceptance criteria. The impact should explain the consequences of the current behavior and why existing workarounds are insufficient. Feature requests also require a workflow-level proposed design and alternatives; bug reports add reproduction steps, environment details, and relevant logs. Once work is authorized through the project workflow or a direct request, contributors should use the skills in `.agents/skills/` to investigate the current code and behavior, implement the change, and verify it. If an issue contains earlier diagnostics, verify them rather than relying on them. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full agent skills table, contribution workflow, and development setup.
|
||||
|
||||
## Telemetry
|
||||
|
||||
@@ -258,7 +264,15 @@ OpenShell collects anonymous telemetry to help improve the project for developer
|
||||
|
||||
Disable telemetry at runtime by setting `OPENSHELL_TELEMETRY_ENABLED=false` on the gateway deployment. For Helm installs, set `server.telemetryEnabled=false`. OpenShell propagates this deployment setting into sandbox supervisor environments so sandbox-side telemetry collection is disabled as well.
|
||||
|
||||
You can also compile telemetry out entirely. Telemetry support is a default-on `telemetry` Cargo feature; building with `--no-default-features` produces binaries that contain no telemetry endpoint, no telemetry HTTP client, and no emission code. Build telemetry-free artifacts with, for example, `cargo build --release -p openshell-server --no-default-features` (gateway) and the equivalent for `openshell-sandbox` and `openshell-driver-vm`. With telemetry compiled out, the gateway emits nothing and reports telemetry disabled to the sandboxes it launches.
|
||||
You can also compile telemetry out entirely. Telemetry support is a default-on `telemetry` Cargo feature, and each crate that carries it also defines a `defaults-without-telemetry` alias covering every other default feature. Build telemetry-free artifacts with `--no-default-features --features defaults-without-telemetry`:
|
||||
|
||||
```shell
|
||||
cargo build --release -p openshell-gateway --no-default-features --features defaults-without-telemetry
|
||||
cargo build --release -p openshell-sandbox --no-default-features --features defaults-without-telemetry
|
||||
cargo build --release -p openshell-driver-vm --no-default-features --features defaults-without-telemetry
|
||||
```
|
||||
|
||||
The resulting binaries contain no telemetry endpoint, no telemetry HTTP client, and no emission code. With telemetry compiled out, the gateway emits nothing and reports telemetry disabled to the sandboxes it launches. Cargo has no way to subtract a single default feature, so `defaults-without-telemetry` must be paired with `--no-default-features`; passing it on its own leaves the defaults in place and fails the build rather than producing a binary that still emits.
|
||||
|
||||
Telemetry events are limited to anonymous operational categories and counts, such as sandbox lifecycle outcomes, provider profile buckets, policy decision counts, and aggregate network activity denial categories. OpenShell telemetry does not collect sandbox names or IDs, hostnames, file paths, binary paths, prompts, credentials, provider names, model names, or user content.
|
||||
|
||||
|
||||
+170
-5
@@ -136,8 +136,6 @@ def test_multiply(sandbox):
|
||||
|---|---|---|
|
||||
| `sandbox_client` | session | gRPC client connected to the active gateway |
|
||||
| `sandbox` | function | Factory returning a `Sandbox` context manager |
|
||||
| `inference_client` | session | Client for managing inference routes |
|
||||
| `mock_inference_route` | session | Creates a mock OpenAI-protocol route for tests |
|
||||
|
||||
### Rust CLI E2E (`e2e/rust/`)
|
||||
|
||||
@@ -148,7 +146,12 @@ lifecycle management, output parsing, and cleanup.
|
||||
Suites:
|
||||
|
||||
- Common suite (`--features e2e`) - driver-neutral CLI behavior, sandbox lifecycle, sync, port forwarding, policy, and provider tests.
|
||||
- Docker suite (`--features e2e-docker`) - common suite plus Docker-only coverage such as Dockerfile image builds, Docker preflight checks, and managed Docker gateway start.
|
||||
- CLI conformance (`openshell-conformance`) - the portable deployment smoke
|
||||
scenario plus focused tests for its reusable command runner.
|
||||
- Driver suites (`--features e2e-docker`, `e2e-podman`, `e2e-kubernetes`, or
|
||||
`e2e-vm`) - CLI conformance plus the common and driver-specific coverage for
|
||||
the selected deployment.
|
||||
- Docker suite (`--features e2e-docker`) - includes Docker-only coverage such as Dockerfile image builds, Docker preflight checks, and managed Docker gateway start.
|
||||
- Docker GPU suite (`--features e2e-docker-gpu`) - Docker suite plus GPU sandbox smoke coverage.
|
||||
- VM suite (`--features e2e-vm`) - runs e2e tests on a VM.
|
||||
- Kubernetes credential-driver suite (`--features e2e-kubernetes-credential-drivers`) - targeted Kubernetes Secrets and Vault provider credential storage coverage.
|
||||
@@ -166,10 +169,48 @@ key.
|
||||
Run the Docker-backed Rust CLI e2e suite:
|
||||
|
||||
```shell
|
||||
mise run e2e:rust
|
||||
mise run e2e:docker
|
||||
```
|
||||
|
||||
Run the Podman-backed Rust CLI e2e suite:
|
||||
Run the minimal portable CLI conformance profile against the gateway selected
|
||||
in your OpenShell CLI configuration:
|
||||
|
||||
```shell
|
||||
mise run e2e:cli-conformance
|
||||
```
|
||||
|
||||
The gateway must already be installed, reachable, and selected before the task
|
||||
starts. The task does not provision a gateway or select a compute driver. Set
|
||||
`OPENSHELL_BIN` to test a prebuilt CLI; otherwise, the task builds the CLI from
|
||||
the current checkout.
|
||||
|
||||
The phase-1 scenario verifies the complete CLI-to-gateway-to-driver path without
|
||||
depending on how the gateway was installed or which driver is configured. It
|
||||
requires machine-readable gRPC status, creates a uniquely named detached
|
||||
sandbox with `--from base`, verifies the sandbox is `Ready` by finding its
|
||||
unique name in paginated JSON list output, executes `echo` with a run-specific
|
||||
marker, deletes the sandbox, and verifies that its name no longer appears.
|
||||
Driver suites enable the same profile
|
||||
instead of maintaining a separate smoke implementation. Sandbox lifecycle,
|
||||
label matrices, VM overlay, and TLS-key permission assertions remain regular
|
||||
E2E coverage.
|
||||
|
||||
Each invocation prints a ten-character run ID before creating resources.
|
||||
Conformance sandboxes use names such as `ct-<run-id>-01`. The runner tracks the
|
||||
exact name and uses it for cleanup; phase 1 does not add ownership labels.
|
||||
|
||||
The runner deletes owned resources after both success and failure. If the test
|
||||
process is interrupted before cleanup, locate leftovers without touching
|
||||
unrelated gateway state:
|
||||
|
||||
```shell
|
||||
openshell sandbox list --output json
|
||||
openshell sandbox delete <sandbox-name>
|
||||
```
|
||||
|
||||
Gateway-backed Rust E2E tasks build the standalone conformance CLI, run its
|
||||
registered scenarios against the configured gateway, then run any lane-specific
|
||||
Rust tests that still apply. Run the Podman-backed Rust CLI e2e suite:
|
||||
|
||||
```shell
|
||||
mise run e2e:podman
|
||||
@@ -189,6 +230,128 @@ Secrets and Vault storage backends one at a time:
|
||||
mise run e2e:kubernetes:credential-drivers
|
||||
```
|
||||
|
||||
### Kubernetes E2E (`e2e/rust/e2e-kubernetes.sh`)
|
||||
|
||||
Kubernetes e2e tests deploy an OpenShell gateway into a real Kubernetes cluster
|
||||
via Helm and run the Rust e2e suite against it. On vanilla Kubernetes the harness
|
||||
reaches the gateway through `kubectl port-forward`; on OpenShift it instead uses a
|
||||
passthrough Route secured with mandatory mTLS (see the OpenShift note below).
|
||||
|
||||
Run with an ephemeral k3d cluster (macOS; created and torn down automatically):
|
||||
|
||||
```shell
|
||||
mise run e2e:kubernetes
|
||||
```
|
||||
|
||||
Target an existing cluster (kind, k3d, or OpenShift):
|
||||
|
||||
```shell
|
||||
OPENSHELL_E2E_KUBE_CONTEXT=my-context mise run e2e:kubernetes
|
||||
```
|
||||
|
||||
Scope to a single test for local debugging:
|
||||
|
||||
```shell
|
||||
OPENSHELL_E2E_KUBE_TEST=smoke mise run e2e:kubernetes
|
||||
```
|
||||
|
||||
**OpenShift**: when the target cluster exposes the `route.openshift.io` API
|
||||
group, the harness automatically applies SCC-compatible Helm overrides, grants
|
||||
the required SCCs (`privileged` to `openshell-sandbox`, and `anyuid` to the
|
||||
PostgreSQL fixture for DB scenarios), and drives the gateway through a
|
||||
passthrough Route with mandatory mTLS instead of port-forward. No extra flags are
|
||||
needed, but `oc` must be installed and authenticated against the target cluster
|
||||
with permission to modify SCC bindings (`oc adm policy add-scc-to-user`) — the
|
||||
harness exits early if `oc` is missing. The SCC grants and extracted client
|
||||
mTLS material are removed during cleanup, including on failure or interrupt.
|
||||
|
||||
On a **remote** cluster, drop the `e2e-host-gateway` feature. Those tests rely
|
||||
on the sandbox-side `host.openshell.internal` alias reaching the machine running
|
||||
the tests, which is unreachable from pods on a remote cluster, so they fail.
|
||||
Left enabled, the `host_gateway_alias` suite fails because
|
||||
`host.openshell.internal` does not resolve inside the pod, so the gateway
|
||||
SSRF-denies the request (`DNS resolution failed` / `ssrf_denied`) — a networking
|
||||
property of remote pods, not a gateway or transport fault. Override
|
||||
`OPENSHELL_E2E_KUBERNETES_FEATURES` to exclude it:
|
||||
|
||||
```shell
|
||||
OPENSHELL_E2E_KUBE_CONTEXT=$(oc config current-context) \
|
||||
OPENSHELL_E2E_KUBERNETES_FEATURES="e2e,e2e-kubernetes" \
|
||||
mise run e2e:kubernetes
|
||||
```
|
||||
|
||||
On an existing cluster the harness builds the CLI from your branch but pulls the
|
||||
**published** gateway/supervisor image (default tag `latest`). The CLI and the
|
||||
image can therefore be different versions. If tests fail because of this version
|
||||
difference — for example, sandbox tests fail with `Pod exists with phase: Failed`
|
||||
or connect-based tests stall because the deployed image predates a feature your
|
||||
branch CLI needs — set `IMAGE_TAG` to an image that matches your branch.
|
||||
|
||||
The `latest` tag lags to the last semver release, so it is often older than
|
||||
`main`. Two better choices:
|
||||
|
||||
- `IMAGE_TAG=dev` — a floating tag that tracks the latest `main` build. Good for
|
||||
an ad-hoc run when your branch is close to `main` HEAD. Because it floats, two
|
||||
runs on different days can pull different images, so it is not reproducible.
|
||||
- **Pin the exact commit your branch is based on** — deterministic and immune to
|
||||
a floating tag moving. Published tags are the full 40-char git SHA (semver tags
|
||||
without a `v` prefix also exist but only for released versions):
|
||||
|
||||
```shell
|
||||
OPENSHELL_E2E_KUBE_CONTEXT=$(oc config current-context) \
|
||||
OPENSHELL_E2E_KUBERNETES_FEATURES="e2e,e2e-kubernetes" \
|
||||
IMAGE_TAG=$(git rev-parse "$(git merge-base HEAD upstream/main)") \
|
||||
mise run e2e:kubernetes
|
||||
```
|
||||
|
||||
To pin a specific released version, use its semver tag without a `v` prefix
|
||||
(`0.0.115`, not `v0.0.115`):
|
||||
|
||||
```shell
|
||||
OPENSHELL_E2E_KUBE_CONTEXT=$(oc config current-context) \
|
||||
OPENSHELL_E2E_KUBERNETES_FEATURES="e2e,e2e-kubernetes" \
|
||||
IMAGE_TAG=0.0.115 \
|
||||
mise run e2e:kubernetes
|
||||
```
|
||||
|
||||
A semver tag matches a released commit, which may be behind `main`; if your
|
||||
branch CLI needs a newer feature, pin the SHA of your branch's base instead.
|
||||
|
||||
Confirm a tag exists before relying on it (set `TAG` to the tag you plan to use):
|
||||
|
||||
```shell
|
||||
TAG=0.0.115
|
||||
skopeo inspect "docker://ghcr.io/nvidia/openshell/gateway:${TAG}"
|
||||
```
|
||||
|
||||
`IMAGE_TAG` sets only the gateway/supervisor image; the CLI under test is always
|
||||
built from your branch. To validate against images from your exact commit
|
||||
instead, build and push them and point `OPENSHELL_REGISTRY`/`IMAGE_TAG` at them.
|
||||
|
||||
Available task variants:
|
||||
|
||||
| Task | Purpose |
|
||||
|---|---|
|
||||
| `e2e:kubernetes` | Default Rust e2e against Helm-deployed gateway |
|
||||
| `e2e:kubernetes:db` | All database backend scenarios (SQLite + external PostgreSQL) |
|
||||
| `e2e:kubernetes:sidecar` | Supervisor sidecar topology overlay |
|
||||
| `e2e:kubernetes:credential-drivers` | Kubernetes Secrets and Vault credential storage |
|
||||
| `e2e:kubernetes:workspace-managed` | Managed workspace mode (auto-created namespaces) |
|
||||
| `e2e:kubernetes:workspace-operator` | Operator workspace mode (pre-provisioned namespaces) |
|
||||
| `e2e:kubernetes:v1alpha1` | Agent Sandbox v1alpha1 compatibility |
|
||||
| `e2e:kubernetes:external-driver` | External Kubernetes driver sidecar |
|
||||
|
||||
Kubernetes e2e environment variables:
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `OPENSHELL_E2E_KUBE_CONTEXT` | kubectl context for an existing cluster (skips k3d creation) |
|
||||
| `OPENSHELL_E2E_KUBE_TEST` | Scope to a single test (e.g. `smoke`) |
|
||||
| `OPENSHELL_E2E_KUBE_EXTRA_VALUES` | Colon-separated additional Helm values files |
|
||||
| `OPENSHELL_E2E_KUBERNETES_FEATURES` | Cargo feature flags (default: `e2e,e2e-host-gateway,e2e-kubernetes`) |
|
||||
| `IMAGE_TAG` | Gateway/supervisor image tag (default: `latest` for existing clusters) |
|
||||
| `OPENSHELL_REGISTRY` | Image registry prefix (default: `ghcr.io/nvidia/openshell`) |
|
||||
|
||||
Run a single test directly with cargo:
|
||||
|
||||
```shell
|
||||
@@ -220,3 +383,5 @@ The harness (`e2e/rust/src/harness/`) provides:
|
||||
| `OPENSHELL_GATEWAY_ENDPOINT` | Run E2E tests against an existing plaintext HTTP gateway endpoint |
|
||||
| `OPENSHELL_E2E_DRIVER` | Driver name exported by the e2e gateway wrapper (`docker`, `podman`, or `vm`) |
|
||||
| `OPENSHELL_E2E_CREDENTIAL_DRIVERS` | Enables the Kubernetes credential-driver fixture path in `e2e/with-kube-gateway.sh` |
|
||||
| `OPENSHELL_E2E_KUBE_CONTEXT` | kubectl context for Kubernetes e2e (skips ephemeral k3d) |
|
||||
| `OPENSHELL_E2E_KUBE_TEST` | Scope Kubernetes e2e to a single test by name |
|
||||
|
||||
+19
-18
@@ -7,7 +7,7 @@ the **Supervisor**.
|
||||
|
||||
The CLI, SDK, and TUI provide user-facing access. The gateway is the
|
||||
authenticated control plane: it owns API access, durable state, policy and
|
||||
settings delivery, provider and inference configuration, and relay
|
||||
settings delivery, provider configuration and attachments, and relay
|
||||
coordination. The supervisor runs inside every sandbox workload and is the local
|
||||
security boundary. It launches the agent as a restricted child process and
|
||||
enforces policy where process identity, filesystem access, network egress, and
|
||||
@@ -50,7 +50,6 @@ flowchart TB
|
||||
subgraph DP["Sandbox Data Plane"]
|
||||
SUP["Supervisor"]
|
||||
PROXY["Policy proxy"]
|
||||
ROUTER["Inference router"]
|
||||
POLICY["OPA policy engine"]
|
||||
AGENT["Restricted agent process"]
|
||||
end
|
||||
@@ -81,8 +80,7 @@ flowchart TB
|
||||
AGENT -->|"all ordinary egress"| PROXY
|
||||
PROXY -->|"evaluate"| POLICY
|
||||
PROXY -->|"allowed traffic"| EXT["External services"]
|
||||
PROXY -->|"inference.local"| ROUTER
|
||||
ROUTER -->|"managed inference"| MODEL["Inference backends"]
|
||||
PROXY -->|"profile-authorized traffic"| MODEL["Model providers"]
|
||||
```
|
||||
|
||||
## Core Boundaries
|
||||
@@ -90,14 +88,13 @@ flowchart TB
|
||||
| Component | Boundary |
|
||||
|---|---|
|
||||
| CLI, SDK, TUI | User-facing management surfaces. They talk to the gateway and do not need to know which infrastructure drivers are active. |
|
||||
| Gateway | Authenticated control plane, API server, durable state, policy and settings delivery, provider and inference config, supervisor session ownership, and relay coordination. |
|
||||
| Gateway | Authenticated control plane, API server, durable state, policy and settings delivery, provider config, supervisor session ownership, and relay coordination. |
|
||||
| Compute subsystem | Sandbox lifecycle semantics: creation, deletion, watching, reconciliation, and state transitions. Platform provisioning details belong to the compute driver. |
|
||||
| Credentials subsystem | Logical provider and credential resolution. Secret storage and platform-native credential access belong to credentials drivers. |
|
||||
| Control-plane identity | Authentication and authorization for users, operators, and API clients. External identity verification belongs to identity drivers. |
|
||||
| Sandbox identity | Workload identity for supervisors and sandbox-to-sandbox authorization. Identity issuance or verification belongs to sandbox identity drivers. |
|
||||
| Supervisor | Sandbox-local security boundary. It prepares isolation, fetches config, injects credentials, runs relay endpoints, starts the proxy, and launches restricted agent processes. |
|
||||
| Policy proxy | Mandatory egress path for agent traffic. It enforces destination, binary identity, SSRF, TLS/L7, credential injection, and inference interception rules. |
|
||||
| Inference router | Sandbox-local forwarding for `https://inference.local` to configured model backends. |
|
||||
| Policy proxy | Mandatory egress path for agent traffic. It enforces destination, binary identity, SSRF, TLS/L7, and endpoint-bound provider credential injection. |
|
||||
|
||||
## Integrating with the Ecosystem
|
||||
|
||||
@@ -109,14 +106,14 @@ platforms that already provide them.
|
||||
|
||||
The gateway owns OpenShell control-plane semantics: sandbox state, lifecycle
|
||||
ordering, policy and settings resolution, credential mapping, authorization,
|
||||
inference configuration, and relay coordination. Drivers translate those
|
||||
semantics into platform-native operations. They should stay thin, preserve
|
||||
and relay coordination. Drivers translate those semantics into platform-native
|
||||
operations. They should stay thin, preserve
|
||||
native behavior by default, and report platform lifecycle events back through
|
||||
the shared contracts.
|
||||
|
||||
The supervisor owns OpenShell sandbox semantics. Filesystem policy, process
|
||||
privilege reduction, network proxying, inference interception, credential
|
||||
injection, security logging, and gateway relay behavior should remain
|
||||
privilege reduction, network proxying, endpoint-bound credential injection,
|
||||
security logging, and gateway relay behavior should remain
|
||||
consistent across runtimes.
|
||||
|
||||
This keeps OpenShell usable in local single-player setups, Kubernetes
|
||||
@@ -128,7 +125,7 @@ ecosystem.
|
||||
|
||||
The gateway and sandbox split control-plane authority from runtime enforcement.
|
||||
The gateway owns durable platform state: sandboxes, policy revisions, runtime
|
||||
settings, provider records, inference configuration, session records, and
|
||||
settings, provider records and profiles, session records, and
|
||||
authorization decisions. A sandbox owns the local execution boundary: process
|
||||
identity, filesystem access, network egress, credential injection, local logs,
|
||||
and the agent child process.
|
||||
@@ -141,13 +138,17 @@ bridge networks, port mappings, NAT traversal, or bespoke tunnels. The common
|
||||
runtime requirement is narrower: the supervisor must be able to reach the
|
||||
gateway.
|
||||
|
||||
The Windows MXC driver is an explicit exception. It launches and monitors a
|
||||
one-shot workload in the driver, self-reports readiness, and does not expose a
|
||||
supervisor session, interactive connect, live policy delivery, or governed egress.
|
||||
The compute-driver capability contract identifies whether a driver reports
|
||||
runtime readiness. Most drivers use the supervisor session model above. A
|
||||
driver that sets `driver_reports_runtime_readiness` may self-report readiness
|
||||
without a supervisor session. Every driver receives the canonical create-time
|
||||
policy in `DriverSandboxSpec`; drivers without a supervisor use the existing
|
||||
sandbox configuration API for later revisions. The Windows MXC driver reports
|
||||
its own readiness and does not expose interactive connect or governed egress.
|
||||
|
||||
The gateway delivers desired state; the sandbox applies it locally. Policy,
|
||||
settings, credentials, and inference routes flow from the gateway to the
|
||||
supervisor. The supervisor validates and applies what can change at runtime,
|
||||
settings, provider attachments, and credential bindings flow from the gateway
|
||||
to the supervisor. The supervisor validates and applies what can change at runtime,
|
||||
keeps last-known-good config when refresh fails, and leaves static isolation
|
||||
controls in place until the sandbox is recreated.
|
||||
|
||||
@@ -166,7 +167,7 @@ that crate's `README.md`.
|
||||
| Document | Purpose |
|
||||
|---|---|
|
||||
| [Gateway](gateway.md) | Gateway control plane, auth, APIs, persistence, settings, and relay coordination. |
|
||||
| [Sandbox](sandbox.md) | Sandbox supervisor, child process isolation, proxy, credentials, inference, connect, and logs. |
|
||||
| [Sandbox](sandbox.md) | Sandbox supervisor, child process isolation, proxy, provider credentials, connect, and logs. |
|
||||
| [Sandbox Limits](sandbox-limits.md) | Sandbox supervisor and egress safety ceilings, ownership rules, current enforcement, and known gaps. |
|
||||
| [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. |
|
||||
|
||||
+199
-35
@@ -10,8 +10,9 @@ OpenShell builds these main artifacts:
|
||||
|
||||
| Artifact | Source |
|
||||
|---|---|
|
||||
| Gateway binary | `crates/openshell-server` |
|
||||
| Gateway binary | `crates/openshell-gateway` |
|
||||
| CLI binaries and system packages | `crates/openshell-cli` plus release packaging |
|
||||
| E2E conformance CLI | `crates/openshell-conformance-cli` |
|
||||
| Python SDK wheel | `python/openshell` |
|
||||
| TypeScript SDK package | `sdk/typescript` |
|
||||
| Gateway container image | `deploy/docker/Dockerfile.gateway` |
|
||||
@@ -24,22 +25,47 @@ Sandbox community images are built outside this repository.
|
||||
|
||||
## Build Features
|
||||
|
||||
Rust builds require Rust 1.94 or newer. TLS and certificate generation use
|
||||
AWS-LC, including the CLI and standalone examples. Native and cross-build
|
||||
environments must provide the C toolchain required by aws-lc-sys; the Nix
|
||||
development shells provide static AWS-LC libraries.
|
||||
|
||||
SQLx uses AWS-LC with native certificate roots. The server enables
|
||||
`sqlx-core/rustls-native-certs` directly because SQLx's facade does not expose
|
||||
that root selection independently of the crypto provider. Credential storage
|
||||
continues to use the same AES-256-GCM envelope format across backend changes.
|
||||
|
||||
Anonymous telemetry emission is gated behind a default-on `telemetry` Cargo
|
||||
feature. It is defined in `openshell-core` (where the emission code, HTTP
|
||||
client, and endpoint live) and forwarded by the binary crates that emit or
|
||||
collect telemetry: `openshell-server` (gateway), `openshell-sandbox`
|
||||
collect telemetry: `openshell-gateway`, `openshell-sandbox`
|
||||
(supervisor), and `openshell-driver-vm`. Every crate depends on
|
||||
`openshell-core` with `default-features = false`, so the binary crate's feature
|
||||
is the single switch that enables `openshell-core/telemetry` for its build
|
||||
graph. In-process drivers (`docker`, `kubernetes`, `podman`) inherit the
|
||||
gateway's setting through feature unification and carry no passthrough.
|
||||
|
||||
Building a binary with `--no-default-features` compiles out telemetry entirely:
|
||||
no endpoint, no telemetry HTTP client, and no emission code. With telemetry
|
||||
compiled out, `telemetry::enabled()` is always `false` and the `emit_*` helpers
|
||||
are no-ops, so the data-model types stay available and dependent crates compile
|
||||
unchanged. The runtime `OPENSHELL_TELEMETRY_ENABLED` switch remains the way to
|
||||
disable telemetry in a default (telemetry-enabled) build.
|
||||
Building a binary without the `telemetry` feature compiles out telemetry
|
||||
entirely: no endpoint, no telemetry HTTP client, and no emission code. With
|
||||
telemetry compiled out, `telemetry::enabled()` is always `false` and the
|
||||
`emit_*` helpers are no-ops, so the data-model types stay available and
|
||||
dependent crates compile unchanged. The runtime `OPENSHELL_TELEMETRY_ENABLED`
|
||||
switch remains the way to disable telemetry in a default (telemetry-enabled)
|
||||
build.
|
||||
|
||||
Cargo cannot subtract a single default feature, so each of the three binary
|
||||
crates also defines a `defaults-without-telemetry` alias listing every default
|
||||
except `telemetry`. Telemetry-free builds use
|
||||
`--no-default-features --features defaults-without-telemetry` and stay correct
|
||||
as the default set grows, instead of dropping unrelated defaults the way a bare
|
||||
`--no-default-features` does on `openshell-sandbox`. The alias is a keep-list,
|
||||
not a switch: enabling it on top of the defaults would otherwise yield a
|
||||
telemetry-on binary that reads as telemetry-free, so each crate root carries a
|
||||
`compile_error!` for the `telemetry` + `defaults-without-telemetry` combination.
|
||||
`rust:verify:defaults-without-telemetry` guards both properties — that each
|
||||
alias still equals its crate's defaults minus `telemetry`, and that the
|
||||
mutual-exclusion error is wired up — and `rust:verify:telemetry-off` builds
|
||||
through the alias and inspects the resulting binaries for telemetry markers.
|
||||
|
||||
Supervisor upstream TLS root-store selection is controlled by the
|
||||
`bundled-ca-roots` Cargo feature (on by default). Default builds use Mozilla
|
||||
@@ -131,6 +157,28 @@ in platform-specific Nix development shells through reusable workflows and the
|
||||
shared `build-rust-binary` action. The image build downloads each binary artifact
|
||||
into the staging directory before running Buildx.
|
||||
|
||||
The Nix flake exposes one development shell with target-specific toolchains.
|
||||
The shared `mkToolchain` function in `nix/toolchain/default.nix` assembles native
|
||||
libraries and Cargo environment settings. Linux and Darwin modules select the
|
||||
compiler and sysroot and generate the compiler wrapper for their platform.
|
||||
The `nix/toolchain/glibc-2.28/` directory contains the pinned glibc build,
|
||||
GCC environment, and sysroot assembly used by GNU Linux targets.
|
||||
The glibc build reuses a pinned historical Nixpkgs recipe with current build
|
||||
tools; its headers, shared libraries, and static archives are assembled into
|
||||
the sysroot from separate outputs.
|
||||
Each toolchain supplies its compiler driver, assembler, archiver, native
|
||||
libraries, and Cargo environment through derivation passthru. The shell omits
|
||||
an implicit host C compiler; Cargo builds select the appropriate tools with
|
||||
`--target`. GNU targets use a glibc 2.28 sysroot and static GCC runtimes, while
|
||||
musl targets produce static executables.
|
||||
|
||||
On macOS, the shell also provides a native Darwin toolchain with static Z3
|
||||
and AWS-LC. Its Clang driver uses the pinned, unprocessed Apple SDK so system
|
||||
library stubs, including libiconv and libc++, retain their Apple install names.
|
||||
System libraries and frameworks remain dynamically linked. The deployment
|
||||
target matches the Nix host platform's minimum macOS version. The Rust toolchain
|
||||
does not propagate Nix's replacement system libraries into the link environment.
|
||||
|
||||
Gateway and supervisor binaries staged into branch E2E, Release Dev, and Release
|
||||
Tag images are compiled through `cargo auditable` (pinned in `mise.toml`), which
|
||||
embeds a `.dep-v0` section describing the Rust dependencies actually compiled
|
||||
@@ -141,14 +189,15 @@ is a different artifact from the source SBOM produced by `syft dir:.` in
|
||||
`tasks/sbom.toml`, which describes the checkout, and from the image SBOM
|
||||
attestation below, which describes a published image.
|
||||
|
||||
The shared binary build action compiles release artifacts with `cargo auditable`.
|
||||
The shared binary build action uses the default Nix shell and compiles release
|
||||
artifacts with `cargo auditable build --target <triple>`. Verification and upload
|
||||
read binaries from `target/<triple>/release/`.
|
||||
Branch E2E, Release Dev, and Release Tag image jobs stage those same artifacts
|
||||
instead of rebuilding binaries in Docker. Each binary build scans its output with
|
||||
Syft and requires at least one decoded Cargo package before uploading the
|
||||
artifact. Darwin builds replace Nix's `libiconv` load command with the macOS
|
||||
system install name, ad-hoc sign the modified binary, and fail if `otool -L`
|
||||
reports any remaining `/nix/store` dependency. Runtime and Syft verification
|
||||
run after that normalization. The CI image gains the pinned `cargo-auditable`
|
||||
artifact. The action checks each binary's `--version` output and leaves its
|
||||
linkage as produced by the Nix toolchain, without post-link rewriting or
|
||||
platform-specific linkage checks. The CI image gains the pinned `cargo-auditable`
|
||||
tool through `mise install --locked` but ships no auditable OpenShell binary of
|
||||
its own.
|
||||
|
||||
@@ -240,6 +289,13 @@ pulls and explicit publication. OCI pulls require a trusted manifest digest
|
||||
and retain that provenance with the local entry; mutable tags are used only
|
||||
for explicit publication.
|
||||
|
||||
CLI conformance runs after target provisioning. Action-free scenarios operate
|
||||
only through the configured OpenShell CLI. A versioned conformance plan may add
|
||||
an ordered sequence of target-supplied host-side actions, such as a gateway
|
||||
restart, while the scenario remains responsible for black-box sandbox
|
||||
continuity checks. The plan exposes opaque executable paths and timeouts rather
|
||||
than driver or package-manager configuration; target setup owns those details.
|
||||
|
||||
## Python Wheel Packaging
|
||||
|
||||
The generated protobuf/gRPC stubs under `python/openshell/_proto/` are gitignored
|
||||
@@ -272,7 +328,7 @@ the release tag.
|
||||
|
||||
## CI and E2E
|
||||
|
||||
Required checks run on GitHub Actions. Workflows that use NVIDIA self-hosted runners trigger from copy-pr-bot mirror branches, so trusted PRs are mirrored into `pull-request/<N>` branches before those workflows run. `main` also uses GitHub merge queue so the final queued integration commit is validated before it merges.
|
||||
Required checks run on GitHub Actions. Pull-request workflows that use NVIDIA self-hosted runners trigger from copy-pr-bot mirror branches, so trusted PRs are mirrored into `pull-request/<N>` branches before those workflows run. `main` also uses GitHub merge queue so the final queued integration commit is validated before it merges.
|
||||
|
||||
The high-level CI model:
|
||||
|
||||
@@ -289,22 +345,34 @@ disables emission for Rust tests, E2E runs, and release canaries. This prevents
|
||||
synthetic activity from contributing to product usage metrics.
|
||||
|
||||
Static security checks are deliberately outside the mirror-branch path. They run
|
||||
directly on GitHub-hosted runners with no secrets, so the pull-request-triggered
|
||||
ones also cover fork pull requests, and none of them consume NVIDIA self-hosted
|
||||
capacity. Scanner jobs request `security-events: write` and upload SARIF to Code
|
||||
Scanning directly on every event they run on, including fork and Dependabot pull
|
||||
requests, which Code Scanning permits for `pull_request` runs despite their
|
||||
read-only `GITHUB_TOKEN`. Each scanner also retains its report as a workflow
|
||||
artifact. No privileged intermediate workflow relays those uploads.
|
||||
directly on GitHub-hosted runners and none of them consume NVIDIA self-hosted
|
||||
capacity. The change-oriented ones receive no secrets, so they also cover fork
|
||||
pull requests. Codex Security release qualification is the exception: it needs a
|
||||
scoped API key, which routes its model calls to NVIDIA-hosted inference while
|
||||
the job itself stays GitHub-hosted. That placement is load-bearing rather than
|
||||
incidental: on the repository self-hosted runner the scan agent executes no
|
||||
shell commands at all, so its preflight never scopes the diff and it seals no
|
||||
draft. Scanner jobs request `security-events: write` and upload SARIF to Code
|
||||
Scanning directly on every event they run on, including fork and Dependabot
|
||||
pull requests, which Code Scanning permits for
|
||||
`pull_request` runs despite their read-only `GITHUB_TOKEN`. No privileged
|
||||
intermediate workflow relays those uploads. Manually dispatched Codex Security
|
||||
runs are the one opt-in exception, described below. Report retention differs by
|
||||
scanner: Actionlint, Zizmor, and CodeQL keep their reports as workflow artifacts,
|
||||
and Codex Security keeps no raw report.
|
||||
Triggers differ by workflow: `.github/workflows/workflow-security.yml` runs on
|
||||
`pull_request`, `merge_group`, `main`, and a weekly schedule;
|
||||
`.github/workflows/dependency-review.yml` runs on `pull_request` and
|
||||
`merge_group` only, because it needs a base and head commit to compare; and
|
||||
`merge_group` only, because it needs a base and head commit to compare;
|
||||
`.github/workflows/codeql.yml` runs nightly on the default branch (`main`) via
|
||||
`schedule`, with `workflow_dispatch` kept for manual diagnostics. CodeQL does
|
||||
`schedule`, with `workflow_dispatch` kept for manual diagnostics; and
|
||||
`.github/workflows/codex-security.yml` runs on pushed `v*.*.*-pre.*` tags, and is
|
||||
also callable through `workflow_call` and `workflow_dispatch`. CodeQL does
|
||||
not run on `pull_request`, `merge_group`, or pushes to `main`, so it reports
|
||||
repository-level Code Scanning state on the default branch instead of per-PR
|
||||
results, and its four-language matrix stays off the per-change critical path.
|
||||
Codex Security is release-scoped rather than change-scoped, so it never runs on
|
||||
a pull request or merge group.
|
||||
|
||||
- **Actionlint and Zizmor** analyze the workflow definitions themselves.
|
||||
Repository configuration lives in `.github/actionlint.yml` (self-hosted runner
|
||||
@@ -329,24 +397,120 @@ results, and its four-language matrix stays off the per-change critical path.
|
||||
Go requires a build; the other languages use build mode `none`. Analysis runs
|
||||
on the nightly schedule or by manual dispatch. Results are uploaded to Code
|
||||
Scanning and always retained as workflow artifacts.
|
||||
- **Codex Security** qualifies release candidates rather than individual
|
||||
changes. The job installs a pinned `@openai/codex-security` release into the
|
||||
runner temp directory before the repository is checked out and invokes it by
|
||||
absolute path, so repository-controlled files cannot shadow the scanner. Model
|
||||
calls go to NVIDIA-hosted inference at `https://inference-api.nvidia.com/v1`,
|
||||
declared as a custom Codex provider named `nvidia` that uses the Responses
|
||||
wire API with WebSockets disabled. The scan runs `openai/openai/gpt-5.6-sol`
|
||||
at `medium` reasoning effort, with the multi-agent runtime capped at eight
|
||||
concurrent threads through
|
||||
`features.multi_agent_v2.max_concurrent_threads_per_session`. The
|
||||
`CODEX_SECURITY_API_KEY` secret holds the
|
||||
NVIDIA key and is exposed to the scan step alone, as `OPENAI_API_KEY` so the
|
||||
CLI selects API-key auth and as `NVIDIA_INFERENCE_API_KEY`, the provider
|
||||
`env_key` read by the Codex child process. `CODEX_SECURITY_STATE_DIR` and
|
||||
`SCAN_DIR` are suffixed with `github.run_id` and `github.run_attempt` and
|
||||
created mode `700`, so no scanner state or result set from a previous run or
|
||||
retry attempt is reused even on a runner with a reusable temp directory.
|
||||
`tasks/scripts/codex_security_range.py` resolves the scan range, reusing the
|
||||
tag parsers in `tasks/scripts/release.py` so both stay on one definition of a
|
||||
release tag while requiring the `v` prefix that a release workflow needs. The
|
||||
job stages both files out of the workspace from the workflow's own revision
|
||||
and runs the resolver by absolute path, because a scanned candidate predates
|
||||
them and a revision under scan must not choose its own scan range. The range
|
||||
itself is resolved against the checked-out candidate: the
|
||||
candidate must be a `vX.Y.Z-pre.N` tag that is an ancestor of `origin/main`,
|
||||
and the base is the newest stable `vX.Y.Z` tag merged into the candidate that
|
||||
is strictly older than the release train `vX.Y.Z` the candidate targets. A
|
||||
full-repository scan is only possible when no such stable tag exists and the
|
||||
caller passes `allow_full_bootstrap`. Each candidate scans the cumulative
|
||||
stable-to-candidate diff, so later candidates re-cover earlier ones. SARIF is
|
||||
uploaded against `refs/heads/main` at the candidate commit under the
|
||||
train-scoped category `codex-security/vX.Y.Z`, which makes each candidate's
|
||||
analysis replace the previous one for that train. Automatic pre-release tag
|
||||
pushes and `workflow_call` runs always upload. `workflow_dispatch` runs still
|
||||
perform the scan and the SARIF export, but skip the Code Scanning upload
|
||||
unless the caller sets the `upload_sarif` input, so manual diagnostics do not
|
||||
overwrite a train's published analysis by default. Codex Security 0.1.24
|
||||
cannot apply `--max-cost` to a slash-qualified model identifier, so the run
|
||||
has no CLI-enforced cost ceiling. Spend is bounded instead by the 120-minute
|
||||
job timeout, a single repository-wide concurrency group that serializes
|
||||
qualification so starting a newer candidate cancels an in-flight one, and
|
||||
NVIDIA account-side controls. No raw report is retained.
|
||||
- The job clears `kernel.apparmor_restrict_unprivileged_userns` before
|
||||
installing the scanner. Codex confines model-run commands with bubblewrap,
|
||||
which needs unprivileged user namespaces; Ubuntu 24.04 restricts those through
|
||||
AppArmor, so bubblewrap fails to configure the sandbox network namespace
|
||||
(`bwrap: loopback: Failed RTM_NEWADDR`) and the agent executes no commands at
|
||||
all. The failure is silent: the agent retries its shell tool, gives up, and
|
||||
seals no draft, while the scanner only reports a missing or incomplete draft.
|
||||
Lifting a kernel restriction on the runner is what allows the sandbox that
|
||||
confines the agent to start, and the runner is ephemeral and GitHub-hosted.
|
||||
- The scan sets `approval_policy="never"`. Codex Security keeps
|
||||
`approvals_reviewer="auto_review"` unconditionally, and that reviewer runs on
|
||||
its own model rather than the configured one. Because the workflow declares a
|
||||
single provider that serves only `openai/openai/gpt-5.6-sol`, any approval
|
||||
request reaches a model the endpoint does not serve, so the agent never gets a
|
||||
shell command approved and seals no draft. The scan stays confined by its
|
||||
`workspace-write` sandbox with network access disabled and by the scanner's
|
||||
own permission profile, which grants read access to the filesystem root and
|
||||
write access only to the workspace roots.
|
||||
- A scan that cannot execute commands reports only a missing or incomplete
|
||||
draft, so diagnosing one means reading the scanner's session rollouts under
|
||||
`CODEX_SECURITY_STATE_DIR`, where every shell command the agent ran is
|
||||
recorded. No command at all is the signal that the sandbox failed to start.
|
||||
|
||||
Findings never fail these checks; scanner and build failures do. A scanner that
|
||||
cannot run, a CodeQL analyzer that does not complete, and an unexpected
|
||||
Dependency Graph API error are all errors, which keeps an informational check
|
||||
from silently degrading into a no-op. None of these checks are required
|
||||
statuses, so they do not gate merges.
|
||||
cannot run, a CodeQL analyzer that does not complete, an unexpected Dependency
|
||||
Graph API error, and a Codex Security range, scan, or export failure are all
|
||||
errors, which keeps an informational check from silently degrading into a no-op.
|
||||
Codex Security also rejects any scan scope other than the resolved
|
||||
cumulative diff or an approved full bootstrap, so a qualification run either
|
||||
covers the whole stable-to-candidate range or fails; a separate no-permission
|
||||
job republishes the analysis job's outcome as the
|
||||
`OpenShell / Codex Security (informational)` status. None of these checks are
|
||||
required statuses, so they do not gate merges.
|
||||
|
||||
See `CI.md` for the contributor workflow, labels, and maintainer merge-queue workflow.
|
||||
Codex Security findings are informational during the observation phase, and the
|
||||
workflow only reports on candidates that already exist. Creating pre-release
|
||||
tags and gating stable promotion on qualification results are part of
|
||||
[RFC 0014](../rfc/0014-release-stability/release-qualification.md) and are not
|
||||
implemented yet.
|
||||
|
||||
## Artifact Scanning
|
||||
|
||||
Two entry points share `tasks/scripts/trivy-scan.sh`: a standalone analysis
|
||||
workflow and a pull-request change gate. Nix supplies Trivy, Helm and `yq`.
|
||||
|
||||
The standalone workflow scans deployment configuration and supplied OCI
|
||||
references independently of release publication. Detailed JSON reports feed the
|
||||
differential gate; the summary and published SARIF consolidate configuration
|
||||
findings across profiles while preserving resource identity and affected profiles.
|
||||
Images and packaged charts retain separate identities based on their full
|
||||
references. Publication batches respect GitHub's limit of 20 SARIF runs.
|
||||
|
||||
The PR/merge-group gate scans base and candidate with the same scanner and rejects
|
||||
new `HIGH` or `CRITICAL` configuration findings. Its stable
|
||||
`OpenShell / Trivy Changes` status succeeds when nothing relevant changed.
|
||||
Image CVEs need the standalone scan. The reporting and gate invariants are:
|
||||
|
||||
- A structurally invalid Trivy report is an error, not an empty finding set.
|
||||
- Scanner failures prevent publication of incomplete analyses; findings alone
|
||||
do not prevent publishing complete reports.
|
||||
- Findings compare per profile against the same baseline profile, by semantic
|
||||
identity and count rather than line number; a profile absent from the baseline
|
||||
falls back to that identity's maximum across all profiles.
|
||||
- The candidate's ignore file is validated, but the baseline's policy applies to
|
||||
both scans, so an exemption takes effect only after merge.
|
||||
|
||||
See [CI.md](../CI.md#artifact-scanning) for profiles, report paths, severity
|
||||
settings, exceptions, and the contributor and maintainer workflows.
|
||||
|
||||
## Docs Site
|
||||
|
||||
Published docs live in `docs/`. Navigation lives in `docs/index.yml`. Fern site
|
||||
configuration, components, theme assets, and publish settings live in `fern/`.
|
||||
|
||||
Use `mise run docs` for strict validation and `mise run docs:serve` for local
|
||||
preview. PR previews are produced by `.github/workflows/branch-docs.yml` when
|
||||
Fern credentials are available. Production docs publish from the release tag
|
||||
workflow.
|
||||
Published docs live in `docs/`, and Fern site configuration lives in `fern/`. See [fern/README.md](../fern/README.md) for the source layout, local development commands, version model, and publishing workflows.
|
||||
|
||||
## Validation Expectations
|
||||
|
||||
|
||||
@@ -1,34 +1,43 @@
|
||||
# Compute Runtimes
|
||||
|
||||
Compute runtimes create, stop, start, delete, and watch sandbox workloads for the
|
||||
gateway. They do not replace sandbox policy enforcement. Every runtime starts a
|
||||
workload that runs the `openshell-sandbox` supervisor, and the supervisor
|
||||
enforces the sandbox contract locally.
|
||||
gateway. Supervisor-controlled runtimes start a workload that runs the
|
||||
`openshell-sandbox` supervisor, which enforces the sandbox contract locally.
|
||||
Driver-controlled runtimes apply the canonical sandbox policy while
|
||||
provisioning and report workload readiness directly.
|
||||
|
||||
## Driver Contract
|
||||
|
||||
Each runtime receives a sandbox spec from the gateway and is responsible for:
|
||||
Each runtime receives a sandbox spec and canonical policy from the gateway and
|
||||
is responsible for:
|
||||
|
||||
- Selecting the sandbox image.
|
||||
- Injecting sandbox identity and gateway callback configuration.
|
||||
- Supplying TLS or secret material for supervisor callbacks.
|
||||
- Providing the supervisor binary or image in the workload.
|
||||
- For supervisor-controlled runtimes, injecting sandbox identity and gateway
|
||||
callback configuration, supplying callback credentials, and providing the
|
||||
supervisor binary or image.
|
||||
- For runtimes without the standard supervisor, validating and applying the
|
||||
canonical policy before launching the workload.
|
||||
- Forwarding the exact canonical main-process argv and TTY mode without shell
|
||||
reconstruction. The sandbox-level environment and policy workspace apply to
|
||||
the main process.
|
||||
- Reporting lifecycle and platform events back to the gateway.
|
||||
- Cleaning up runtime-owned resources.
|
||||
|
||||
Drivers report **backend state only**. A driver snapshot with `Ready=True` means
|
||||
the underlying compute resource (container, pod, VM) is healthy and running —
|
||||
nothing more. Drivers must not gate on supervisor session state or hold
|
||||
references to gateway-internal types. The gateway owns the public
|
||||
`SandboxPhase::Ready` decision. This applies equally to extension drivers
|
||||
implementing `ComputeDriver` out of tree.
|
||||
Drivers report **runtime-observed state only** and must not hold references to
|
||||
gateway-internal types. For supervisor-controlled runtimes, `Ready=True` means
|
||||
only that the compute resource is healthy; the gateway also requires a
|
||||
supervisor session before publishing `SandboxPhase::Ready`. For
|
||||
drivers that report runtime readiness, `Ready=True` is authoritative because the driver
|
||||
launches and monitors the policy-constrained workload itself.
|
||||
|
||||
`compute_driver.proto` is the supported gateway/driver extension boundary.
|
||||
At initialization the gateway snapshots the driver's identity, version,
|
||||
default image, and gateway-lifecycle preference from `GetCapabilities`.
|
||||
default image, gateway-lifecycle preference, and
|
||||
`driver_reports_runtime_readiness` from `GetCapabilities`. The gateway includes
|
||||
the canonical `SandboxPolicy` in `DriverSandboxSpec.policy` for validation and
|
||||
creation. Drivers that enforce policy outside the standard supervisor fetch
|
||||
later revisions through `GetSandboxConfig` and acknowledge them through
|
||||
`ReportPolicyStatus`.
|
||||
Process-identity omissions are preserved across this boundary so every driver
|
||||
can apply its native image or runtime defaults. Driver-requested listeners are
|
||||
structurally validated and remain restricted to sandbox callback RPCs.
|
||||
@@ -45,28 +54,29 @@ reason strings.
|
||||
|
||||
## Sandbox Readiness Composition
|
||||
|
||||
The gateway composes driver backend state with supervisor session presence to
|
||||
produce the public `SandboxPhase`. This composition is gateway-owned and applied
|
||||
uniformly across all drivers:
|
||||
The gateway composes driver state with the advertised readiness behavior to
|
||||
produce the public `SandboxPhase`:
|
||||
|
||||
```
|
||||
backend_phase = derive_phase(driver_status)
|
||||
|
||||
public_phase =
|
||||
if backend_phase in {Error, Deleting}: → pass through (terminal precedence)
|
||||
if driver_reports_runtime_readiness && backend_phase == Ready: → Ready
|
||||
if backend_phase == Ready && session connected: → Ready
|
||||
if backend_phase == Ready && no session: → Provisioning
|
||||
if backend_phase in {Provisioning, Unknown} && session: → Ready
|
||||
if backend_phase in {Provisioning, Unknown} && no session: → Provisioning
|
||||
```
|
||||
|
||||
When `public_phase == Ready` the sandbox is usable through the gateway — both the
|
||||
For a supervisor-controlled runtime, `public_phase == Ready` means both the
|
||||
backend resource is healthy and a supervisor session is registered. A sandbox whose
|
||||
backend reports ready but has no supervisor session yet holds `Provisioning` with a
|
||||
`Ready=False`, `SupervisorNotConnected` condition and the message
|
||||
`Backend ready; waiting for supervisor session`. This distinguishes it from a sandbox
|
||||
whose compute resource is still provisioning without exposing contradictory public
|
||||
readiness signals.
|
||||
readiness signals. When the driver reports runtime readiness, its ready condition
|
||||
is published without waiting for a supervisor session.
|
||||
|
||||
**Session precedence over lagging driver snapshots:** A supervisor session can only be
|
||||
established by a running workload. When `set_supervisor_session_state` promotes the
|
||||
@@ -85,10 +95,12 @@ session-owning replica. That work is deferred to GitHub issue #1868. Until then,
|
||||
deployments that require reliable readiness composition must run a single gateway
|
||||
replica.
|
||||
|
||||
**Extension point:** The readiness decision is a safety invariant, not an
|
||||
operator-configurable hook. The driver contract is the correct extension point for
|
||||
custom backend readiness semantics. RFC-0010 lifecycle hooks may observe readiness
|
||||
transitions via `post_commit`; they do not override the composition rule.
|
||||
**Extension point:** Driver-reported readiness is a capability, not an
|
||||
operator-configurable hook. A driver may enable it only when it owns workload
|
||||
readiness. Policy delivery remains independent: create-time policy is embedded
|
||||
in the sandbox specification, and later revisions use the existing sandbox
|
||||
configuration API. RFC-0010 lifecycle hooks may observe readiness transitions via
|
||||
`post_commit`; they do not override the composition rule.
|
||||
|
||||
The capability RPC reports driver identity, version, and the default sandbox
|
||||
image used by the gateway. GPU availability stays driver-local and is validated
|
||||
@@ -103,9 +115,8 @@ of re-querying drivers on each request.
|
||||
The gateway binary explicitly installs the compute drivers compiled into that
|
||||
binary before entering server startup. The server selects a configured driver
|
||||
by normalized registry name. When no driver is configured, it evaluates only
|
||||
the installed drivers' probes in registered priority order, records every
|
||||
available registration, and selects the first. Drivers without a probe,
|
||||
including VM, remain opt-in.
|
||||
the installed drivers' probes and chooses the lowest registered priority.
|
||||
Drivers without a probe, including VM, remain opt-in.
|
||||
|
||||
Startup computes this selection once after merging configuration. The same
|
||||
selection drives authentication defaults and runtime construction, so a probe
|
||||
@@ -117,17 +128,18 @@ registry. Adding or removing a compiled driver therefore changes registration
|
||||
rather than the server's selection flow. Alternate gateway binaries can install
|
||||
their own `ComputeDriverFactory` registrations and hand the completed registry
|
||||
to `run_cli_with_compute_drivers`; factories receive merged driver config and
|
||||
finish through the same in-process runtime adapter. A configured UDS endpoint
|
||||
still takes precedence over a compiled registration with the same name.
|
||||
return either an in-process driver or a gateway-managed remote endpoint. The
|
||||
server constructs the common runtime adapter and snapshots `GetCapabilities`
|
||||
for either result. A configured UDS endpoint still takes precedence over a
|
||||
compiled registration with the same name.
|
||||
|
||||
The standard server crate groups first-party registrations behind the
|
||||
`in-tree-compute-drivers` feature. Protocol-only gateway builds disable that
|
||||
feature and link no compute-driver crates. E2E lanes compose that gateway with
|
||||
Docker, Podman, Kubernetes, and VM driver executables over the public UDS gRPC
|
||||
contract so an in-tree driver cannot silently depend on a server-only API.
|
||||
External Kubernetes drivers support shared and managed workspace modes.
|
||||
Operator mode requires an in-process dynamic namespace allowlist and is
|
||||
rejected when Kubernetes is configured through an external endpoint.
|
||||
The `openshell-gateway` composition crate groups first-party registrations
|
||||
behind the `in-tree-compute-drivers` feature. `openshell-server` has no compute
|
||||
driver dependencies or backend-name dispatch. Protocol-only gateway builds
|
||||
disable the composition feature and link no compute-driver crates. E2E lanes
|
||||
compose that gateway with Docker, Podman, Kubernetes, and VM driver executables
|
||||
over the public UDS gRPC contract so an in-tree driver cannot silently depend
|
||||
on a server-only API.
|
||||
|
||||
## Stop and Start Lifecycle
|
||||
|
||||
@@ -208,6 +220,22 @@ 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.
|
||||
Owned-record cleanup discovers records before mutating them and uses bounded
|
||||
set-based deletes so teardown cannot amplify one sandbox into an unbounded
|
||||
sequence of individual persistence writes.
|
||||
|
||||
When a sandbox is instead discovered gone out-of-band — a watcher deletion
|
||||
event, or the periodic prune sweep finding no matching driver resource, with
|
||||
no explicit `DeleteSandbox` request involved at all — the gateway also
|
||||
releases driver-owned resources (for example Podman's per-sandbox secrets and
|
||||
workspace volume) by calling the driver's idempotent `DeleteSandbox`, not just
|
||||
gateway state. Both paths skip that call when a request-side lifecycle
|
||||
operation already holds the sandbox's gate, since that operation already owns
|
||||
driver-side cleanup. The watch path defers the call itself to a background
|
||||
task after a non-blocking gate check, so a slow driver call cannot stall the
|
||||
sequential watch loop; the prune sweep calls the driver inline, since it
|
||||
already makes a blocking `GetSandbox` call per sandbox as part of its normal
|
||||
operation.
|
||||
|
||||
The request acquires both locks before starting owned work, so cancellation
|
||||
while queued does not leave a delete armed. After that commitment point, the
|
||||
@@ -230,6 +258,16 @@ template resource limits. Docker and Podman apply them as runtime limits.
|
||||
Kubernetes mirrors each limit into the matching request. VM accepts the fields
|
||||
but currently ignores them.
|
||||
|
||||
Reusable sandbox workload templates are resolved before the compute-driver
|
||||
boundary. Drivers do not receive a separate template resource; the gateway
|
||||
lowers the selected `SandboxWorkloadTemplate` into the existing sandbox spec
|
||||
and validates that spec before calling `ValidateSandboxCreate` or
|
||||
`CreateSandbox`. Template CPU and memory become the same typed resource limits
|
||||
described above. Template GPU settings become `ResourceRequirements`, preserving
|
||||
the driver's default GPU assignment when the count is omitted. Template
|
||||
`driver_config` remains a driver-keyed envelope until the compute layer selects
|
||||
the active driver block and forwards only that block to the driver.
|
||||
|
||||
Docker and Podman also accept per-sandbox driver-config mounts for existing
|
||||
runtime-managed named volumes and tmpfs mounts. Podman additionally accepts
|
||||
image mounts through its image-volume API. User-supplied bind and volume mounts
|
||||
@@ -255,6 +293,17 @@ through the driver configuration. The Helm chart defaults sandbox agents to
|
||||
`Unconfined` so runtime/default AppArmor profiles do not block supervisor
|
||||
network namespace setup on AppArmor-enabled nodes.
|
||||
|
||||
The Kubernetes deployment packaging has two ownership boundaries. The gateway
|
||||
chart owns the gateway workload, configuration, Services, PKI, and
|
||||
cluster-scoped gateway resources. It can retain the legacy combined behavior,
|
||||
or omit workspace resources. The workspace chart is installed into a
|
||||
pre-provisioned sandbox namespace and owns only the sandbox ServiceAccount,
|
||||
namespaced RBAC, and sandbox ingress NetworkPolicy. Its RoleBinding names the
|
||||
gateway ServiceAccount and namespace explicitly, so the two releases have
|
||||
disjoint lifecycle ownership. A shared-mode gateway can target one external
|
||||
namespace, while operator mode maps workspace names to multiple
|
||||
platform-provisioned namespaces.
|
||||
|
||||
Resource requirements enter the driver layer through `SandboxSpec.resource_requirements`. This includes a set of GPU requirements, where a user
|
||||
can request a specific number of GPUs or the driver-specific default behaviour.
|
||||
For all in-tree drivers, this is equivalent to selecting a single GPU.
|
||||
@@ -455,13 +504,13 @@ image-pull Secrets in every operator-managed namespace.
|
||||
|
||||
**Operator** uses pre-provisioned namespaces discovered through two optional
|
||||
sources: a K8s label selector (`operator_namespace_label`) and a drop-in
|
||||
allowlist file (`operator_namespace_file`). At least one must be configured.
|
||||
The `OperatorNamespaceAllowlist` (`Arc<RwLock<BTreeSet<String>>>`) is populated
|
||||
at runtime by background watchers and read by the namespace resolver. Sandbox
|
||||
creation fails closed if the workspace is not in the current allowlist. Platform
|
||||
teams manage namespace lifecycle externally. RBAC uses the same ClusterRole as
|
||||
managed mode but without namespace `create`/`delete` or ServiceAccount
|
||||
permissions.
|
||||
allowlist file (`operator_namespace_file`). Exactly one must be configured.
|
||||
The compute driver and the gateway's ServiceAccount authenticator independently
|
||||
watch that public config source; no in-process driver state crosses into the
|
||||
server. Sandbox creation and token bootstrap fail closed if the workspace is
|
||||
not in the current allowlist. Platform teams manage namespace lifecycle
|
||||
externally. RBAC uses the same ClusterRole as managed mode but without namespace
|
||||
`create`/`delete` or ServiceAccount permissions.
|
||||
|
||||
### Watching and Querying
|
||||
|
||||
|
||||
+150
-85
@@ -2,17 +2,16 @@
|
||||
|
||||
The gateway is the OpenShell control plane. It exposes the API used by the CLI,
|
||||
SDK, and TUI; persists platform state; manages provider credentials and
|
||||
inference configuration; and asks compute runtimes to create or delete sandbox
|
||||
workloads.
|
||||
attachments; and asks compute runtimes to create or delete sandbox workloads.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Authenticate clients and sandbox callbacks.
|
||||
- Serve gRPC APIs for sandbox lifecycle, provider management, policy updates,
|
||||
settings, inference configuration, logs, watch streams, and relay forwarding.
|
||||
settings, logs, watch streams, and relay forwarding.
|
||||
- Serve HTTP endpoints for health, WebSocket tunnels, and edge-auth flows.
|
||||
- Persist domain objects in SQLite or Postgres.
|
||||
- Resolve provider credentials and inference bundles for sandbox supervisors.
|
||||
- Resolve endpoint-bound provider environments for sandbox supervisors.
|
||||
- Coordinate supervisor relay sessions for connect, exec, file sync, and
|
||||
service forwarding.
|
||||
- Persist the canonical main-process instance ID and normalized exit code on
|
||||
@@ -55,6 +54,16 @@ health, metrics, or tunnel routes. The plaintext service router also rejects
|
||||
browser requests whose Fetch Metadata, Origin, or Referer headers indicate a
|
||||
cross-origin or sibling-subdomain request.
|
||||
|
||||
Public workspace-scoped RPCs carry a typed `WorkspaceSelector`. A request must
|
||||
select one non-empty workspace explicitly; `default` is an ordinary explicit
|
||||
name, not an omitted-value fallback. Sandbox, sandbox template, provider, and
|
||||
service list RPCs also accept an all-workspaces marker after Platform Admin
|
||||
authorization. Single-workspace handlers reject that marker. Platform-global
|
||||
policy operations require the selector to be absent, while workspace policy
|
||||
operations require it. The gateway authorizes the selected scope before
|
||||
performing resource lookup so malformed, unsupported, and unauthorized scopes
|
||||
have consistent behavior across resource types.
|
||||
|
||||
Docker and Podman report the local address through which their sandboxes can
|
||||
reach the gateway. When the primary listener covers that address, the gateway
|
||||
reuses it; sandbox JWT authentication and its RPC allowlist remain the callback
|
||||
@@ -62,7 +71,7 @@ authorization boundary. When the primary listener does not cover the address,
|
||||
the gateway adds a callback-only listener. Additional callback listeners accept
|
||||
only gRPC methods classified as sandbox-callable by the gateway's generated
|
||||
authorization metadata. They reject user and administrator APIs, health,
|
||||
reflection, non-callback inference APIs, and HTTP routes before normal request
|
||||
reflection, and HTTP routes before normal request
|
||||
authentication. The operator-configured primary listener retains the full
|
||||
multiplexed API surface.
|
||||
|
||||
@@ -192,7 +201,7 @@ Supported auth modes:
|
||||
| Plaintext | Local development or a trusted reverse proxy boundary. |
|
||||
| Unauthenticated local users | Trusted Kubernetes dev or fully trusted proxy deployments only. |
|
||||
| Cloudflare JWT | Edge-authenticated deployments where Cloudflare Access supplies identity. |
|
||||
| OIDC | Bearer-token auth for users, with browser or device-code PKCE and client credentials login. |
|
||||
| OIDC | Bearer-token auth for users, with browser or device-code PKCE and client credentials login. JWKS validation accepts RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, and EdDSA (Ed25519) signing keys. |
|
||||
|
||||
The CLI persists the scopes requested during OIDC login in gateway metadata and
|
||||
reuses them when refreshing an access token. This preserves the intended API
|
||||
@@ -277,7 +286,6 @@ The gateway API is organized around platform objects and operational streams:
|
||||
| Sandbox lifecycle | Create, list, delete, watch, exec, SSH session bootstrap, ForwardTcp service forwarding. |
|
||||
| Providers | Store provider records, discover credentials, resolve runtime environment. |
|
||||
| Policy and settings | Get effective sandbox config, update sandbox policy, manage global settings. |
|
||||
| Inference | Set gateway-level model/provider config and resolve sandbox route bundles. |
|
||||
| Observability | Push sandbox logs, stream sandbox status and logs to clients. |
|
||||
|
||||
Domain objects use shared metadata: stable server-generated IDs, human-readable
|
||||
@@ -299,7 +307,7 @@ The storage schema is intentionally narrow:
|
||||
| Column | Purpose |
|
||||
|---|---|
|
||||
| `id` | Stable gateway-generated object ID and primary key. |
|
||||
| `object_type` | Logical resource kind, such as `sandbox`, `provider`, `ssh_session`, `inference_route`, `sandbox_policy`, or `draft_policy_chunk`. |
|
||||
| `object_type` | Logical resource kind, such as `sandbox`, `provider`, `provider_profile`, `ssh_session`, `sandbox_policy`, or `draft_policy_chunk`. |
|
||||
| `name` | Human-readable name, unique within an object type when present. |
|
||||
| `scope` | Optional owner or namespace for scoped/versioned records, such as a sandbox ID for policy revisions. |
|
||||
| `version` | Optional monotonically increasing version for scoped records. |
|
||||
@@ -310,6 +318,81 @@ The storage schema is intentionally narrow:
|
||||
| `created_at_ms` and `updated_at_ms` | Gateway timestamps used for ordering and list output. |
|
||||
| `labels` | JSON object carrying Kubernetes-style object labels for filtering and organization. |
|
||||
|
||||
### Protobuf API and storage boundaries
|
||||
|
||||
Public RPC contracts and durable protobuf formats have separate ownership. The
|
||||
`openshell.v1.OpenShell` service currently has 74 RPCs. Their request and
|
||||
response roots, streaming flags, and transitive message closure come from the
|
||||
public descriptor set generated by `openshell-core`; a fingerprint test in
|
||||
`openshell-server` requires this inventory to be reviewed whenever it changes.
|
||||
Compute-driver, credential-driver, gateway-interceptor, and
|
||||
supervisor-middleware services are compiled contracts for internal extension
|
||||
boundaries, not public gateway RPCs. The current public inventory has 74
|
||||
methods, 278 messages, and 12 enums
|
||||
(`c95ae90962c10fb28747db2b645adf4044562a3d208d84dfe4699d677e4364ee`).
|
||||
|
||||
Storage-only messages live in the private, versioned
|
||||
`openshell.storage.v1` package under `crates/openshell-server/proto`. The server
|
||||
generates these types separately, so the public descriptor set and the Rust,
|
||||
Go, Python, and TypeScript client generation inputs do not advertise them.
|
||||
|
||||
| Storage classification | Protobuf messages | Durable use |
|
||||
|---|---|---|
|
||||
| Encoded storage roots | `StoredProviderCredentialRefreshState`, `StoredProviderProfile`, `PolicyRevisionPayload`, `DraftChunkPayload` | Complete protobuf payload stored in an object row or a scoped policy row. |
|
||||
| Nested storage-only type | `StoredRefreshMaterialDeletion` | Repeated child records inside provider refresh state. |
|
||||
| SQL materializations | `StoredPolicyRevision`, `StoredDraftChunk` | Server-only typed results assembled from indexed columns and decoded payloads; not public RPC messages. |
|
||||
| Public messages used directly as encoded storage roots | `Sandbox`, `SandboxWorkloadTemplate`, `Provider`, `Workspace`, `WorkspaceMember`, `SshSession`, `ServiceEndpoint` | The generated public type is also the persisted payload. `SshSession` is not in the current public RPC message closure. |
|
||||
| Embedded encoded root | `SandboxPolicy` | Stored in policy rows and inside the JSON settings envelope. |
|
||||
|
||||
The 12 encoded durable roots above have a closure of 81 messages and eight
|
||||
enums (`920a5243dfb37ce709f0f562a47d17791a5ede90fd7f662ed01542abd60a0dfb`).
|
||||
Its intersection with the public RPC closure contains 71 messages and eight
|
||||
enums (`05add438ba041defc98d791038ae593d3f09352677cae43f2276d494205ce415`).
|
||||
The descriptor-derived test owns these full inventories; the tables here record
|
||||
the reviewed roots and classifications.
|
||||
|
||||
| Dual-purpose encoded root | Current decision |
|
||||
|---|---|
|
||||
| `Sandbox` | Defer a storage twin; govern its complete dependency closure as durable. |
|
||||
| `SandboxWorkloadTemplate` | Defer a storage twin; govern its complete dependency closure as durable. |
|
||||
| `Provider` | Defer a storage twin; govern its complete dependency closure as durable. |
|
||||
| `Workspace` | Defer a storage twin; govern its complete dependency closure as durable. |
|
||||
| `WorkspaceMember` | Defer a storage twin; govern its complete dependency closure as durable. |
|
||||
| `SshSession` | Defer a storage twin; govern its complete dependency closure as durable. |
|
||||
| `ServiceEndpoint` | Defer a storage twin; govern its complete dependency closure as durable. |
|
||||
| `SandboxPolicy` | Defer a storage twin; govern its complete dependency closure as durable. |
|
||||
|
||||
The public/storage overlap is deliberate for the current format. Storage twins
|
||||
for the public roots are deferred: introducing them would require a broad
|
||||
conversion boundary, and Prost does not retain unknown fields through a
|
||||
decode-and-reencode conversion. Each root therefore carries a reviewed decision
|
||||
to remain dual-purpose, and its complete transitive dependency closure is also
|
||||
a durable format. Important embedded dependencies include `ObjectMeta`,
|
||||
`ProviderProfile`, `CredentialHandle`, `SandboxPolicy`, and
|
||||
`NetworkPolicyRule`. Global and sandbox settings additionally store an encoded
|
||||
`SandboxPolicy` inside their JSON envelope.
|
||||
|
||||
Public API compatibility and storage compatibility are reviewed independently:
|
||||
|
||||
- Public compatibility is evaluated from public service descriptors and SDK
|
||||
generation inputs. Storage-only packages must never enter that closure.
|
||||
- `openshell.storage.v1` is frozen. Its test fingerprint covers message names,
|
||||
field numbers, cardinality, scalar wire types, referenced types, map-entry
|
||||
shapes, and optional presence. Keep its decoder available and introduce a
|
||||
new versioned package plus an explicit migration or fallback decoder for a
|
||||
format change; never reuse removed tags or names.
|
||||
- Checked-in synthetic byte fixtures were encoded with the former
|
||||
`openshell.v1` declarations. Current storage types must continue to decode
|
||||
them semantically, which proves the package move does not require a database
|
||||
rewrite. Protobuf payload bytes do not encode a message's package name.
|
||||
- A change to a dual-purpose public message or any transitive durable
|
||||
dependency requires both public-wire review and storage-migration review.
|
||||
Wire-incompatible changes require a migration or fallback decoder and a
|
||||
fixture for the earlier format.
|
||||
- Mixed-version writers are unsupported. An older Prost writer can discard
|
||||
fields it does not know when it reads and rewrites a record, even when the
|
||||
newer field is wire-compatible.
|
||||
|
||||
Common resources use generic helpers that derive `object_type`, `id`, `name`,
|
||||
and labels from protobuf metadata traits before encoding the full message into
|
||||
`payload`. Policy revisions and draft policy chunks use the same table but also
|
||||
@@ -336,31 +419,45 @@ This keeps the gateway data model portable across storage backends and leaves
|
||||
room for future stores that can provide the same object, label, version, and
|
||||
scope semantics.
|
||||
|
||||
For in-memory SQLite, the adapter retains a dedicated keepalive connection for
|
||||
the store lifetime. Operational connection replacement therefore preserves the
|
||||
shared in-memory schema and objects instead of creating an empty database.
|
||||
|
||||
The SQLite adapter tightens the on-disk database file to mode `0o600` on every
|
||||
connect so that provider API keys, SSH session tokens, and sandbox metadata are
|
||||
not readable by other local users on shared hosts. The same restriction is
|
||||
reapplied to the `<db>-wal` and `<db>-shm` sidecars (created by SQLite's
|
||||
default WAL journal mode), which mirror the same sensitive contents.
|
||||
|
||||
Persisted state includes sandboxes, providers, provider credential refresh
|
||||
state, SSH sessions, policy revisions, settings, inference configuration, and
|
||||
deployment records. Provider refresh state is stored as a separate object
|
||||
scoped to the provider instance through `objects.scope`. Its non-secret
|
||||
configuration remains inline, while refresh tokens, client secrets, private
|
||||
keys, and other secret source material are stored through the active credential
|
||||
driver and represented by opaque handles. The provider record keeps only the
|
||||
current injectable credential handles and optional per-credential expiry
|
||||
timestamps. A refresh normally mints one credential, but a strategy may
|
||||
co-mint several (AWS STS mints the access key, secret key, and session token in
|
||||
one call); the refresh state pins the resolved set of env keys it owns so
|
||||
collision checks reserve all of them before the first mint. Provider records
|
||||
keep inline credential values only for legacy records created before credential
|
||||
driver storage. New provider and refresh-material writes keep driver-owned
|
||||
credential handles. When no external credential driver is configured, gateways
|
||||
use server-owned encrypted database credential storage for defense in depth.
|
||||
Multi-replica deployments can use that default with a shared database and
|
||||
shared key-encryption key, or opt into an external backend such as Vault or
|
||||
Kubernetes Secrets.
|
||||
Persisted state includes sandboxes, providers, provider profiles, provider
|
||||
credential refresh state, SSH sessions, policy revisions, settings, deployment
|
||||
records, and reusable sandbox workload templates. Provider refresh
|
||||
state is stored as a separate object scoped to the provider instance through
|
||||
`objects.scope`. Its non-secret configuration remains inline, while refresh
|
||||
tokens, client secrets, private keys, and other secret source material are
|
||||
stored through the active credential driver and represented by opaque handles.
|
||||
The provider record keeps only the current injectable credential handles and
|
||||
optional per-credential expiry timestamps. A refresh normally mints one
|
||||
credential, but a strategy may co-mint several (AWS STS mints the access key,
|
||||
secret key, and session token in one call); the refresh state pins the resolved
|
||||
set of env keys it owns so collision checks reserve all of them before the
|
||||
first mint. Provider records keep inline credential values only for legacy
|
||||
records created before credential driver storage. New provider and
|
||||
refresh-material writes keep driver-owned credential handles. When no external
|
||||
credential driver is configured, gateways use server-owned encrypted database
|
||||
credential storage for defense in depth. Multi-replica deployments can use that
|
||||
default with a shared database and shared key-encryption key, or opt into an
|
||||
external backend such as Vault or Kubernetes Secrets.
|
||||
|
||||
Sandbox workload templates are workspace-scoped gateway resources. Workspace
|
||||
admins create and delete them; workspace users can read and list them. A
|
||||
template owns reusable workload intent: image, environment, CPU and memory
|
||||
limits, GPU request, driver-specific config, and service-level hints. A sandbox
|
||||
created from a template resolves that resource once and persists an ordinary
|
||||
`SandboxSpec` snapshot. The create request still owns per-sandbox governance:
|
||||
name, labels, annotations, provider attachments, and policy. The sandbox stores
|
||||
template provenance as the template name and resource version used for the
|
||||
snapshot, so later template edits or deletes do not mutate existing sandboxes.
|
||||
|
||||
OAuth refresh failures retain a gateway-owned recovery classification alongside
|
||||
the refresh state. The gateway reads only a bounded error response and maps
|
||||
@@ -450,7 +547,6 @@ coverage:
|
||||
| Sandbox | `MustCreate` | `update_message_cas` | `list_messages` |
|
||||
| Provider | `MustCreate` | `update_message_cas` | `list_messages` |
|
||||
| ProviderProfile | `MustCreate` | `MatchResourceVersion` | `list_messages` |
|
||||
| InferenceRoute | `MustCreate` | `update_message_cas` | `list_messages` |
|
||||
| SandboxPolicy | scoped versioning | scoped versioning | scoped query |
|
||||
| Settings | `Mutex`-guarded | `Mutex`-guarded | single-row |
|
||||
|
||||
@@ -512,69 +608,25 @@ configuration, valid endpoint-bound static credentials from other attached
|
||||
providers, and the dynamic credential snapshot. Provider environment revisions
|
||||
include profile endpoint and binding changes.
|
||||
|
||||
## Inference Resolution
|
||||
## Provider Environment Resolution
|
||||
|
||||
Cluster inference routes store only `provider_name`, `model_id`, and optional
|
||||
timeout. The gateway resolves endpoint URLs, protocols, credentials, auth
|
||||
style, and route-shaping metadata from the provider record when supervisors call
|
||||
`GetInferenceBundle`. Supported provider types for cluster inference are
|
||||
`openai`, `anthropic`, `nvidia`, `deepinfra`, and `google-vertex-ai`.
|
||||
The gateway resolves only the providers attached to a sandbox. It combines each
|
||||
provider instance with its profile, returns non-secret configuration, and marks
|
||||
credentials with the profile's host, port, and path boundaries. The supervisor
|
||||
uses those bindings when it replaces credential placeholders in policy-allowed
|
||||
requests.
|
||||
|
||||
The bundle carries enough information for sandbox-local routers to construct
|
||||
upstream URLs without re-deriving provider-specific routing logic. Each resolved
|
||||
route may include:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `model_in_path` | When true, the model identifier is part of the upstream URL path, not only the request body. |
|
||||
| `request_path_override` | Path override or suffix. With `model_in_path=false`, replaces the protocol-derived path; with `model_in_path=true`, appended after the model ID. |
|
||||
|
||||
For standard providers these fields stay unset and the sandbox router uses default
|
||||
protocol paths. Vertex AI is model-aware: the gateway constructs the base URL
|
||||
from provider config (`VERTEX_AI_PROJECT_ID`, `VERTEX_AI_REGION`, optional
|
||||
`VERTEX_AI_PUBLISHER`) and emits route-shaping metadata so the sandbox router
|
||||
stays provider-agnostic.
|
||||
|
||||
Host selection follows the configured region:
|
||||
|
||||
| Region value | Vertex host |
|
||||
|---|---|
|
||||
| `global` | `aiplatform.googleapis.com` |
|
||||
| `us` or `eu` | `aiplatform.{region}.rep.googleapis.com` |
|
||||
| Any other (e.g. `us-central1`) | `{region}-aiplatform.googleapis.com` |
|
||||
|
||||
Route shaping by publisher:
|
||||
|
||||
- **Anthropic (Claude)** — `model_in_path=true`, base path under
|
||||
`publishers/anthropic/models`, protocol `anthropic_messages` only. The gateway
|
||||
resolves `request_path_override=:rawPredict`; the sandbox router keeps
|
||||
`:rawPredict` for buffered requests and upgrades to `:streamRawPredict` only
|
||||
for streaming proxy calls.
|
||||
- **All other models** (Gemini, third-party, unknown) — OpenAI-compatible
|
||||
`.../endpoints/openapi` base with `request_path_override=/chat/completions`;
|
||||
protocol `openai_chat_completions`.
|
||||
|
||||
Callers may supply `GOOGLE_VERTEX_AI_BASE_URL` or `VERTEX_AI_BASE_URL` only for
|
||||
non-Anthropic routes. Anthropic base URL overrides are rejected because they
|
||||
cannot safely preserve model-path shaping and `anthropic_version` body
|
||||
adaptation. Overrides still pin `request_path_override=/chat/completions` and
|
||||
must use `https` with an official Vertex AI hostname (`aiplatform.googleapis.com`,
|
||||
`aiplatform.{us,eu}.rep.googleapis.com`, or `{region}-aiplatform.googleapis.com`).
|
||||
|
||||
Header passthrough is protocol-dependent. Vertex Claude rawPredict routes strip
|
||||
client `anthropic-beta` headers; `anthropic-version` is not forwarded because
|
||||
the sandbox router injects `anthropic_version` into the request body for Vertex
|
||||
rawPredict. Non-Anthropic Vertex routes do not inherit Anthropic passthrough
|
||||
headers.
|
||||
Model selection, API protocol, request and response shapes, streaming behavior,
|
||||
and endpoint URL construction remain responsibilities of the workload's native
|
||||
client. The gateway does not parse or transform model API requests.
|
||||
|
||||
For `google-vertex-ai` providers created with CLI `--from-gcloud-adc`, the CLI
|
||||
calls gateway `ConfigureProviderRefresh` with OAuth2 refresh material from gcloud
|
||||
ADC, then `RotateProviderCredential` to mint the first access token before
|
||||
reporting success. ADC-backed providers mint into `GOOGLE_VERTEX_AI_TOKEN`. A
|
||||
successful create therefore yields an immediately usable provider; failures roll
|
||||
back the provider record. Service-account JSON and private keys are gateway-side
|
||||
refresh bootstrap material only; sandbox runtime inference receives minted
|
||||
access tokens, not raw service-account material.
|
||||
back the provider record. Service-account JSON and private keys remain gateway-side
|
||||
refresh bootstrap material; sandboxes receive minted access tokens instead.
|
||||
|
||||
## Supervisor Relay
|
||||
|
||||
@@ -746,9 +798,22 @@ identity and configured compute driver.
|
||||
|
||||
The gateway forwards OTLP configuration, its configured gateway name, and W3C
|
||||
trace context to managed external drivers. Built-in drivers use dedicated
|
||||
in-process providers that preserve the same RPC trace boundary. Each driver
|
||||
in-process providers that preserve the same RPC trace boundary. A shared
|
||||
compute-driver tracing descriptor derives provider layers and standalone
|
||||
installation from each driver's service identity, and routes both the shared
|
||||
RPC boundary target and that driver's crate target prefix. A shared RPC tracer
|
||||
owns unary and streaming boundary outcomes. Each driver
|
||||
exports to the configured collector under its own service name and carries the
|
||||
gateway name as a resource attribute.
|
||||
gateway name and configured compute driver as resource attributes.
|
||||
Compute-driver client and server spans share the fully qualified protobuf
|
||||
operation name, such as `openshell.compute.v1.ComputeDriver/CreateSandbox`,
|
||||
in both the span name and `rpc.method`; the current RPC semantic conventions
|
||||
integrate the service into that fully qualified method and do not emit
|
||||
`rpc.service`. `service.name` and span kind distinguish the two sides.
|
||||
Backend-prefixed spans describe implementation work beneath that boundary.
|
||||
Streaming `WatchSandboxes` spans remain open for the stream lifetime on both
|
||||
sides. A terminal stream status records its outcome;
|
||||
consumer teardown without a terminal status leaves the span status unset.
|
||||
|
||||
Two invariants shape the failure behavior. Telemetry is diagnostic, so no OTLP
|
||||
failure stops the gateway from serving: a malformed endpoint is logged at
|
||||
|
||||
@@ -1,544 +1,98 @@
|
||||
# Google Vertex AI Provider — Implementation Reference
|
||||
# Google Vertex AI Provider
|
||||
|
||||
This document covers the full implementation of the `google-vertex-ai` provider in
|
||||
OpenShell. It is the canonical reference for maintainers working on anything in the
|
||||
Vertex AI request path, from CLI through gateway to sandbox.
|
||||
The `google-vertex-ai` provider gives selected sandboxes direct access to
|
||||
Google Vertex AI endpoints without exposing long-lived Google credentials.
|
||||
The provider profile owns endpoint policy and credential metadata; the
|
||||
sandbox attachment owns which workload receives access.
|
||||
|
||||
---
|
||||
## Boundaries
|
||||
|
||||
## 1. Overview
|
||||
|
||||
OpenShell's `google-vertex-ai` provider routes `inference.local` traffic through
|
||||
Google Cloud's Vertex AI platform. It differs from a direct Anthropic or OpenAI
|
||||
integration in two ways that touch nearly every layer of the stack:
|
||||
|
||||
1. **Authentication is OAuth2 bearer, not a static API key.** Vertex AI accepts
|
||||
short-lived GCP access tokens (`ya29.*`) as `Authorization: Bearer` headers. The
|
||||
gateway mints and rotates these tokens from one of two refresh sources: a GCP
|
||||
service account key (JWT-bearer grant) or gcloud Application Default Credentials
|
||||
(OAuth2 refresh-token grant).
|
||||
|
||||
2. **The URL and wire format depend on the model family.** Anthropic Claude models
|
||||
use Vertex AI's native rawPredict surface (`/publishers/anthropic/models/{model}:rawPredict`)
|
||||
with the Anthropic Messages API body shape. Gemini and all other models use Vertex
|
||||
AI's OpenAI-compatible Chat Completions surface
|
||||
(`/v1beta1/.../endpoints/openapi/chat/completions`). The gateway selects the right
|
||||
route at `openshell inference set` time based on the model name (or the explicit
|
||||
`VERTEX_AI_PUBLISHER` config key).
|
||||
|
||||
### Canonical provider type
|
||||
|
||||
The canonical provider type string is `google-vertex-ai`. The following aliases are
|
||||
accepted everywhere and normalized to the canonical string:
|
||||
|
||||
| Input | Resolves to |
|
||||
| Component | Responsibility |
|
||||
|---|---|
|
||||
| `google-vertex-ai` | `google-vertex-ai` |
|
||||
| `vertex` | `google-vertex-ai` |
|
||||
| `vertex-ai` | `google-vertex-ai` |
|
||||
| `google-vertex` | `google-vertex-ai` |
|
||||
| `gcp-vertex` | `google-vertex-ai` |
|
||||
| CLI | Discovers ADC or accepts service-account bootstrap material and creates the provider record. |
|
||||
| Gateway | Stores refresh material through the credential driver and rotates short-lived access tokens. |
|
||||
| Provider profile | Declares Vertex hosts, credential aliases, refresh constraints, and permitted binaries. |
|
||||
| Sandbox supervisor | Delivers opaque credential placeholders and resolves them only for profile-authorized Vertex requests. |
|
||||
| Workload | Selects the native Vertex endpoint, model, request format, streaming mode, and timeout. |
|
||||
|
||||
Alias resolution lives in `openshell_core::inference::normalize_inference_provider_type`
|
||||
and is the single source of truth shared by `openshell-server`, `openshell-providers`,
|
||||
and the CLI.
|
||||
The provider does not select a model or transform requests. Anthropic Claude
|
||||
uses Vertex's publisher-model `rawPredict` or `streamRawPredict` paths. Gemini
|
||||
and other models use their documented native or OpenAI-compatible Vertex API.
|
||||
|
||||
---
|
||||
## Credential Flows
|
||||
|
||||
## 2. Architecture — How the Pieces Fit Together
|
||||
Vertex accepts short-lived Google OAuth2 access tokens. Both supported setup
|
||||
flows converge on a rotating access token stored in the provider record.
|
||||
|
||||
```
|
||||
CLI (openshell provider create / openshell inference set)
|
||||
│
|
||||
├── read_gcloud_adc() reads ~/.config/gcloud/application_default_credentials.json
|
||||
├── CreateProviderRequest persists provider object (type, credentials, config)
|
||||
└── ConfigureProviderRefreshRequest registers a refresh state record
|
||||
### Service account
|
||||
|
||||
Gateway (openshell-server)
|
||||
│
|
||||
├── provider_refresh worker background loop that rotates access tokens
|
||||
│ ├── mint_oauth2_refresh_token() for gcloud ADC flow
|
||||
│ └── mint_google_service_account_jwt() for service account key flow
|
||||
│
|
||||
├── SetClusterInferenceRequest
|
||||
│ └── resolve_vertex_ai_route() builds RouterResolvedRoute
|
||||
│ ├── infer_vertex_publisher() model → publisher
|
||||
│ └── vertex_location_and_host() region → Vertex API host
|
||||
│
|
||||
└── GetInferenceBundleRequest (from sandbox on connect)
|
||||
└── resolve_route_by_name() re-resolves live route+credentials
|
||||
The operator creates the provider with service-account bootstrap material and
|
||||
configures `google-service-account-jwt` refresh. The private key remains in the
|
||||
gateway credential store. The gateway mints
|
||||
`GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN` and refreshes it before expiry.
|
||||
|
||||
Router (openshell-router)
|
||||
│
|
||||
├── proxy_with_candidates_streaming()
|
||||
│ ├── build_provider_url() appends model/:rawPredict or /chat/completions
|
||||
│ ├── sanitize_request_headers() strips auth, strips anthropic-beta for rawPredict
|
||||
│ └── prepare_backend_request()
|
||||
│ ├── bearer_auth(access_token) Authorization: Bearer ya29.*
|
||||
│ ├── remove "model" from body (rawPredict only — model is in the URL)
|
||||
│ └── inject "anthropic_version" (rawPredict only — must be in body, not header)
|
||||
│
|
||||
└── proxy_to_backend() / proxy_to_backend_streaming()
|
||||
### gcloud ADC
|
||||
|
||||
Sandbox (inference.local)
|
||||
└── agent connects to inference.local → gateway proxy → Vertex AI
|
||||
For local development, `--from-gcloud-adc` reads an authorized-user ADC file,
|
||||
stores the refresh grant at the gateway, and mints
|
||||
`GOOGLE_VERTEX_AI_TOKEN`. The ADC file and refresh token do not enter the
|
||||
sandbox.
|
||||
|
||||
## Runtime Data Flow
|
||||
|
||||
1. The operator attaches the provider to a sandbox.
|
||||
2. The effective policy includes the profile's Vertex endpoint and binary
|
||||
rules.
|
||||
3. A newly launched workload receives the current token as an opaque
|
||||
placeholder plus non-secret project and region configuration.
|
||||
4. The workload calls the native Vertex endpoint with that placeholder in the
|
||||
`Authorization: Bearer` header.
|
||||
5. After policy and endpoint binding pass, the proxy substitutes the current
|
||||
real access token and forwards the request.
|
||||
6. Token refresh updates the resolver; the workload continues using the same
|
||||
placeholder.
|
||||
|
||||
The raw `GOOGLE_SERVICE_ACCOUNT_KEY` credential is bootstrap-only and is never
|
||||
part of sandbox runtime material.
|
||||
|
||||
## Endpoint Boundary
|
||||
|
||||
The built-in profile permits official Vertex hosts:
|
||||
|
||||
- `<region>-aiplatform.googleapis.com`
|
||||
- `aiplatform.googleapis.com`
|
||||
- `aiplatform.us.rep.googleapis.com`
|
||||
- `aiplatform.eu.rep.googleapis.com`
|
||||
|
||||
The workload constructs the documented project, location, publisher, and model
|
||||
path. The proxy does not infer the publisher or rewrite the body.
|
||||
|
||||
For Claude on Vertex, a request uses this form:
|
||||
|
||||
```text
|
||||
https://<location>-aiplatform.googleapis.com/v1/projects/<project>/locations/<location>/publishers/anthropic/models/<model>:rawPredict
|
||||
```
|
||||
|
||||
Key crates and their roles:
|
||||
|
||||
| Crate | Role |
|
||||
|---|---|
|
||||
| `openshell-core` (`inference.rs`) | Canonical type aliases, profile constants, URL alias resolution |
|
||||
| `openshell-providers` | Environment-based credential discovery for `--from-existing` |
|
||||
| `openshell-server` (`inference.rs`) | Route resolution: maps provider + model → `RouterResolvedRoute` |
|
||||
| `openshell-server` (`provider_refresh.rs`) | Credential refresh worker; mints access tokens |
|
||||
| `openshell-router` (`backend.rs`) | Proxy: URL construction, header sanitization, body rewriting |
|
||||
| `openshell-cli` (`run.rs`) | `provider create` and `--from-gcloud-adc` CLI flow |
|
||||
| `providers/google-vertex-ai.yaml` | Provider type profile: credential keys, refresh strategy, endpoints |
|
||||
|
||||
---
|
||||
|
||||
## 3. Credential Model — Two Flows
|
||||
|
||||
Vertex AI accepts only short-lived GCP access tokens. The gateway never sends the raw
|
||||
service account JSON or gcloud ADC material to Vertex AI. Both flows converge on the
|
||||
same runtime secret: a `ya29.*` token stored under one of these credential keys,
|
||||
searched in priority order:
|
||||
|
||||
```
|
||||
GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN
|
||||
VERTEX_AI_SERVICE_ACCOUNT_TOKEN
|
||||
GOOGLE_VERTEX_AI_TOKEN
|
||||
VERTEX_AI_TOKEN
|
||||
```
|
||||
|
||||
These names are defined in `openshell_core::inference::VERTEX_AI_CREDENTIAL_KEY_NAMES`
|
||||
and are shared between the profile, the CLI, and the route resolver.
|
||||
|
||||
### 3a. Service Account Key Flow (production)
|
||||
|
||||
```
|
||||
User: openshell provider create --type google-vertex-ai \
|
||||
--credential GOOGLE_SERVICE_ACCOUNT_KEY="$(cat key.json)" \
|
||||
--config VERTEX_AI_PROJECT_ID=my-project \
|
||||
--config VERTEX_AI_REGION=us-central1
|
||||
|
||||
User: openshell provider refresh configure vertex-prod \
|
||||
--credential-key GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN \
|
||||
--strategy google-service-account-jwt \
|
||||
--material client_email="sa@..." \
|
||||
--material private_key="..." \
|
||||
--secret-material-key private_key
|
||||
|
||||
Gateway: mint_google_service_account_jwt()
|
||||
1. build JWT claims: iss=client_email, scope=cloud-platform, aud=token_url
|
||||
2. sign with RS256 using private_key
|
||||
3. POST assertion to https://oauth2.googleapis.com/token
|
||||
4. store access_token as GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN
|
||||
5. schedule next refresh 300 s before expiry (max 3600 s lifetime)
|
||||
```
|
||||
|
||||
The raw `GOOGLE_SERVICE_ACCOUNT_KEY` is stored as bootstrap material for the refresh
|
||||
worker. It is never exposed to sandboxes; the sandbox only ever sees the short-lived
|
||||
access token.
|
||||
|
||||
### 3b. gcloud Application Default Credentials Flow (local dev)
|
||||
|
||||
```
|
||||
User: gcloud auth application-default login
|
||||
User: openshell provider create --type google-vertex-ai \
|
||||
--from-gcloud-adc \
|
||||
--config VERTEX_AI_PROJECT_ID=my-project
|
||||
|
||||
CLI: read_gcloud_adc()
|
||||
checks GOOGLE_APPLICATION_CREDENTIALS → $CLOUDSDK_CONFIG/... → ~/.config/gcloud/adc.json
|
||||
validates type == "authorized_user" (rejects service_account — different flow)
|
||||
extracts client_id, client_secret, refresh_token
|
||||
|
||||
configure_provider_refresh(strategy=oauth2_refresh_token, ...)
|
||||
rotate_provider_credential(...) ← mints first token immediately
|
||||
|
||||
Gateway: mint_oauth2_refresh_token()
|
||||
POST to https://oauth2.googleapis.com/token
|
||||
grant_type=refresh_token, client_id, client_secret, refresh_token
|
||||
stores access_token as GOOGLE_VERTEX_AI_TOKEN
|
||||
if response includes a new refresh_token, rotates it in state
|
||||
```
|
||||
|
||||
The `--from-gcloud-adc` flag is rejected for any provider type other than
|
||||
`google-vertex-ai`. The CLI validates and reads the ADC file before creating the
|
||||
provider, so a missing or malformed ADC file results in a clean error with no orphaned
|
||||
gateway state.
|
||||
|
||||
### 3c. Refresh Worker
|
||||
|
||||
`provider_refresh.rs` runs a background tokio task (`spawn_refresh_worker`) that
|
||||
sweeps all `StoredProviderCredentialRefreshState` records on a configurable interval.
|
||||
For each record where `next_refresh_at_ms <= now` or `status == "rotation_requested"`,
|
||||
it calls `refresh_provider_credential`, which calls `mint_credential` and then
|
||||
`apply_minted_credential`. The minted access token is written back to the provider's
|
||||
`credentials` map under the configured `credential_key` via a CAS update.
|
||||
|
||||
Key timing constants:
|
||||
|
||||
- Default `refresh_before_seconds`: 300 (refresh 5 minutes before expiry)
|
||||
- Default `max_lifetime_seconds`: 3600 (token lifetime cap)
|
||||
- Error retry interval: 60 seconds
|
||||
|
||||
---
|
||||
|
||||
## 4. Route Resolution — From Provider to RouterResolvedRoute
|
||||
|
||||
When `openshell inference set --provider <name> --model <id>` is called, the server
|
||||
runs `resolve_vertex_ai_route` in `openshell-server/src/inference.rs`. This function
|
||||
produces a `RouterResolvedRoute` that the router uses verbatim for every proxied
|
||||
request.
|
||||
|
||||
### 4a. Publisher Inference
|
||||
|
||||
`infer_vertex_publisher(model_id)` maps model name prefixes to Vertex AI publishers:
|
||||
|
||||
| Prefix | Publisher | Routing |
|
||||
|---|---|---|
|
||||
| `claude-*` | `anthropic` | Anthropic Messages API (rawPredict) |
|
||||
| `gemini-*`, `text-bison-*`, `chat-bison-*` | `google` | OpenAI-compat Chat Completions |
|
||||
| `llama-*` | `meta` | OpenAI-compat Chat Completions |
|
||||
| `mistral-*`, `codestral-*` | `mistralai` | OpenAI-compat Chat Completions |
|
||||
| `jamba-*` | `ai21` | OpenAI-compat Chat Completions |
|
||||
| `deepseek-*` | `deepseek` | OpenAI-compat Chat Completions |
|
||||
| (unrecognized) | `None` | OpenAI-compat Chat Completions |
|
||||
|
||||
Only the `anthropic` result changes routing; all non-Anthropic publishers use the same
|
||||
OpenAI-compatible Vertex surface. The `VERTEX_AI_PUBLISHER` config key overrides
|
||||
inference: set it to `anthropic` to force rawPredict for a non-standard model name.
|
||||
|
||||
### 4b. Host Resolution
|
||||
|
||||
`vertex_location_and_host(region)` maps the `VERTEX_AI_REGION` config value to a
|
||||
Vertex API host:
|
||||
|
||||
| Region value | Host |
|
||||
|---|---|
|
||||
| `global` | `aiplatform.googleapis.com` |
|
||||
| `us` | `aiplatform.us.rep.googleapis.com` |
|
||||
| `eu` | `aiplatform.eu.rep.googleapis.com` |
|
||||
| `us-central1`, `europe-west4`, etc. | `<region>-aiplatform.googleapis.com` |
|
||||
|
||||
Default region when not set: `us-central1`.
|
||||
|
||||
### 4c. Endpoint and Protocol Selection
|
||||
|
||||
**Anthropic (Claude) models:**
|
||||
|
||||
```
|
||||
endpoint = https://{host}/v1/projects/{project}/locations/{location}/publishers/anthropic/models
|
||||
protocol = ["anthropic_messages"]
|
||||
model_in_path = true
|
||||
request_path_override = ":rawPredict"
|
||||
```
|
||||
|
||||
The model ID is NOT embedded in the endpoint URL. It is stored in `route.model` and
|
||||
appended by `build_provider_url` at proxy time: `{endpoint}/{model}:rawPredict` for
|
||||
buffered requests, `{endpoint}/{model}:streamRawPredict` for streaming.
|
||||
|
||||
**Non-Anthropic models (Gemini, Llama, Mistral, etc.):**
|
||||
|
||||
```
|
||||
endpoint = https://{host}/v1beta1/projects/{project}/locations/{location}/endpoints/openapi
|
||||
protocol = ["openai_chat_completions"]
|
||||
model_in_path = false
|
||||
request_path_override = "/chat/completions"
|
||||
```
|
||||
|
||||
**Base URL override (escape hatch, non-Anthropic only):**
|
||||
When `GOOGLE_VERTEX_AI_BASE_URL` or `VERTEX_AI_BASE_URL` is set:
|
||||
|
||||
- `GOOGLE_VERTEX_AI_BASE_URL` takes priority over `VERTEX_AI_BASE_URL`
|
||||
- Rejected with `InvalidArgument` for Anthropic models — Anthropic routes require
|
||||
model-path shaping that a bare URL override cannot preserve safely
|
||||
- Must be `https://`, no IP literals, no userinfo, port 443 only if explicit, must
|
||||
target an official Vertex AI hostname (pattern validated by `is_allowed_vertex_override_host`)
|
||||
|
||||
### 4d. Credential Lookup
|
||||
|
||||
For Vertex AI specifically, `find_provider_api_key` uses `CredentialLookup::PreferredOnly`:
|
||||
it searches only the four `VERTEX_AI_CREDENTIAL_KEY_NAMES` keys and returns `None` if
|
||||
none match. This prevents the raw service account JSON stored under
|
||||
`GOOGLE_SERVICE_ACCOUNT_KEY` from being mistakenly used as a bearer token, which would
|
||||
produce a confusing auth failure from Vertex AI.
|
||||
|
||||
### 4e. Model ID Validation
|
||||
|
||||
Before any URL is constructed, `validate_vertex_model_id` rejects model IDs that
|
||||
contain path separators (`/`, `\`), traversal segments (`..`), URL delimiters (`?`,
|
||||
`#`, `%`), control characters, or surrounding whitespace. This is defense-in-depth
|
||||
against injection into the URL path that appears in Anthropic rawPredict routes.
|
||||
|
||||
---
|
||||
|
||||
## 5. Request Proxying — Backend Transformations
|
||||
|
||||
The router (`openshell-router/src/backend.rs`) applies four Vertex-specific
|
||||
transformations on every proxied request:
|
||||
|
||||
### 5a. URL Construction
|
||||
|
||||
`build_provider_url` is called with `model_in_path` and `request_path_override` from
|
||||
the resolved route:
|
||||
|
||||
- **Anthropic buffered:** `{endpoint}/{model_id}:rawPredict`
|
||||
- **Anthropic streaming:** `{endpoint}/{model_id}:streamRawPredict`
|
||||
(`:rawPredict` suffix is upgraded to `:streamRawPredict` when `stream_response=true`)
|
||||
- **OpenAI-compat:** `{endpoint}/chat/completions`
|
||||
|
||||
### 5b. Authentication
|
||||
|
||||
All Vertex AI routes use `AuthHeader::Bearer`. The router injects
|
||||
`Authorization: Bearer {access_token}` where `access_token` is the `ya29.*` token
|
||||
read from the provider's credentials at route resolution time.
|
||||
|
||||
### 5c. Header Sanitization — Stripping `anthropic-beta`
|
||||
|
||||
For rawPredict routes (`is_vertex_anthropic_rawpredict_route`), `sanitize_request_headers`
|
||||
strips the `anthropic-beta` header even though it is in the route's `passthrough_headers`
|
||||
list. Vertex AI's rawPredict endpoint rejects requests that include `anthropic-beta` with
|
||||
HTTP 400. Beta feature enablement for Vertex AI is controlled through Google Cloud
|
||||
(Model Garden access), not HTTP headers. Claude Code always sends `anthropic-beta` flags;
|
||||
stripping them here prevents spurious 400 errors.
|
||||
|
||||
Direct Anthropic API routes (non-Vertex) still forward `anthropic-beta` unchanged.
|
||||
|
||||
### 5d. Body Rewriting — `model` and `anthropic_version`
|
||||
|
||||
For rawPredict routes, `prepare_backend_request` rewrites the JSON request body:
|
||||
|
||||
- **Removes `"model"` field.** Vertex AI rawPredict encodes the model in the URL path.
|
||||
Sending `"model"` in the body causes HTTP 400 "Extra inputs are not permitted". Claude
|
||||
Code and other Anthropic SDK clients always include `"model"` in the body; the router
|
||||
strips it unconditionally for rawPredict routes.
|
||||
|
||||
- **Injects `"anthropic_version": "vertex-2023-10-16"`.** The standard Anthropic API
|
||||
sends this as the `anthropic-version` request header. Vertex AI's rawPredict expects
|
||||
it as a JSON body field instead. The router injects it only when the client has not
|
||||
already sent it (`!obj.contains_key("anthropic_version")`). The constant
|
||||
`VERTEX_ANTHROPIC_VERSION = "vertex-2023-10-16"` is the Google-published value.
|
||||
|
||||
For OpenAI-compatible routes, the standard model rewrite applies: `"model"` in the body
|
||||
is overwritten with `route.model` (the model ID configured at `openshell inference set`
|
||||
time).
|
||||
|
||||
---
|
||||
|
||||
## 6. Configuration Keys
|
||||
|
||||
These keys are set at `openshell provider create` time via `--config KEY=VALUE` and
|
||||
stored in the provider's `config` map. They are re-read on every bundle resolution
|
||||
(i.e. on every sandbox connect), so changing them with `openshell provider update` takes
|
||||
effect for new sandboxes without restarting the gateway.
|
||||
|
||||
| Key | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `VERTEX_AI_PROJECT_ID` | Yes (unless base URL override set) | — | GCP project ID. Must be 6–30 chars, lowercase letters/digits/hyphens, no leading/trailing hyphen. |
|
||||
| `VERTEX_AI_REGION` | No | `us-central1` | GCP region or `global`/`us`/`eu`. Determines the Vertex API host. |
|
||||
| `GOOGLE_VERTEX_AI_BASE_URL` | No | — | Full base URL override for non-Anthropic routes. Takes priority over `VERTEX_AI_BASE_URL`. |
|
||||
| `VERTEX_AI_BASE_URL` | No | — | Backward-compatible alias for `GOOGLE_VERTEX_AI_BASE_URL`. |
|
||||
| `VERTEX_AI_PUBLISHER` | No | Inferred from model name | Set to `anthropic` to force rawPredict routing. |
|
||||
|
||||
Config key constants are defined in `openshell_core::inference`:
|
||||
`VERTEX_AI_PROJECT_ID_KEY`, `VERTEX_AI_REGION_KEY`, `VERTEX_AI_PUBLISHER_KEY`.
|
||||
|
||||
The full list of config keys scanned during `--from-existing` discovery is
|
||||
`VERTEX_AI_CONFIG_KEY_NAMES` in the same module.
|
||||
|
||||
---
|
||||
|
||||
## 7. Credential Keys and the Provider Profile
|
||||
|
||||
`providers/google-vertex-ai.yaml` is the authoritative provider type profile. It
|
||||
defines:
|
||||
|
||||
- **`service_account_key` (`GOOGLE_SERVICE_ACCOUNT_KEY`):** The raw service account JSON.
|
||||
This is gateway-side bootstrap material, not a sandbox credential. Not injected into
|
||||
sandboxes. `required: false` because some deployments use gcloud ADC instead.
|
||||
|
||||
- **`service_account_token` (`GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN`,
|
||||
`VERTEX_AI_SERVICE_ACCOUNT_TOKEN`):** Short-lived access token minted from the service
|
||||
account key via `google_service_account_jwt` refresh strategy. `auth_style: bearer`.
|
||||
Refreshed 300 s before expiry; max lifetime 3600 s; scope
|
||||
`https://www.googleapis.com/auth/cloud-platform`.
|
||||
|
||||
- **`gcloud_adc_token` (`GOOGLE_VERTEX_AI_TOKEN`, `VERTEX_AI_TOKEN`):** Short-lived
|
||||
access token minted from gcloud ADC via `oauth2_refresh_token` refresh strategy.
|
||||
Same `auth_style: bearer`, same timing.
|
||||
|
||||
The `discovery` section lists `[service_account_token, gcloud_adc_token]` as the two
|
||||
credential sources the gateway will scan during `--from-existing`.
|
||||
|
||||
The `endpoints` section enumerates all Vertex AI API hosts that sandbox network
|
||||
policies must permit when `providers_v2_enabled=true`:
|
||||
|
||||
- `*-aiplatform.googleapis.com:443` (regional endpoints)
|
||||
- `aiplatform.googleapis.com:443` (global endpoint)
|
||||
- `aiplatform.us.rep.googleapis.com:443` (US multi-region)
|
||||
- `aiplatform.eu.rep.googleapis.com:443` (EU multi-region)
|
||||
|
||||
---
|
||||
|
||||
## 8. `openshell-providers` — Discovery Plugin
|
||||
|
||||
`openshell-providers` handles credential discovery for `--from-existing`. There is no
|
||||
dedicated `vertex.rs` plugin file because Vertex AI token discovery is profile-driven:
|
||||
the gateway reads the `providers/google-vertex-ai.yaml` profile and scans the
|
||||
credential env vars listed there.
|
||||
|
||||
Vertex AI config keys (`VERTEX_AI_PROJECT_ID`, `VERTEX_AI_REGION`, etc.) are not listed
|
||||
in the profile's `discovery.credentials` section, so they are scanned separately in
|
||||
`discover_existing_provider_data` in the CLI:
|
||||
|
||||
```rust
|
||||
if provider_type == VERTEX_AI_PROVIDER_TYPE {
|
||||
for key in openshell_core::inference::VERTEX_AI_CONFIG_KEY_NAMES {
|
||||
if let Ok(val) = std::env::var(key) { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Provider type normalization for the `ProviderRegistry` (non-inference providers like
|
||||
`claude-code`, `github`, `gitlab`) is handled by `normalize_provider_type` in
|
||||
`openshell-providers/src/lib.rs`, which delegates Vertex AI aliases to
|
||||
`normalize_inference_provider_type` in `openshell-core`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Inference Routing in the Sandbox
|
||||
|
||||
When a sandbox agent connects to `https://inference.local`, the sandbox fetches the
|
||||
inference bundle from the gateway (`GetInferenceBundleRequest`). The bundle contains one
|
||||
or more `ResolvedRoute` proto messages built by `resolve_route_by_name`. For a Vertex AI
|
||||
route the bundle contains:
|
||||
|
||||
```
|
||||
ResolvedRoute {
|
||||
name: "inference.local",
|
||||
base_url: "https://us-central1-aiplatform.googleapis.com/v1/projects/.../publishers/anthropic/models",
|
||||
model_id: "claude-sonnet-4-20250514",
|
||||
api_key: "ya29.<short-lived-token>",
|
||||
protocols: ["anthropic_messages"],
|
||||
provider_type: "google-vertex-ai",
|
||||
model_in_path: true,
|
||||
request_path_override: ":rawPredict",
|
||||
}
|
||||
```
|
||||
|
||||
The sandbox proxy uses this bundle to configure the local `inference.local` route. The
|
||||
bundle is re-fetched on reconnect, which picks up rotated access tokens automatically
|
||||
without sandbox restart.
|
||||
|
||||
The gateway does NOT expose GCP credentials (project ID, region, service account key)
|
||||
in the bundle. Sandboxes see only the short-lived access token.
|
||||
|
||||
---
|
||||
|
||||
## 10. Sandbox Usage Pattern
|
||||
|
||||
Agents inside sandboxes connect to Vertex AI through `inference.local`. The correct
|
||||
setup differs by model family:
|
||||
|
||||
**Claude (Anthropic Messages API):**
|
||||
|
||||
```sh
|
||||
ANTHROPIC_BASE_URL="https://inference.local" ANTHROPIC_API_KEY=unused claude --bare
|
||||
```
|
||||
|
||||
The `ANTHROPIC_API_KEY` value is stripped by the gateway and replaced with the real
|
||||
GCP token. `--bare` skips Claude Code's OAuth flow. Do NOT set
|
||||
`CLAUDE_CODE_USE_VERTEX=1` inside a sandbox — that makes Claude Code connect directly
|
||||
to Vertex AI and attempt GCP ADC discovery, which fails in the sandbox environment.
|
||||
|
||||
**Gemini / other models (OpenAI-compat):**
|
||||
Point the SDK's base URL at `https://inference.local/v1` and use any non-empty value
|
||||
as the API key.
|
||||
|
||||
**Common sandbox policy denials to expect:**
|
||||
|
||||
- `metadata.google.internal:80` — resolves to `169.254.169.254` (GCE metadata service).
|
||||
Always blocked by the proxy unconditionally.
|
||||
- `downloads.claude.ai:443` — Claude Code update checking. Block or approve per policy.
|
||||
- `storage.googleapis.com:443` — GCS access. Optional; approve if the agent needs it.
|
||||
|
||||
---
|
||||
|
||||
## 11. Key Files Reference
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `providers/google-vertex-ai.yaml` | Provider type profile: credential keys, refresh strategy params, allowed endpoints |
|
||||
| `docs/providers/google-vertex-ai.mdx` | User-facing documentation |
|
||||
| `crates/openshell-core/src/inference.rs` | Canonical provider type aliases, `InferenceProviderProfile`, `VERTEX_AI_*` constants, auth header logic |
|
||||
| `crates/openshell-server/src/inference.rs` | Route resolution: `resolve_vertex_ai_route`, `infer_vertex_publisher`, `vertex_location_and_host`, model ID validation |
|
||||
| `crates/openshell-server/src/provider_refresh.rs` | Refresh worker: `mint_google_service_account_jwt`, `mint_oauth2_refresh_token`, `apply_minted_credential` |
|
||||
| `crates/openshell-router/src/backend.rs` | Proxy engine: `build_provider_url`, `sanitize_request_headers`, `prepare_backend_request` (model strip + anthropic_version inject) |
|
||||
| `crates/openshell-router/src/config.rs` | `ResolvedRoute` struct with `model_in_path`, `request_path_override` fields |
|
||||
| `crates/openshell-cli/src/run.rs` | `provider_create`, `read_gcloud_adc`, `rollback_provider_create_after_vertex_adc_failure` |
|
||||
| `crates/openshell-providers/src/lib.rs` | `ProviderRegistry`, `normalize_provider_type`, `discover_existing_provider_data` |
|
||||
|
||||
---
|
||||
|
||||
## 12. Maintenance Notes
|
||||
|
||||
### Adding a new Vertex AI region
|
||||
|
||||
No code changes are needed for standard regional endpoints (`<region>-aiplatform.googleapis.com`).
|
||||
`vertex_location_and_host` constructs the host from the region string dynamically. For
|
||||
new special-case multi-region endpoints (analogous to `us` and `eu`), update the match
|
||||
arm in `vertex_location_and_host` in `openshell-server/src/inference.rs`.
|
||||
|
||||
Add the new host pattern to the `endpoints` list in `providers/google-vertex-ai.yaml`
|
||||
so it is included in sandbox network policy injection.
|
||||
|
||||
### Adding a new model family publisher
|
||||
|
||||
Add a prefix match arm to `infer_vertex_publisher` in `openshell-server/src/inference.rs`.
|
||||
Unless the new publisher uses a different wire format than the OpenAI-compatible
|
||||
endpoint, no other changes are needed — all non-Anthropic publishers currently route
|
||||
to the same `endpoints/openapi/chat/completions` surface.
|
||||
|
||||
If the new publisher requires a separate rawPredict-style surface (like Anthropic),
|
||||
update `resolve_vertex_ai_route` to add a new branch for that publisher, and add the
|
||||
corresponding body transformation logic in `prepare_backend_request` in
|
||||
`openshell-router/src/backend.rs`.
|
||||
|
||||
### Updating `VERTEX_ANTHROPIC_VERSION`
|
||||
|
||||
The constant `VERTEX_ANTHROPIC_VERSION = "vertex-2023-10-16"` in
|
||||
`openshell-router/src/backend.rs` is the version string Google requires in the body of
|
||||
rawPredict requests. Update it when Google publishes a new required version for the
|
||||
Vertex AI Anthropic API. Check the
|
||||
[Google Cloud Claude documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude)
|
||||
for the current required value.
|
||||
|
||||
### Rotating the GCP OAuth2 token endpoint URL
|
||||
|
||||
`google_token_url` in `provider_refresh.rs` defaults to `https://oauth2.googleapis.com/token`
|
||||
when `state.token_url` is empty. The `providers/google-vertex-ai.yaml` profile sets this
|
||||
in the `refresh.token_url` field. If Google changes the token endpoint, update both:
|
||||
|
||||
- `refresh.token_url` in `providers/google-vertex-ai.yaml`
|
||||
- The default in `google_token_url` in `provider_refresh.rs`
|
||||
|
||||
### Changing the `cloud-platform` OAuth scope
|
||||
|
||||
The scope `https://www.googleapis.com/auth/cloud-platform` is set in the
|
||||
`refresh.scopes` list in `providers/google-vertex-ai.yaml`. The gateway uses it when
|
||||
constructing JWT claims and OAuth2 refresh requests. If a narrower scope becomes
|
||||
available (e.g. a Vertex AI-specific scope), update the profile's `scopes` list.
|
||||
|
||||
### Adding a new Vertex AI body invariant
|
||||
|
||||
Body transformations for rawPredict live in `prepare_backend_request` in
|
||||
`openshell-router/src/backend.rs`, gated by `is_vertex_anthropic_rawpredict_route`.
|
||||
When adding a new transformation:
|
||||
|
||||
1. Add the transformation in the `needs_vertex_anthropic_version` branch.
|
||||
2. Add a wiremock-based integration test directly below the existing tests in that file.
|
||||
3. Ensure the new transformation does not apply to standard Anthropic or OpenAI routes.
|
||||
The request body includes the Vertex Anthropic API version expected by Google.
|
||||
Streaming uses the corresponding native streaming endpoint.
|
||||
|
||||
## Invariants
|
||||
|
||||
- Refresh bootstrap material remains gateway-only.
|
||||
- Sandboxes receive placeholders, never real access tokens.
|
||||
- A token resolves only at endpoints covered by the attached provider profile.
|
||||
- Detach and expiry revoke placeholder resolution.
|
||||
- Project IDs, regions, publishers, and model IDs are non-secret workload
|
||||
configuration.
|
||||
- Provider attachment does not grant access when a gateway global policy
|
||||
override suppresses provider-derived policy.
|
||||
- Provider environment keys and dynamic credential bindings remain
|
||||
unambiguous across all providers attached to one sandbox.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
Attach or detach the provider with the normal sandbox provider lifecycle. A
|
||||
running sandbox observes policy and resolver changes, but a process must be
|
||||
launched after attachment to receive new environment variables. Direct native
|
||||
requests provide the end-to-end verification path; provider creation does not
|
||||
probe a model endpoint or validate a model ID.
|
||||
|
||||
@@ -40,6 +40,15 @@ New limits should follow these rules:
|
||||
query parameters, or external free-form diagnostics.
|
||||
- Test time bounds with simulated time and test shared budgets under saturation.
|
||||
|
||||
## Gateway Sandbox Resources
|
||||
|
||||
Gateway-owned sandbox resources also carry admission limits before they can
|
||||
produce supervisor work. Reusable workload templates are capped at 1000 per
|
||||
workspace. Template payloads reuse sandbox spec validation for environment
|
||||
entry count and size, image and resource field sizes, driver-config serialized
|
||||
size, and GPU count. Template names use the same DNS-style resource-name rules
|
||||
as other named gateway resources.
|
||||
|
||||
## Middleware
|
||||
|
||||
Middleware limits are process-wide per sandbox. Registry replacement preserves
|
||||
@@ -112,21 +121,15 @@ middleware. A passed binary logical message still advances the active
|
||||
middleware session sequence and emits coverage telemetry, so a later text RPC
|
||||
can contain a valid sequence gap.
|
||||
|
||||
## Inference and Upstream Proxying
|
||||
## Network and Upstream Proxying
|
||||
|
||||
| Path | Current bound | Terminal behavior |
|
||||
|---|---:|---|
|
||||
| `inference.local` request parse buffer | 10 MiB | Return `413` for an oversized request. |
|
||||
| Chunked inference request | 10 MiB and 4,096 chunks | Reject an invalid or over-limit request. |
|
||||
| Streaming inference response | 32 MiB and 120 s chunk idle | Truncate the stream and attempt a safe SSE error. |
|
||||
| Corporate proxy CONNECT response headers | 8 KiB | Fail the tunnel. |
|
||||
| Corporate proxy CONNECT handshake | 30 s total | Fail the tunnel; validated-address attempts share the aggregate budget. |
|
||||
| Token-grant HTTP request | 30 s request and connect | Fail credential resolution. |
|
||||
| Response-derived token cache TTL | 5 min default; 1 h response cap; 30 s expiry margin | A positive profile `cache_ttl_seconds` override replaces the response-derived calculation. |
|
||||
|
||||
Streaming response byte limits are integrity-relevant. Protocols whose clients
|
||||
require one complete buffered object do not use the truncating SSE path.
|
||||
|
||||
## Sandbox-Local Surfaces
|
||||
|
||||
| Surface | Current bound | Scope and behavior |
|
||||
|
||||
+54
-16
@@ -28,7 +28,7 @@ only when the set is already empty; any other outcome fails the spawn.
|
||||
2. The supervisor loads policy and runtime settings from local files or the
|
||||
gateway, depending on mode.
|
||||
3. It prepares filesystem access, process restrictions, network namespace
|
||||
routing, trust stores, provider credential resolution, and inference routes.
|
||||
routing, trust stores, and provider credential resolution.
|
||||
4. It launches the persisted canonical main-process argv and retains its PTY
|
||||
or pipes in the main-session multiplexer.
|
||||
5. It starts the policy proxy and local SSH server.
|
||||
@@ -45,12 +45,12 @@ OpenShell uses overlapping controls rather than a single sandbox primitive:
|
||||
| Process policy | The child process runs as a non-root user with reduced privileges. |
|
||||
| Seccomp | Blocks dangerous syscalls, including raw socket paths that bypass the proxy. |
|
||||
| Network namespace | Forces ordinary agent egress through the local CONNECT proxy. |
|
||||
| Policy proxy | Evaluates destination, binary identity, TLS/L7 rules, SSRF checks, and inference interception. |
|
||||
| Policy proxy | Evaluates destination, binary identity, TLS/L7 rules, SSRF checks, and endpoint-bound credential injection. |
|
||||
|
||||
The supervisor may enrich baseline filesystem allowances for runtime-required
|
||||
paths, such as proxy support files or GPU device paths when a GPU is present.
|
||||
|
||||
## Network and Inference
|
||||
## Network and Provider Access
|
||||
|
||||
See [Sandbox Limits](sandbox-limits.md) for the current numeric safety ceilings,
|
||||
their ownership, terminal behavior, and known gaps.
|
||||
@@ -157,6 +157,8 @@ support `params` matchers; generic JSON-RPC rules match only the method.
|
||||
JSON-RPC responses and server-to-client MCP messages on response or SSE streams
|
||||
are relayed but are not currently parsed for policy enforcement.
|
||||
|
||||
Every `protocol: mcp` endpoint carries a canonical, nonempty `mcp.versions` allowlist drawn from OpenShell's exact revision registry: `2025-03-26`, `2025-06-18`, and `2025-11-25`. A policy author may omit the entire `mcp` object when using the other endpoint defaults, or omit `mcp.versions` while setting another MCP option. Both forms resolve immediately to the exact allowlist `["2025-11-25"]`; omission never means latest or all known revisions. Defaulting applies only when the corresponding YAML key is absent: `mcp: null`, `versions: null`, and an explicit `versions: []` are invalid. At protobuf ingress, an empty repeated field means omission and uses the same default because protobuf repeated fields do not preserve presence. Normalization stores and serializes the materialized allowlist in semantic order, so adding a supported revision to the registry never widens a previously normalized policy. An explicit nonempty allowlist remains available as an advanced compatibility or downgrade control. The registry is a closed set rather than a date range, so duplicate or padded values, unknown dates, and moving aliases such as `draft` or `latest` are rejected. The sessionless `2026-07-28` revision is not accepted until OpenShell supports its distinct per-request contract. A version names a core protocol revision only; there is no policy syntax for layering a separately named SEP onto it. For every MCP HTTP request except a valid standalone `initialize`, the supervisor reads exactly one `MCP-Protocol-Version` header and requires that revision to appear in the endpoint allowlist. If the header is absent, the MCP transport specification defines `2025-03-26` as the compatibility fallback; OpenShell permits that fallback only when the allowlist contains it. Duplicate, empty, or unsupported header values receive `400 Bad Request`, while a supported revision outside the allowlist receives `403 Forbidden`. The supervisor repeats this check after middleware changes the request and before any upstream write. This check does not store session state or infer a version from a previous connection request. The registry also owns immutable batch-shape metadata: `2025-03-26` permits nonempty same-side top-level JSON-RPC batches, which OpenShell's planned enforcement caps at 64 members, while `2025-06-18` and `2025-11-25` prohibit top-level arrays. The current request parser does not yet apply these version-specific batch rules.
|
||||
|
||||
For admitted HTTP requests, the proxy can run an ordered supervisor middleware
|
||||
chain after L7 policy evaluation and before credential injection. Destination
|
||||
host selectors choose the chain independently of the network rule that admitted
|
||||
@@ -169,6 +171,10 @@ the remote adapter materializes an owned HTTP evaluation only when a request
|
||||
crosses that transport boundary. Both paths support bounded bidirectional
|
||||
WebSocket sessions, so a manifest advertises capabilities independently of
|
||||
transport.
|
||||
When a stage ends, the remote adapter sends its terminal event, half-closes the
|
||||
request stream, and briefly drains the response stream before releasing the
|
||||
transport. This keeps a queued terminal event from being canceled with the
|
||||
bidirectional RPC.
|
||||
The runtime keeps three states distinct: host selection attaches policy configs,
|
||||
manifest operation and phase bindings select the active chain, and the parsed
|
||||
message type determines whether that chain can inspect an individual payload.
|
||||
@@ -200,8 +206,9 @@ polling runs far more frequently than credentials expire, so the loop rotates
|
||||
only when a credential is missing or has passed four fifths of its lifetime,
|
||||
and bounds its sleep by the soonest rotation deadline.
|
||||
|
||||
Middleware cannot observe injected credentials or mutate supervisor-owned
|
||||
credential, routing, or framing headers. Body transformations are re-evaluated
|
||||
Middleware cannot observe injected credentials, introduce credential
|
||||
placeholders, or mutate supervisor-owned credential, routing, or framing
|
||||
headers. Body transformations are re-evaluated
|
||||
against body-aware L7 policy before later stages or the upstream can observe
|
||||
them. Requests, results, chain length, execution time, and diagnostics are
|
||||
bounded; external free-form diagnostic text is not exposed in responses or
|
||||
@@ -209,17 +216,11 @@ security logs. See
|
||||
[Supervisor Middleware](../docs/extensibility/supervisor-middleware.mdx) for
|
||||
configuration and protocol details.
|
||||
|
||||
`https://inference.local` is special. It bypasses OPA network policy and is
|
||||
handled by the inference interception path:
|
||||
|
||||
1. The proxy terminates the local TLS connection with the sandbox CA.
|
||||
2. It detects known OpenAI, Anthropic, and compatible inference request shapes.
|
||||
3. It strips caller-supplied credentials and disallowed headers.
|
||||
4. It forwards through `openshell-router` using the route bundle fetched from
|
||||
the gateway.
|
||||
|
||||
External inference endpoints that do not use `inference.local` are treated like
|
||||
ordinary network traffic and must be allowed by policy.
|
||||
Inference providers use the same egress path as other external services. An
|
||||
attached provider profile contributes endpoint and binary policy. The proxy
|
||||
then resolves the provider's credential placeholder only when both policy and
|
||||
the profile's endpoint binding authorize the native request. Model selection,
|
||||
request shape, headers, streaming, and timeouts remain client concerns.
|
||||
|
||||
In proxy-required networks, the supervisor chains upstream TLS tunnels through
|
||||
a corporate forward proxy with HTTP CONNECT instead of connecting directly,
|
||||
@@ -259,6 +260,12 @@ own DNS view, e.g. DoH tunneled via CONNECT, is a possible future
|
||||
enhancement and out of scope.) The workload child's proxy variables are
|
||||
unaffected — they are always rewritten to point at the local policy proxy.
|
||||
|
||||
Template environment is treated like user-provided sandbox environment. It can
|
||||
shape the workload child, but it cannot override driver-controlled identity,
|
||||
gateway callback, TLS, relay socket, proxy, provider, or supervisor coordination
|
||||
variables. Drivers and the supervisor rewrite those reserved values after image
|
||||
and template environment are considered.
|
||||
|
||||
The configuration is fail-closed: a setting that is present but invalid — an
|
||||
empty value, an unsupported or malformed proxy URL, an unreadable auth file or
|
||||
CA bundle, a malformed credential, or an auth file, `NO_PROXY` list, or CA
|
||||
@@ -294,6 +301,35 @@ file and builds the `Proxy-Authorization: Basic` header; a credential that is
|
||||
empty, contains control characters, or is not in `user:pass` form is fatal on
|
||||
both sides.
|
||||
|
||||
The VM driver has no argv seam of its own: its guest init script runs as PID 1
|
||||
and execs a fixed supervisor command line, and the libkrun and QEMU launch
|
||||
backends both reach the supervisor through that script. Driver-owned
|
||||
supervisor arguments therefore travel in a per-sandbox file the driver writes
|
||||
into the overlay upperdir at a fixed guest path, one argument per line, which
|
||||
the guest reads verbatim (no word splitting or globbing) and appends to every
|
||||
supervisor exec. The file is written on **every** launch, including an empty
|
||||
file when there is nothing to pass: the upperdir copy always shadows the
|
||||
read-only image layer, so a sandbox image can neither supply its own
|
||||
supervisor arguments by baking a file at that path nor disable the operator's
|
||||
by omitting one. This mirrors the driver-authored `init.d` manifest, which
|
||||
solves the same trust problem for guest init drop-ins.
|
||||
|
||||
A microVM has no bind mounts or container secrets, so the VM driver stages the
|
||||
credential and the CA bundle into the per-sandbox overlay disk instead — the
|
||||
credential root-only, the CA world-readable, both at fixed `/opt/openshell`
|
||||
paths and both removed with the sandbox state directory. The consequence,
|
||||
which differs from the Podman secret model, is that the credential is at rest
|
||||
inside that overlay image on the gateway host; the per-sandbox gateway JWT
|
||||
already travels the same path. Proxy reachability differs by VM backend. libkrun-backed
|
||||
sandboxes egress through gvproxy, so a proxy on the gateway host's loopback is
|
||||
reachable through the host alias `host.openshell.internal`, which gvproxy NATs
|
||||
to the host's `127.0.0.1`. QEMU/TAP sandboxes (GPU) have no equivalent: that
|
||||
alias resolves to the TAP host address, and the driver's nftables `input`
|
||||
chain accepts only the gateway port from the guest, so no gateway-host proxy
|
||||
is reachable. The driver rejects a gateway-host proxy URL on the QEMU path at
|
||||
launch rather than producing CONNECT timeouts. The guest's gateway callback is
|
||||
unaffected in both backends and never traverses the proxy.
|
||||
|
||||
For Kubernetes sandboxes, the operator configures a Secret name and key rather
|
||||
than a gateway-host file path. Kubernetes projects that Secret only into the
|
||||
container that runs network supervision. Proxy credential Secrets require the
|
||||
@@ -419,6 +455,8 @@ sandbox workload directly. The relay supports:
|
||||
Sandbox logs are emitted locally and can also be pushed back to the gateway.
|
||||
Security-relevant sandbox behavior uses OCSF structured events; internal
|
||||
diagnostics use ordinary tracing.
|
||||
The OCSF device describes the sandbox environment, with type ID Other and type
|
||||
label `Sandbox`; its operating system is a separate attribute.
|
||||
|
||||
## Policy Proposals
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ For the field-by-field YAML reference, use
|
||||
| Filesystem | Landlock restricts read-only and read-write paths. |
|
||||
| Process | The supervisor launches the agent as an unprivileged user with reduced capabilities. |
|
||||
| Network | The proxy evaluates destination, port, calling binary, and optional L7 rules. |
|
||||
| Inference | `inference.local` is configured through gateway inference settings, not OPA network policy. |
|
||||
| Provider access | Attached provider profiles contribute endpoint and binary rules; credentials remain bound to profile-authorized endpoints. |
|
||||
| Runtime settings | Typed settings are delivered with policy and can be global or sandbox scoped. |
|
||||
|
||||
Filesystem and process policy are startup-time controls. Network policy is
|
||||
@@ -97,6 +97,13 @@ raw relay by default. A `protocol: rest` endpoint can opt in to
|
||||
after an allowed `101` upgrade; server-to-client traffic and all other upgraded
|
||||
protocols remain raw passthrough.
|
||||
|
||||
A `protocol: tcp` hostname is a connection-routing constraint, not an
|
||||
application-authority boundary. Transparent capture validates the approved DNS
|
||||
name, pinned destination address, port, and calling binary before opening the
|
||||
stream, but it does not inspect TLS SNI, HTTP `Host`, or another protocol-level
|
||||
destination. Compatible shared infrastructure can therefore let a client
|
||||
select another tenant, virtual host, or service behind the approved front door.
|
||||
|
||||
## Credentialed Endpoints
|
||||
|
||||
OpenShell keeps provider credentials on paths it can inspect or rewrite by
|
||||
@@ -216,6 +223,12 @@ through the proposal loop instead of treating the denial as terminal.
|
||||
|
||||
1. **Submit.** Both proposers POST through the same `SubmitPolicyAnalysis`
|
||||
path. Each chunk is persisted with its `analysis_mode` for audit provenance.
|
||||
Agent-authored chunks cannot request `protocol: tcp` or `tls: skip`; the
|
||||
sandbox-local API rejects those transport choices for immediate feedback,
|
||||
and the gateway repeats the check before persistence.
|
||||
Omitted-protocol endpoints remain available through the explicit proxy with
|
||||
default TLS termination and HTTP authority checks. Administrators can still
|
||||
author native TCP and raw TLS policy directly.
|
||||
2. **Build and validate the candidate.** The gateway first canonicalizes a
|
||||
mechanistic proposal against the live effective policy. If an endpoint is
|
||||
already governed by an inspected or provider-owned contract, the candidate
|
||||
@@ -268,6 +281,22 @@ After any successful policy write, pending chunks already covered by the new
|
||||
live effective policy are rejected as redundant. This keeps the review inbox
|
||||
aligned with what the sandbox currently enforces.
|
||||
|
||||
Endpoint and binary advisor markers are provenance, not authorization or
|
||||
connection metadata. Provider- or user-authored declarations carry explicit
|
||||
provenance; `policy.local` declarations carry advisor provenance. A difference
|
||||
in endpoint provenance alone is compatible during effective-policy ambiguity
|
||||
validation. When identical endpoint or binary identities merge, an explicit
|
||||
declaration dominates an advisor declaration. Proposal coverage likewise
|
||||
ignores provenance so an approved overlay converges when an existing explicit
|
||||
declaration already supplies the same identity.
|
||||
|
||||
This compatibility does not weaken SSRF classification. Exact-host trust
|
||||
requires one matching rule to contain both an exact explicit endpoint and an
|
||||
explicit binary identity. An advisor-only endpoint or binary cannot assemble
|
||||
that trust from unrelated rules. A provider rule may independently establish
|
||||
trust for its own explicit endpoint and binary pair, but an advisor overlay
|
||||
does not broaden that pair to a different binary.
|
||||
|
||||
### Security-notes gate
|
||||
|
||||
Separately from the prover, each chunk carries advisory `security_notes`.
|
||||
|
||||
@@ -10,6 +10,9 @@ driver. It does not make Windows a Docker, Kubernetes, Podman, or VM runtime hos
|
||||
- Keep the Linux and macOS build paths unchanged.
|
||||
- Preserve gateway configuration parsing for all existing compute driver names.
|
||||
- Build and test the in-process MXC driver on supported Windows hosts.
|
||||
- Use the ordinary in-process compute-driver composition path; MXC receives the
|
||||
canonical sandbox policy through `DriverSandboxSpec` and advertises that it
|
||||
reports runtime readiness.
|
||||
- Return clear unsupported errors when a Windows gateway is configured to use Docker, Kubernetes, Podman, or VM.
|
||||
- Keep dedicated `windows:*` validation tasks while allowing the repository-wide
|
||||
`pre-commit` task to delegate compiler-bearing Rust checks to the native
|
||||
@@ -23,9 +26,10 @@ driver. It does not make Windows a Docker, Kubernetes, Podman, or VM runtime hos
|
||||
|
||||
## Unsupported Driver Strategy
|
||||
|
||||
The gateway uses platform-specific configuration contracts on Windows. These
|
||||
contracts preserve config-file parsing and reject unsupported driver selection
|
||||
with a clear error without depending on the runtime driver crates.
|
||||
The gateway composition crate installs platform-specific registration stubs on
|
||||
Windows. These registrations preserve config-file selection and reject
|
||||
unsupported drivers with a clear error without depending on their runtime
|
||||
crates.
|
||||
|
||||
The Windows lane does not build, release, package, or smoke-test standalone
|
||||
driver binaries for Docker, Kubernetes, Podman, or VM. Those binaries are Linux
|
||||
@@ -39,10 +43,10 @@ on Windows.
|
||||
|
||||
| Driver | Windows build behavior | Runtime behavior |
|
||||
|---|---|---|
|
||||
| Docker | Driver crate excluded; server config contract retained. | Gateway construction returns unsupported. |
|
||||
| Kubernetes | Driver crate excluded; server config contract retained. | Gateway construction returns unsupported. |
|
||||
| Podman | Driver crate excluded; server config contract retained. | Gateway construction returns unsupported. |
|
||||
| VM | Driver crate excluded from workspace validation. | Gateway construction returns unsupported. |
|
||||
| Docker | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. |
|
||||
| Kubernetes | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. |
|
||||
| Podman | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. |
|
||||
| VM | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. |
|
||||
| MXC | Driver links into the native gateway and runs in Windows validation. | `process_container` is default-deny; grant-only `isolation_session` requires explicit configuration. |
|
||||
|
||||
This keeps Windows behavior explicit without carrying runtime dependencies or
|
||||
@@ -66,7 +70,7 @@ Windows validation is exposed through `tasks/windows.toml`:
|
||||
| `windows:build:arm64` | Build release ARM64 `openshell-gateway.exe` and `openshell.exe`. |
|
||||
| `windows:test:x64` | Run native x64 workspace tests, including MXC mapper and lifecycle tests, while excluding unsupported Windows packages as top-level test targets. |
|
||||
| `windows:test:arm64` | Run native ARM64 workspace tests with the same package exclusions. |
|
||||
| `windows:test:unsupported:x64` | Run focused server/runtime tests for unsupported driver contracts. |
|
||||
| `windows:test:unsupported:x64` | Run focused gateway-composition tests for unsupported driver contracts. |
|
||||
| `windows:test:unsupported:arm64` | Run the same focused contracts natively on ARM64. |
|
||||
| `windows:ci` | Run check, build, test, unsupported-contract tests, and artifact reporting. |
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
version: v2
|
||||
modules:
|
||||
- path: proto
|
||||
- path: crates/openshell-server/proto
|
||||
lint:
|
||||
use:
|
||||
- STANDARD
|
||||
|
||||
@@ -12,20 +12,15 @@ rust-version.workspace = true
|
||||
[dependencies]
|
||||
openshell-core = { path = "../openshell-core", default-features = false }
|
||||
bytes = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
miette = { workspace = true }
|
||||
rcgen = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tar = "0.4"
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_os = "windows"))'.dependencies]
|
||||
bollard = "0.20"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -1,677 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Build container images for sandbox runtimes.
|
||||
//!
|
||||
//! This module wraps bollard's `build_image()` API to build a container image
|
||||
//! from a Dockerfile and build context. Package-managed local gateways use the
|
||||
//! host Docker daemon, so the resulting tag is passed to the gateway directly.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use bollard::Docker;
|
||||
use bollard::query_parameters::BuildImageOptionsBuilder;
|
||||
use futures::StreamExt;
|
||||
use miette::{IntoDiagnostic, Result, WrapErr};
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// Maximum gap between Docker build stream events before a build is treated
|
||||
/// as stuck.
|
||||
///
|
||||
/// Total silence longer than this on under-provisioned container runtimes
|
||||
/// (e.g. default Colima 2 vCPU / 2 GiB on macOS) reliably indicates a
|
||||
/// deadlocked builder that will never recover. The default leaves headroom
|
||||
/// for legitimately quiet steps (a single long `RUN` that produces no output)
|
||||
/// — override with `OPENSHELL_BUILD_NO_PROGRESS_TIMEOUT_SECS` if a specific
|
||||
/// build needs more time, or shorter for CI tightening.
|
||||
const DEFAULT_BUILD_NO_PROGRESS_TIMEOUT_SECS: u64 = 1800;
|
||||
|
||||
async fn docker_daemon_platform(docker: &Docker) -> Result<String> {
|
||||
let info = docker
|
||||
.info()
|
||||
.await
|
||||
.into_diagnostic()
|
||||
.wrap_err("failed to query local Docker daemon info")?;
|
||||
let os = info
|
||||
.os_type
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| miette::miette!("Docker daemon did not report an operating system"))?;
|
||||
let arch = info
|
||||
.architecture
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| miette::miette!("Docker daemon did not report an architecture"))?;
|
||||
|
||||
normalize_docker_platform(os, arch)
|
||||
}
|
||||
|
||||
fn normalize_docker_platform(os: &str, arch: &str) -> Result<String> {
|
||||
// Remote or non-standard daemons may return mixed-case arch strings.
|
||||
let arch_lower = arch.to_lowercase();
|
||||
let normalized_arch = match arch_lower.as_str() {
|
||||
"amd64" | "x86_64" => "amd64",
|
||||
"arm64" | "aarch64" => "arm64",
|
||||
"arm" | "armv7" | "armv7l" | "armhf" => "arm/v7",
|
||||
"armv6" | "armv6l" => "arm/v6",
|
||||
"386" | "i386" | "i686" => "386",
|
||||
"ppc64le" => "ppc64le",
|
||||
"s390x" => "s390x",
|
||||
_ => {
|
||||
return Err(miette::miette!(
|
||||
"unsupported Docker daemon architecture for local image build: {arch}"
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(format!("{os}/{normalized_arch}"))
|
||||
}
|
||||
|
||||
/// Parse `os/arch[/variant]` and reject malformed empty segments.
|
||||
fn parse_platform(platform: &str) -> Result<(&str, &str, &str)> {
|
||||
let mut parts = platform.split('/');
|
||||
let os = parts
|
||||
.next()
|
||||
.filter(|v| !v.is_empty())
|
||||
.ok_or_else(|| miette::miette!("platform is missing an operating system"))?;
|
||||
let arch = parts
|
||||
.next()
|
||||
.filter(|v| !v.is_empty())
|
||||
.ok_or_else(|| miette::miette!("platform is missing an architecture"))?;
|
||||
// A trailing slash creates a malformed empty variant segment.
|
||||
let variant = match parts.next() {
|
||||
Some("") => {
|
||||
return Err(miette::miette!(
|
||||
"platform variant must not be empty (trailing slash?): '{platform}'"
|
||||
));
|
||||
}
|
||||
Some(v) => v,
|
||||
None => "",
|
||||
};
|
||||
if parts.next().is_some() {
|
||||
return Err(miette::miette!(
|
||||
"platform must be os/arch[/variant], got '{platform}'"
|
||||
));
|
||||
}
|
||||
Ok((os, arch, variant))
|
||||
}
|
||||
|
||||
/// Seed Docker's implicit `BuildKit` platform args without replacing caller values.
|
||||
///
|
||||
/// `target_platform` identifies the output image. `build_platform` identifies
|
||||
/// the Docker daemon doing the build. Variant args are only injected when the
|
||||
/// platform string has an explicit variant, which preserves Dockerfile ARG
|
||||
/// defaults for no-variant platforms.
|
||||
fn insert_implicit_platform_build_args(
|
||||
build_args: &mut HashMap<String, String>,
|
||||
target_platform: &str,
|
||||
build_platform: &str,
|
||||
) -> Result<()> {
|
||||
let (target_os, target_arch, target_variant) = parse_platform(target_platform)
|
||||
.wrap_err_with(|| format!("invalid target platform '{target_platform}'"))?;
|
||||
let (build_os, build_arch, build_variant) = parse_platform(build_platform)
|
||||
.wrap_err_with(|| format!("invalid build platform '{build_platform}'"))?;
|
||||
|
||||
for (key, val) in [
|
||||
("TARGETPLATFORM", target_platform),
|
||||
("TARGETOS", target_os),
|
||||
("TARGETARCH", target_arch),
|
||||
("BUILDPLATFORM", build_platform),
|
||||
("BUILDOS", build_os),
|
||||
("BUILDARCH", build_arch),
|
||||
] {
|
||||
build_args
|
||||
.entry(key.to_string())
|
||||
.or_insert_with(|| val.to_string());
|
||||
}
|
||||
if !target_variant.is_empty() {
|
||||
build_args
|
||||
.entry("TARGETVARIANT".to_string())
|
||||
.or_insert_with(|| target_variant.to_string());
|
||||
}
|
||||
if !build_variant.is_empty() {
|
||||
build_args
|
||||
.entry("BUILDVARIANT".to_string())
|
||||
.or_insert_with(|| build_variant.to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a container image from a Dockerfile using the local Docker daemon.
|
||||
///
|
||||
/// This is used by `openshell sandbox create --from <Dockerfile>`. The image
|
||||
/// remains available in the local Docker daemon so the gateway's active local
|
||||
/// compute driver can resolve the tag.
|
||||
#[allow(clippy::implicit_hasher)]
|
||||
pub async fn build_local_image(
|
||||
dockerfile_path: &Path,
|
||||
tag: &str,
|
||||
context_dir: &Path,
|
||||
build_args: &HashMap<String, String>,
|
||||
on_log: &mut impl FnMut(String),
|
||||
) -> Result<()> {
|
||||
on_log(format!(
|
||||
"Building image {tag} from {}",
|
||||
dockerfile_path.display()
|
||||
));
|
||||
build_image(dockerfile_path, tag, context_dir, build_args, on_log).await?;
|
||||
on_log(format!("Built image {tag}"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a container image using the local Docker daemon.
|
||||
///
|
||||
/// Creates a tar archive of `context_dir`, sends it to Docker with the
|
||||
/// specified Dockerfile path and tag, and streams build output to `on_log`.
|
||||
async fn build_image(
|
||||
dockerfile_path: &Path,
|
||||
tag: &str,
|
||||
context_dir: &Path,
|
||||
build_args: &HashMap<String, String>,
|
||||
on_log: &mut impl FnMut(String),
|
||||
) -> Result<()> {
|
||||
let docker = Docker::connect_with_local_defaults()
|
||||
.into_diagnostic()
|
||||
.wrap_err("failed to connect to local Docker daemon")?;
|
||||
// BUILD* always comes from daemon info. A TARGETPLATFORM override only
|
||||
// changes the output image platform.
|
||||
let build_platform = docker_daemon_platform(&docker).await?;
|
||||
let target_platform = build_args
|
||||
.get("TARGETPLATFORM")
|
||||
.map_or_else(|| build_platform.clone(), Clone::clone);
|
||||
let mut effective_build_args = build_args.clone();
|
||||
insert_implicit_platform_build_args(
|
||||
&mut effective_build_args,
|
||||
&target_platform,
|
||||
&build_platform,
|
||||
)?;
|
||||
|
||||
// Compute the relative path of the Dockerfile within the context.
|
||||
let dockerfile_relative = dockerfile_path
|
||||
.strip_prefix(context_dir)
|
||||
.unwrap_or(dockerfile_path);
|
||||
let dockerfile_str = dockerfile_relative
|
||||
.to_str()
|
||||
.ok_or_else(|| miette::miette!("Dockerfile path is not valid UTF-8"))?;
|
||||
|
||||
// Create a tar archive of the build context, respecting .dockerignore.
|
||||
let context_tar = create_build_context_tar(context_dir)?;
|
||||
|
||||
let mut builder = BuildImageOptionsBuilder::default()
|
||||
.dockerfile(dockerfile_str)
|
||||
.t(tag)
|
||||
.rm(true)
|
||||
.platform(&target_platform);
|
||||
|
||||
// Pass build args to Docker.
|
||||
if !effective_build_args.is_empty() {
|
||||
builder = builder.buildargs(&effective_build_args);
|
||||
}
|
||||
|
||||
let options = builder.build();
|
||||
|
||||
let body = bollard::body_full(bytes::Bytes::from(context_tar));
|
||||
let mut stream = docker.build_image(options, None, Some(body));
|
||||
let no_progress_secs: u64 = std::env::var("OPENSHELL_BUILD_NO_PROGRESS_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.filter(|&n| n > 0)
|
||||
.unwrap_or(DEFAULT_BUILD_NO_PROGRESS_TIMEOUT_SECS);
|
||||
let no_progress_timeout = Duration::from_secs(no_progress_secs);
|
||||
|
||||
loop {
|
||||
let next = match timeout(no_progress_timeout, stream.next()).await {
|
||||
Ok(Some(result)) => result,
|
||||
Ok(None) => break,
|
||||
Err(_) => {
|
||||
return Err(miette::miette!(
|
||||
"Docker build produced no output for {}s. This usually means the container \
|
||||
runtime is under-provisioned (CPU/memory) and the builder has deadlocked; \
|
||||
check `docker info` (NCPU, MemTotal) and increase Colima/Docker Desktop \
|
||||
resources before retrying. If a legitimate build step is just quiet, raise \
|
||||
the threshold with OPENSHELL_BUILD_NO_PROGRESS_TIMEOUT_SECS=<secs>.",
|
||||
no_progress_timeout.as_secs()
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let info = next
|
||||
.into_diagnostic()
|
||||
.wrap_err("Docker build stream error")?;
|
||||
|
||||
// Forward build output lines.
|
||||
if let Some(stream_line) = &info.stream {
|
||||
let trimmed = stream_line.trim_end();
|
||||
if !trimmed.is_empty() {
|
||||
on_log(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Check for build errors.
|
||||
if let Some(error_detail) = &info.error_detail {
|
||||
let msg = error_detail
|
||||
.message
|
||||
.as_deref()
|
||||
.unwrap_or("unknown build error");
|
||||
return Err(miette::miette!("Docker build failed: {msg}"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a tar archive of a directory for use as a Docker build context.
|
||||
///
|
||||
/// Walks `context_dir` recursively, respects a `.dockerignore` file if present,
|
||||
/// and adds matching files with paths relative to the context root.
|
||||
fn create_build_context_tar(context_dir: &Path) -> Result<Vec<u8>> {
|
||||
let ignore_patterns = load_dockerignore(context_dir);
|
||||
|
||||
let mut builder = tar::Builder::new(Vec::new());
|
||||
|
||||
// Walk the directory tree and add entries, skipping ignored paths.
|
||||
walk_and_add(context_dir, context_dir, &ignore_patterns, &mut builder)?;
|
||||
|
||||
builder
|
||||
.into_inner()
|
||||
.into_diagnostic()
|
||||
.wrap_err("failed to finalize build context tar")
|
||||
}
|
||||
|
||||
/// Recursively walk a directory and add entries to a tar archive,
|
||||
/// skipping paths that match `.dockerignore` patterns.
|
||||
fn walk_and_add(
|
||||
root: &Path,
|
||||
current: &Path,
|
||||
ignore_patterns: &[IgnorePattern],
|
||||
builder: &mut tar::Builder<Vec<u8>>,
|
||||
) -> Result<()> {
|
||||
let entries = std::fs::read_dir(current)
|
||||
.into_diagnostic()
|
||||
.wrap_err_with(|| format!("failed to read directory: {}", current.display()))?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry
|
||||
.into_diagnostic()
|
||||
.wrap_err("failed to read directory entry")?;
|
||||
let path = entry.path();
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
// Normalize to forward slashes for pattern matching.
|
||||
let relative_normalized = relative.replace('\\', "/");
|
||||
|
||||
if is_ignored(&relative_normalized, path.is_dir(), ignore_patterns) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if path.is_dir() {
|
||||
walk_and_add(root, &path, ignore_patterns, builder)?;
|
||||
} else {
|
||||
// Use append_path_with_name which handles GNU LongName extensions
|
||||
// for paths exceeding 100 bytes (the POSIX tar name field limit).
|
||||
builder
|
||||
.append_path_with_name(&path, &relative_normalized)
|
||||
.into_diagnostic()
|
||||
.wrap_err_with(|| format!("failed to add file to tar: {relative_normalized}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A parsed `.dockerignore` pattern.
|
||||
#[derive(Debug, Clone)]
|
||||
struct IgnorePattern {
|
||||
/// The glob pattern (may contain `*`, `**`, `?`).
|
||||
pattern: String,
|
||||
/// Whether this is a negation pattern (starts with `!`).
|
||||
negated: bool,
|
||||
}
|
||||
|
||||
/// Load and parse a `.dockerignore` file from the context directory.
|
||||
///
|
||||
/// Returns an empty list if no `.dockerignore` exists.
|
||||
fn load_dockerignore(context_dir: &Path) -> Vec<IgnorePattern> {
|
||||
let dockerignore_path = context_dir.join(".dockerignore");
|
||||
let Ok(contents) = std::fs::read_to_string(&dockerignore_path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
contents
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.map(|line| {
|
||||
line.strip_prefix('!').map_or_else(
|
||||
|| IgnorePattern {
|
||||
pattern: line.to_string(),
|
||||
negated: false,
|
||||
},
|
||||
|rest| IgnorePattern {
|
||||
pattern: rest.trim().to_string(),
|
||||
negated: true,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check whether a relative path should be ignored based on `.dockerignore` patterns.
|
||||
///
|
||||
/// Uses a simple glob-matching approach: patterns are matched against the
|
||||
/// full relative path. A leading `/` anchors to the context root. The last
|
||||
/// matching pattern wins (negation patterns re-include files).
|
||||
fn is_ignored(relative_path: &str, is_dir: bool, patterns: &[IgnorePattern]) -> bool {
|
||||
let mut ignored = false;
|
||||
|
||||
for pat in patterns {
|
||||
let pattern = pat.pattern.trim_start_matches('/');
|
||||
|
||||
// Check if the pattern matches.
|
||||
let matches = glob_match(pattern, relative_path, is_dir);
|
||||
|
||||
if matches {
|
||||
ignored = !pat.negated;
|
||||
}
|
||||
}
|
||||
|
||||
ignored
|
||||
}
|
||||
|
||||
/// Simple glob matching supporting `*`, `**`, and `?`.
|
||||
///
|
||||
/// This is intentionally simple — it covers the common `.dockerignore` cases
|
||||
/// without pulling in a full glob crate. For complex patterns, Docker's own
|
||||
/// builder handles them during the build step anyway; this is just for
|
||||
/// reducing the context tar size.
|
||||
fn glob_match(pattern: &str, path: &str, is_dir: bool) -> bool {
|
||||
// Handle ** prefix (match any number of directories)
|
||||
if let Some(rest) = pattern.strip_prefix("**/") {
|
||||
// Match against the path itself and any suffix after a /
|
||||
if glob_match(rest, path, is_dir) {
|
||||
return true;
|
||||
}
|
||||
for (idx, _) in path.match_indices('/') {
|
||||
if let Some(suffix) = path.get(idx + 1..)
|
||||
&& glob_match(rest, suffix, is_dir)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle pattern that is just a name (no slashes) — match against any
|
||||
// path component or as a prefix directory match.
|
||||
if !pattern.contains('/') {
|
||||
// Match the final component of the path.
|
||||
let basename = path.rsplit('/').next().unwrap_or(path);
|
||||
if simple_glob_match(pattern, basename) {
|
||||
return true;
|
||||
}
|
||||
// Also match as a directory prefix: pattern "node_modules" should
|
||||
// match "node_modules/foo/bar".
|
||||
if let Some(first) = path.split('/').next()
|
||||
&& simple_glob_match(pattern, first)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Pattern contains slashes — match against the full path.
|
||||
simple_glob_match(pattern, path) || (is_dir && path.starts_with(pattern.trim_end_matches('/')))
|
||||
}
|
||||
|
||||
/// Match a simple glob pattern (with `*` and `?` but not `**`) against a string.
|
||||
fn simple_glob_match(pattern: &str, text: &str) -> bool {
|
||||
let mut p_star: Option<usize> = None;
|
||||
let mut t_star: Option<usize> = None;
|
||||
|
||||
let p_bytes: Vec<char> = pattern.chars().collect();
|
||||
let t_bytes: Vec<char> = text.chars().collect();
|
||||
let mut pi = 0;
|
||||
let mut ti = 0;
|
||||
|
||||
while ti < t_bytes.len() {
|
||||
if pi < p_bytes.len() && (p_bytes[pi] == '?' || p_bytes[pi] == t_bytes[ti]) {
|
||||
pi += 1;
|
||||
ti += 1;
|
||||
} else if pi < p_bytes.len() && p_bytes[pi] == '*' {
|
||||
p_star = Some(pi);
|
||||
t_star = Some(ti);
|
||||
pi += 1;
|
||||
} else if let Some(ps) = p_star {
|
||||
pi = ps + 1;
|
||||
t_star = Some(t_star.unwrap() + 1);
|
||||
ti = t_star.unwrap();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
while pi < p_bytes.len() && p_bytes[pi] == '*' {
|
||||
pi += 1;
|
||||
}
|
||||
|
||||
pi == p_bytes.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn test_create_build_context_tar() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir_path = dir.path();
|
||||
|
||||
// Create a simple Dockerfile and a file.
|
||||
fs::write(dir_path.join("Dockerfile"), "FROM ubuntu:24.04\n").unwrap();
|
||||
fs::write(dir_path.join("hello.txt"), "hello world\n").unwrap();
|
||||
fs::create_dir(dir_path.join("subdir")).unwrap();
|
||||
fs::write(dir_path.join("subdir/nested.txt"), "nested\n").unwrap();
|
||||
|
||||
let tar_bytes = create_build_context_tar(dir_path).unwrap();
|
||||
assert!(!tar_bytes.is_empty());
|
||||
|
||||
// Verify the tar contains the expected entries.
|
||||
let mut archive = tar::Archive::new(tar_bytes.as_slice());
|
||||
let entries: Vec<String> = archive
|
||||
.entries()
|
||||
.unwrap()
|
||||
.filter_map(std::result::Result::ok)
|
||||
.map(|e| e.path().unwrap().to_string_lossy().to_string())
|
||||
.collect();
|
||||
|
||||
assert!(entries.iter().any(|e| e.contains("Dockerfile")));
|
||||
assert!(entries.iter().any(|e| e.contains("hello.txt")));
|
||||
assert!(entries.iter().any(|e| e.contains("subdir/nested.txt")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dockerignore_excludes_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir_path = dir.path();
|
||||
|
||||
fs::write(dir_path.join("Dockerfile"), "FROM ubuntu:24.04\n").unwrap();
|
||||
fs::write(dir_path.join("hello.txt"), "hello\n").unwrap();
|
||||
fs::write(dir_path.join("secret.env"), "SECRET=foo\n").unwrap();
|
||||
fs::create_dir(dir_path.join("node_modules")).unwrap();
|
||||
fs::write(dir_path.join("node_modules/pkg.js"), "module\n").unwrap();
|
||||
fs::write(dir_path.join(".dockerignore"), "*.env\nnode_modules\n").unwrap();
|
||||
|
||||
let tar_bytes = create_build_context_tar(dir_path).unwrap();
|
||||
let mut archive = tar::Archive::new(tar_bytes.as_slice());
|
||||
let entries: Vec<String> = archive
|
||||
.entries()
|
||||
.unwrap()
|
||||
.filter_map(std::result::Result::ok)
|
||||
.map(|e| e.path().unwrap().to_string_lossy().to_string())
|
||||
.collect();
|
||||
|
||||
assert!(entries.iter().any(|e| e.contains("Dockerfile")));
|
||||
assert!(entries.iter().any(|e| e.contains("hello.txt")));
|
||||
assert!(!entries.iter().any(|e| e.contains("secret.env")));
|
||||
assert!(!entries.iter().any(|e| e.contains("node_modules")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dockerignore_negation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir_path = dir.path();
|
||||
|
||||
fs::write(dir_path.join("Dockerfile"), "FROM ubuntu:24.04\n").unwrap();
|
||||
fs::write(dir_path.join("a.log"), "log\n").unwrap();
|
||||
fs::write(dir_path.join("important.log"), "keep\n").unwrap();
|
||||
fs::write(dir_path.join(".dockerignore"), "*.log\n!important.log\n").unwrap();
|
||||
|
||||
let tar_bytes = create_build_context_tar(dir_path).unwrap();
|
||||
let mut archive = tar::Archive::new(tar_bytes.as_slice());
|
||||
let entries: Vec<String> = archive
|
||||
.entries()
|
||||
.unwrap()
|
||||
.filter_map(std::result::Result::ok)
|
||||
.map(|e| e.path().unwrap().to_string_lossy().to_string())
|
||||
.collect();
|
||||
|
||||
assert!(!entries.iter().any(|e| e.contains("a.log")));
|
||||
assert!(entries.iter().any(|e| e.contains("important.log")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_long_path_exceeding_100_bytes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir_path = dir.path();
|
||||
|
||||
// Build a nested path that exceeds 100 bytes when relative to root.
|
||||
let deep_dir = dir_path.join(
|
||||
"a/deeply/nested/directory/path/that/exceeds/one/hundred/bytes/total/from/the/build/context/root",
|
||||
);
|
||||
fs::create_dir_all(&deep_dir).unwrap();
|
||||
fs::write(deep_dir.join("file.txt"), "deep content\n").unwrap();
|
||||
fs::write(dir_path.join("Dockerfile"), "FROM ubuntu:24.04\n").unwrap();
|
||||
|
||||
let tar_bytes = create_build_context_tar(dir_path).unwrap();
|
||||
let mut archive = tar::Archive::new(tar_bytes.as_slice());
|
||||
let entries: Vec<String> = archive
|
||||
.entries()
|
||||
.unwrap()
|
||||
.filter_map(std::result::Result::ok)
|
||||
.map(|e| e.path().unwrap().to_string_lossy().to_string())
|
||||
.collect();
|
||||
|
||||
let long_entry = entries.iter().find(|e| e.contains("file.txt"));
|
||||
assert!(
|
||||
long_entry.is_some(),
|
||||
"tar should contain deeply nested file; entries: {entries:?}"
|
||||
);
|
||||
assert!(
|
||||
long_entry.unwrap().len() > 100,
|
||||
"path should exceed 100 bytes to exercise GNU LongName handling"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simple_glob_match() {
|
||||
assert!(simple_glob_match("*.txt", "hello.txt"));
|
||||
assert!(!simple_glob_match("*.txt", "hello.rs"));
|
||||
assert!(simple_glob_match("test?", "test1"));
|
||||
assert!(!simple_glob_match("test?", "test12"));
|
||||
assert!(simple_glob_match("*", "anything"));
|
||||
assert!(simple_glob_match("foo*bar", "fooXYZbar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_glob_match_double_star() {
|
||||
assert!(glob_match("**/*.log", "a/b/c.log", false));
|
||||
assert!(glob_match("**/*.log", "c.log", false));
|
||||
assert!(!glob_match("**/*.log", "c.txt", false));
|
||||
assert!(glob_match("**/föö.log", "a/b/föö.log", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_ignored_directory_prefix() {
|
||||
let patterns = vec![IgnorePattern {
|
||||
pattern: "node_modules".to_string(),
|
||||
negated: false,
|
||||
}];
|
||||
assert!(is_ignored("node_modules", true, &patterns));
|
||||
assert!(is_ignored("node_modules/foo.js", false, &patterns));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_docker_platform_maps_docker_desktop_arch() {
|
||||
assert_eq!(
|
||||
normalize_docker_platform("linux", "aarch64").unwrap(),
|
||||
"linux/arm64"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_docker_platform("linux", "x86_64").unwrap(),
|
||||
"linux/amd64"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_docker_platform_case_insensitive_arch() {
|
||||
// Some remote or non-standard Docker daemons return mixed-case arch strings.
|
||||
assert_eq!(
|
||||
normalize_docker_platform("linux", "ARM64").unwrap(),
|
||||
"linux/arm64"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_docker_platform("linux", "X86_64").unwrap(),
|
||||
"linux/amd64"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_implicit_platform_build_args_native() {
|
||||
// Native builds use one platform for both target and build args.
|
||||
let mut build_args = HashMap::new();
|
||||
insert_implicit_platform_build_args(&mut build_args, "linux/arm/v7", "linux/arm/v7")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(build_args.get("TARGETPLATFORM").unwrap(), "linux/arm/v7");
|
||||
assert_eq!(build_args.get("TARGETOS").unwrap(), "linux");
|
||||
assert_eq!(build_args.get("TARGETARCH").unwrap(), "arm");
|
||||
assert_eq!(build_args.get("TARGETVARIANT").unwrap(), "v7");
|
||||
assert_eq!(build_args.get("BUILDPLATFORM").unwrap(), "linux/arm/v7");
|
||||
assert_eq!(build_args.get("BUILDOS").unwrap(), "linux");
|
||||
assert_eq!(build_args.get("BUILDARCH").unwrap(), "arm");
|
||||
assert_eq!(build_args.get("BUILDVARIANT").unwrap(), "v7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_implicit_platform_build_args_cross_compile() {
|
||||
// Cross-builds keep daemon BUILD* args separate from target args.
|
||||
let mut build_args = HashMap::new();
|
||||
insert_implicit_platform_build_args(&mut build_args, "linux/arm64", "linux/amd64").unwrap();
|
||||
|
||||
assert_eq!(build_args.get("TARGETPLATFORM").unwrap(), "linux/arm64");
|
||||
assert_eq!(build_args.get("TARGETARCH").unwrap(), "arm64");
|
||||
assert_eq!(build_args.get("BUILDPLATFORM").unwrap(), "linux/amd64");
|
||||
assert_eq!(build_args.get("BUILDARCH").unwrap(), "amd64");
|
||||
// Leaving variant args absent preserves Dockerfile ARG defaults.
|
||||
assert!(!build_args.contains_key("TARGETVARIANT"));
|
||||
assert!(!build_args.contains_key("BUILDVARIANT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_implicit_platform_build_args_trailing_slash_rejected() {
|
||||
let mut build_args = HashMap::new();
|
||||
let result =
|
||||
insert_implicit_platform_build_args(&mut build_args, "linux/arm64/", "linux/amd64");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"trailing slash should be rejected as a malformed variant segment"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Windows stub for local Dockerfile image builds.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use miette::Result;
|
||||
|
||||
// Keep this stub's signature aligned with the supported-platform implementation.
|
||||
#[allow(clippy::implicit_hasher)]
|
||||
pub async fn build_local_image(
|
||||
_dockerfile_path: &Path,
|
||||
_tag: &str,
|
||||
_context_dir: &Path,
|
||||
_build_args: &HashMap<String, String>,
|
||||
_on_log: &mut impl FnMut(String),
|
||||
) -> Result<()> {
|
||||
Err(miette::miette!(
|
||||
"local Dockerfile sandbox sources are unsupported on Windows"
|
||||
))
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub mod build;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "build_windows.rs"]
|
||||
pub mod build;
|
||||
pub mod edge_token;
|
||||
pub mod jwt;
|
||||
pub mod oidc_token;
|
||||
|
||||
@@ -124,6 +124,21 @@ pub fn is_token_expired(bundle: &OidcTokenBundle) -> bool {
|
||||
now + 30 >= expires_at
|
||||
}
|
||||
|
||||
/// Check whether the stored access token has actually expired.
|
||||
///
|
||||
/// Unlike [`is_token_expired`], this does not include the 30-second refresh
|
||||
/// window. Tokens without expiry metadata are treated as valid.
|
||||
pub fn is_token_actually_expired(bundle: &OidcTokenBundle) -> bool {
|
||||
let Some(expires_at) = bundle.expires_at else {
|
||||
return false;
|
||||
};
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
now >= expires_at
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -163,6 +178,30 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_checks_distinguish_refresh_window_from_actual_expiry() {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let bundle = OidcTokenBundle {
|
||||
access_token: "token".to_string(),
|
||||
refresh_token: None,
|
||||
expires_at: Some(now + 10),
|
||||
issuer: "https://issuer.example.com".to_string(),
|
||||
client_id: "openshell-cli".to_string(),
|
||||
};
|
||||
|
||||
assert!(is_token_expired(&bundle));
|
||||
assert!(!is_token_actually_expired(&bundle));
|
||||
|
||||
let expired = OidcTokenBundle {
|
||||
expires_at: Some(now.saturating_sub(1)),
|
||||
..bundle
|
||||
};
|
||||
assert!(is_token_actually_expired(&expired));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oidc_login_prompt_marker_is_per_gateway() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -37,6 +37,9 @@ chrono = "0.4"
|
||||
clap = { workspace = true }
|
||||
clap_complete = { workspace = true }
|
||||
crossterm = { workspace = true }
|
||||
# Styling backend shared by dialoguer and indicatif; `color` overrides its
|
||||
# global switch so both follow the CLI's --color setting.
|
||||
console = "0.15"
|
||||
dialoguer = "0.11"
|
||||
indicatif = { workspace = true }
|
||||
owo-colors = { workspace = true }
|
||||
@@ -46,7 +49,7 @@ bytes = { workspace = true }
|
||||
http-body-util = { workspace = true }
|
||||
hyper = { workspace = true }
|
||||
hyper-util = { workspace = true }
|
||||
hyper-rustls = { version = "0.27", default-features = false, features = ["native-tokio", "http1", "http2", "tls12", "logging", "ring"] }
|
||||
hyper-rustls = { version = "0.27", default-features = false, features = ["native-tokio", "http1", "http2", "tls12", "logging", "aws-lc-rs"] }
|
||||
rustls = { workspace = true }
|
||||
rustls-pemfile = { workspace = true }
|
||||
tokio-rustls = { workspace = true }
|
||||
@@ -88,7 +91,7 @@ nix = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
futures = { workspace = true }
|
||||
rcgen = { version = "0.13", features = ["crypto", "pem"] }
|
||||
rcgen = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
temp-env = "0.3"
|
||||
|
||||
@@ -35,7 +35,7 @@ use tokio::sync::oneshot;
|
||||
use tracing::debug;
|
||||
|
||||
/// Timeout for the browser auth flow.
|
||||
const AUTH_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
const AUTH_TIMEOUT: Duration = Duration::from_mins(2);
|
||||
|
||||
/// Length of the confirmation code (alphanumeric characters).
|
||||
const CODE_LENGTH: usize = 7;
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Runtime color control for CLI output.
|
||||
//!
|
||||
//! The CLI styles its human-readable tables and status lines with ANSI escape
|
||||
//! sequences. Those sequences must not appear when the output is being consumed
|
||||
//! by another program, or callers end up writing brittle patterns against bytes
|
||||
//! they cannot see.
|
||||
//!
|
||||
//! `owo_colors::OwoColorize` emits escapes unconditionally, so this module wraps
|
||||
//! it with a process-wide switch resolved once at startup by [`init`]. Command
|
||||
//! modules import [`Colorize`] instead of `OwoColorize`; the method names match,
|
||||
//! so call sites are unchanged, but each one now checks the switch when it
|
||||
//! renders.
|
||||
//!
|
||||
//! Styling is not confined to `owo-colors`, and the other paths each carry
|
||||
//! their own default, so [`init`] brings them under the same setting:
|
||||
//!
|
||||
//! - `tracing_subscriber` formats with ANSI on, does no terminal detection, and
|
||||
//! writes to stdout. Left alone, `openshell -v ... | ...` leaks escapes into a
|
||||
//! pipe exactly like the styled tables did. `main` passes [`stdout_enabled`]
|
||||
//! to `with_ansi`.
|
||||
//! - `indicatif` and `dialoguer` both style through `console`, which has its own
|
||||
//! detection and honors `NO_COLOR` but cannot know about `--color`. [`init`]
|
||||
//! overrides it globally, which covers every progress bar and prompt rather
|
||||
//! than the specific ones the CLI happens to construct today.
|
||||
//! - `miette` renders errors to stderr with its own detection, likewise unaware
|
||||
//! of `--color`. [`init`] installs a report handler built from
|
||||
//! [`stderr_enabled`].
|
||||
//!
|
||||
//! Resolution order, highest precedence first:
|
||||
//!
|
||||
//! 1. `--color always|never` on the command line.
|
||||
//! 2. `NO_COLOR`, set and non-empty, disables color (<https://no-color.org>).
|
||||
//! 3. `FORCE_COLOR`, set and non-empty, forces color on (<https://force-color.org>).
|
||||
//! 4. Otherwise the stream is styled only when that stream is a terminal *and*
|
||||
//! that terminal renders ANSI.
|
||||
//!
|
||||
//! Attachment and capability are separate questions. `TERM=dumb` is a terminal
|
||||
//! that does not interpret escapes, so `auto` must not style it — and neither
|
||||
//! `console` nor `miette` can apply their own `TERM` checks any more, because
|
||||
//! [`init`] overrides both. Capability is consulted only under `auto`, so
|
||||
//! `--color always` and `FORCE_COLOR` still force styling on a `dumb` terminal
|
||||
//! for anyone who wants it.
|
||||
//!
|
||||
//! Step 4 is resolved per stream. Redirecting one must not decide for the other:
|
||||
//! `openshell ... 2> build.log` from a terminal should keep a styled stdout and
|
||||
//! write a plain-text log, and `openshell ... | grep` should keep styled
|
||||
//! diagnostics on the terminal while feeding the pipe clean bytes. [`init`]
|
||||
//! therefore stores one answer per stream and hands each library the one for the
|
||||
//! stream it writes to.
|
||||
//!
|
||||
//! The `owo-colors` wrapper is the exception, because its call sites are split
|
||||
//! across `println!` and `eprintln!` and a [`Painted`] value cannot tell which
|
||||
//! macro will consume it. It styles only when both streams accept escapes, which
|
||||
//! errs toward plain text rather than risk writing escapes to a redirected
|
||||
//! stream.
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::fmt::{self, Display};
|
||||
use std::io::IsTerminal;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use owo_colors::{OwoColorize, Style};
|
||||
|
||||
/// Whether stdout may carry escapes. Consulted for `tracing` and console's
|
||||
/// stdout switch.
|
||||
///
|
||||
/// Defaults to disabled so that any output produced before [`init`] runs — and
|
||||
/// output from unit tests, which never call `init` — stays free of escapes.
|
||||
static STDOUT_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Whether stderr may carry escapes. Consulted for `miette` and console's stderr
|
||||
/// switch.
|
||||
static STDERR_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// When to colorize CLI output.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
|
||||
pub enum ColorChoice {
|
||||
/// Colorize a stream only when that stream is a terminal and no environment
|
||||
/// override applies.
|
||||
#[default]
|
||||
Auto,
|
||||
/// Always colorize, even when output is redirected.
|
||||
Always,
|
||||
/// Never colorize.
|
||||
Never,
|
||||
}
|
||||
|
||||
/// Resolve the color setting and store it for the rest of the process.
|
||||
///
|
||||
/// Call once, as early as possible after argument parsing and before any output
|
||||
/// is written.
|
||||
pub fn init(choice: ColorChoice) {
|
||||
let no_color = std::env::var_os("NO_COLOR");
|
||||
let force_color = std::env::var_os("FORCE_COLOR");
|
||||
let term = std::env::var_os("TERM");
|
||||
|
||||
// Under `auto` each stream answers for itself. Redirecting one must not
|
||||
// decide for the other: `openshell ... 2> build.log` from a terminal has a
|
||||
// styled stdout and a plain-text stderr, and vice versa for `| grep`.
|
||||
let stdout_enabled = resolve(
|
||||
choice,
|
||||
no_color.as_deref(),
|
||||
force_color.as_deref(),
|
||||
terminal_supports_ansi(std::io::stdout().is_terminal(), term.as_deref()),
|
||||
);
|
||||
let stderr_enabled = resolve(
|
||||
choice,
|
||||
no_color.as_deref(),
|
||||
force_color.as_deref(),
|
||||
terminal_supports_ansi(std::io::stderr().is_terminal(), term.as_deref()),
|
||||
);
|
||||
STDOUT_ENABLED.store(stdout_enabled, Ordering::Relaxed);
|
||||
STDERR_ENABLED.store(stderr_enabled, Ordering::Relaxed);
|
||||
|
||||
// `indicatif` and `dialoguer` both style through `console`, which keeps its
|
||||
// own detection. Override it so progress bars and prompts follow the same
|
||||
// setting as everything else — including `--color`, which console has no way
|
||||
// to learn about on its own.
|
||||
console::set_colors_enabled(stdout_enabled);
|
||||
console::set_colors_enabled_stderr(stderr_enabled);
|
||||
|
||||
// miette renders errors to stderr. The hook can only be installed once per
|
||||
// process; a failure means something already installed one, and error
|
||||
// rendering is not worth aborting the command over.
|
||||
let _ = miette::set_hook(Box::new(move |_| {
|
||||
Box::new(
|
||||
miette::MietteHandlerOpts::new()
|
||||
.color(stderr_enabled)
|
||||
.build(),
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
/// Whether stdout may carry ANSI escapes.
|
||||
///
|
||||
/// [`init`] configures `console` and `miette` directly; `tracing` is wired up by
|
||||
/// the caller, which writes to stdout and passes this to `with_ansi`.
|
||||
#[must_use]
|
||||
pub fn stdout_enabled() -> bool {
|
||||
STDOUT_ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Whether stderr may carry ANSI escapes.
|
||||
#[must_use]
|
||||
pub fn stderr_enabled() -> bool {
|
||||
STDERR_ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Whether a [`Painted`] value should render styled.
|
||||
///
|
||||
/// The `owo-colors` call sites are split across `println!` and `eprintln!` and
|
||||
/// the wrapper cannot tell which one will consume it, so it styles only when
|
||||
/// *both* streams accept escapes. Erring toward plain text keeps a redirected
|
||||
/// stream clean, which is the whole point of the switch; the cost is that
|
||||
/// `openshell ... 2>/dev/null` from a terminal prints an uncolored table.
|
||||
/// `--color always` overrides it.
|
||||
#[must_use]
|
||||
fn painted_enabled() -> bool {
|
||||
stdout_enabled() && stderr_enabled()
|
||||
}
|
||||
|
||||
/// Decide whether one stream may carry escapes. Split out from [`init`] so the
|
||||
/// precedence rules are testable without mutating process state.
|
||||
fn resolve(
|
||||
choice: ColorChoice,
|
||||
no_color: Option<&OsStr>,
|
||||
force_color: Option<&OsStr>,
|
||||
stream_supports_ansi: bool,
|
||||
) -> bool {
|
||||
match choice {
|
||||
ColorChoice::Always => return true,
|
||||
ColorChoice::Never => return false,
|
||||
ColorChoice::Auto => {}
|
||||
}
|
||||
|
||||
// Both conventions key on presence rather than value: set and non-empty
|
||||
// means yes, regardless of what the value is. <https://no-color.org> and
|
||||
// <https://force-color.org>.
|
||||
if is_set(no_color) {
|
||||
return false;
|
||||
}
|
||||
if is_set(force_color) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only `auto` consults the terminal. An explicit request above has already
|
||||
// returned, so `--color always` and `FORCE_COLOR` still win on a terminal
|
||||
// that reports no ANSI support.
|
||||
stream_supports_ansi
|
||||
}
|
||||
|
||||
/// Whether this output stream's terminal renders ANSI escapes.
|
||||
///
|
||||
/// Being attached to a terminal is not the same as that terminal rendering
|
||||
/// ANSI. `TERM` is the unix signal for it; Windows consoles enable virtual
|
||||
/// terminal processing instead and do not set `TERM`, so the check does not
|
||||
/// apply there.
|
||||
fn terminal_supports_ansi(stream_is_terminal: bool, term: Option<&OsStr>) -> bool {
|
||||
stream_is_terminal
|
||||
&& if cfg!(unix) {
|
||||
term_supports_ansi(term)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the terminal named by `TERM` renders ANSI escapes on Unix.
|
||||
///
|
||||
/// Follows the rule `console` applies on unix, which this module overrides:
|
||||
/// `dumb` means no, and an unset `TERM` means no because nothing identifies a
|
||||
/// capable terminal.
|
||||
///
|
||||
/// Empty is treated as unset, which is a deliberate divergence: `console` reads
|
||||
/// `TERM=""` as `Ok("")`, and since that is not `"dumb"` it counts as capable.
|
||||
/// An empty value names no terminal type, and every other variable here already
|
||||
/// treats empty as unset, so it is handled the same way.
|
||||
fn term_supports_ansi(term: Option<&OsStr>) -> bool {
|
||||
is_set(term) && term != Some(OsStr::new("dumb"))
|
||||
}
|
||||
|
||||
/// Whether an environment variable counts as set: present and not empty.
|
||||
fn is_set(value: Option<&OsStr>) -> bool {
|
||||
value.is_some_and(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
/// A value tagged with a style, rendered only when color is enabled.
|
||||
///
|
||||
/// Borrows its value and forwards the caller's format specification to the
|
||||
/// inner `Display`, so width and alignment apply to the text rather than to the
|
||||
/// text plus escapes.
|
||||
pub struct Painted<'a, T: ?Sized> {
|
||||
value: &'a T,
|
||||
style: Style,
|
||||
}
|
||||
|
||||
impl<T: Display + ?Sized> Display for Painted<'_, T> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
if !painted_enabled() {
|
||||
return Display::fmt(&self.value, f);
|
||||
}
|
||||
// Hand the whole style to owo-colors in one go. `Styled` writes the
|
||||
// prefix, forwards `f` to the inner `Display` so padding still measures
|
||||
// the text, then writes the reset.
|
||||
Display::fmt(&OwoColorize::style(&self.value, self.style), f)
|
||||
}
|
||||
}
|
||||
|
||||
/// Style-combining methods, so `x.green().bold()` renders as one escape
|
||||
/// sequence rather than nesting two.
|
||||
///
|
||||
/// These are inherent so they take precedence over the [`Colorize`] blanket
|
||||
/// impl, which would otherwise wrap a `Painted` in another `Painted`.
|
||||
impl<T: ?Sized> Painted<'_, T> {
|
||||
/// Add bold to the current style.
|
||||
#[must_use]
|
||||
pub fn bold(self) -> Self {
|
||||
Self {
|
||||
style: self.style.bold(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
/// Add cyan to the current style.
|
||||
#[must_use]
|
||||
pub fn cyan(self) -> Self {
|
||||
Self {
|
||||
style: self.style.cyan(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
/// Add dimmed to the current style.
|
||||
#[must_use]
|
||||
pub fn dimmed(self) -> Self {
|
||||
Self {
|
||||
style: self.style.dimmed(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
/// Add green to the current style.
|
||||
#[must_use]
|
||||
pub fn green(self) -> Self {
|
||||
Self {
|
||||
style: self.style.green(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
/// Add red to the current style.
|
||||
#[must_use]
|
||||
pub fn red(self) -> Self {
|
||||
Self {
|
||||
style: self.style.red(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
/// Add yellow to the current style.
|
||||
#[must_use]
|
||||
pub fn yellow(self) -> Self {
|
||||
Self {
|
||||
style: self.style.yellow(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Styling methods mirroring the `owo_colors::OwoColorize` surface the CLI uses.
|
||||
///
|
||||
/// Import this instead of `OwoColorize` so styled output honors [`init`]. The
|
||||
/// two traits have colliding method names on purpose: importing both in one
|
||||
/// module is an ambiguity error, which keeps unconditional coloring from
|
||||
/// creeping back in.
|
||||
pub trait Colorize {
|
||||
/// Render in bold.
|
||||
fn bold(&self) -> Painted<'_, Self>;
|
||||
/// Render in cyan.
|
||||
fn cyan(&self) -> Painted<'_, Self>;
|
||||
/// Render dimmed.
|
||||
fn dimmed(&self) -> Painted<'_, Self>;
|
||||
/// Render in green.
|
||||
fn green(&self) -> Painted<'_, Self>;
|
||||
/// Render in red.
|
||||
fn red(&self) -> Painted<'_, Self>;
|
||||
/// Render in yellow.
|
||||
fn yellow(&self) -> Painted<'_, Self>;
|
||||
}
|
||||
|
||||
impl<T: ?Sized> Colorize for T {
|
||||
fn bold(&self) -> Painted<'_, Self> {
|
||||
Painted {
|
||||
value: self,
|
||||
style: Style::new().bold(),
|
||||
}
|
||||
}
|
||||
fn cyan(&self) -> Painted<'_, Self> {
|
||||
Painted {
|
||||
value: self,
|
||||
style: Style::new().cyan(),
|
||||
}
|
||||
}
|
||||
fn dimmed(&self) -> Painted<'_, Self> {
|
||||
Painted {
|
||||
value: self,
|
||||
style: Style::new().dimmed(),
|
||||
}
|
||||
}
|
||||
fn green(&self) -> Painted<'_, Self> {
|
||||
Painted {
|
||||
value: self,
|
||||
style: Style::new().green(),
|
||||
}
|
||||
}
|
||||
fn red(&self) -> Painted<'_, Self> {
|
||||
Painted {
|
||||
value: self,
|
||||
style: Style::new().red(),
|
||||
}
|
||||
}
|
||||
fn yellow(&self) -> Painted<'_, Self> {
|
||||
Painted {
|
||||
value: self,
|
||||
style: Style::new().yellow(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// Deliberately not `use super::*`: that would also pull in `OwoColorize`
|
||||
// and make every `.green()` below ambiguous.
|
||||
use super::{
|
||||
ColorChoice, Colorize, Ordering, STDERR_ENABLED, STDOUT_ENABLED, Style, painted_enabled,
|
||||
resolve, term_supports_ansi,
|
||||
};
|
||||
use std::ffi::OsStr;
|
||||
|
||||
/// Serializes tests that flip the process-wide switches.
|
||||
static SWITCH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Set both stream switches, since [`Painted`] requires both.
|
||||
fn with_color<R>(on: bool, body: impl FnOnce() -> R) -> R {
|
||||
with_streams(on, on, body)
|
||||
}
|
||||
|
||||
fn with_streams<R>(stdout: bool, stderr: bool, body: impl FnOnce() -> R) -> R {
|
||||
let _guard = SWITCH_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let prev_out = STDOUT_ENABLED.swap(stdout, Ordering::Relaxed);
|
||||
let prev_err = STDERR_ENABLED.swap(stderr, Ordering::Relaxed);
|
||||
let result = body();
|
||||
STDOUT_ENABLED.store(prev_out, Ordering::Relaxed);
|
||||
STDERR_ENABLED.store(prev_err, Ordering::Relaxed);
|
||||
result
|
||||
}
|
||||
|
||||
/// Helper so the `resolve` cases read as plain strings.
|
||||
fn env(value: &str) -> &OsStr {
|
||||
OsStr::new(value)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_output_has_no_escapes() {
|
||||
with_color(false, || {
|
||||
assert_eq!("running".green().to_string(), "running");
|
||||
assert_eq!("dead".red().to_string(), "dead");
|
||||
assert_eq!("STATUS".bold().to_string(), "STATUS");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_output_is_wrapped_in_the_expected_escapes() {
|
||||
with_color(true, || {
|
||||
// The styling the bug report observed from `forward list`.
|
||||
assert_eq!("running".green().to_string(), "\u{1b}[32mrunning\u{1b}[0m");
|
||||
// Delegation is to owo-colors, so the bytes match what it would
|
||||
// produce for the same style.
|
||||
assert_eq!(
|
||||
"running".green().to_string(),
|
||||
owo_colors::OwoColorize::style(&"running", Style::new().green()).to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
"x".dimmed().to_string(),
|
||||
owo_colors::OwoColorize::style(&"x", Style::new().dimmed()).to_string()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_width_applies_to_text_not_escapes() {
|
||||
// Padding must measure the value, so columns line up identically whether
|
||||
// or not color is on.
|
||||
let plain = with_color(false, || format!("[{:<10}]", "running".green()));
|
||||
let colored = with_color(true, || format!("[{:<10}]", "running".green()));
|
||||
|
||||
assert_eq!(plain, "[running ]");
|
||||
assert_eq!(colored, "[\u{1b}[32mrunning \u{1b}[0m]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_merge_into_one_escape_sequence() {
|
||||
with_color(true, || {
|
||||
// Chaining combines into a single style rather than nesting two
|
||||
// wrappers, so there is one prefix and one reset.
|
||||
assert_eq!("hi".green().bold().to_string(), "\u{1b}[32;1mhi\u{1b}[0m");
|
||||
assert_eq!(
|
||||
"hi".green().bold().to_string(),
|
||||
owo_colors::OwoColorize::style(&"hi", Style::new().green().bold()).to_string()
|
||||
);
|
||||
// Order of the calls does not change the resulting style.
|
||||
assert_eq!(
|
||||
"hi".green().bold().to_string(),
|
||||
"hi".bold().green().to_string()
|
||||
);
|
||||
});
|
||||
with_color(false, || {
|
||||
assert_eq!("hi".green().bold().to_string(), "hi");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_string_values_render() {
|
||||
with_color(false, || {
|
||||
assert_eq!(8443.green().to_string(), "8443");
|
||||
assert_eq!(true.yellow().to_string(), "true");
|
||||
});
|
||||
}
|
||||
|
||||
/// Render a `dialoguer` confirm prompt, which styles through `console`.
|
||||
///
|
||||
/// Asserting on emitted bytes rather than on `console::colors_enabled()`
|
||||
/// keeps this honest about what a user would actually see.
|
||||
fn rendered_prompt() -> String {
|
||||
use dialoguer::theme::Theme as _;
|
||||
|
||||
let mut out = String::new();
|
||||
dialoguer::theme::ColorfulTheme::default()
|
||||
.format_confirm_prompt(&mut out, "Continue?", Some(false))
|
||||
.expect("format confirm prompt");
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn console_override_governs_indicatif_and_dialoguer_styling() {
|
||||
let _guard = SWITCH_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// Prompts and progress bars draw to stderr, so their styles consult
|
||||
// console's stderr switch. `init` sets both; so does this test.
|
||||
let previous = console::colors_enabled_stderr();
|
||||
|
||||
console::set_colors_enabled_stderr(true);
|
||||
let styled = rendered_prompt();
|
||||
console::set_colors_enabled_stderr(false);
|
||||
let plain = rendered_prompt();
|
||||
|
||||
console::set_colors_enabled_stderr(previous);
|
||||
|
||||
// Positive control first: without it, the plain assertion could pass
|
||||
// simply because dialoguer stopped emitting anything at all.
|
||||
assert!(
|
||||
styled.contains('\u{1b}'),
|
||||
"console override should permit styling, got: {styled:?}"
|
||||
);
|
||||
assert!(
|
||||
!plain.contains('\u{1b}'),
|
||||
"console override should suppress styling, got: {plain:?}"
|
||||
);
|
||||
assert!(plain.contains("Continue?"), "got: {plain:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_choice_overrides_environment_and_terminal() {
|
||||
assert!(resolve(ColorChoice::Always, Some(env("1")), None, false));
|
||||
assert!(!resolve(ColorChoice::Never, None, Some(env("1")), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_color_disables_when_set_and_non_empty() {
|
||||
assert!(!resolve(ColorChoice::Auto, Some(env("1")), None, true));
|
||||
// Presence is what counts; the value is not interpreted, so a value that
|
||||
// reads as falsy still disables color.
|
||||
assert!(!resolve(ColorChoice::Auto, Some(env("0")), None, true));
|
||||
// NO_COLOR outranks FORCE_COLOR.
|
||||
assert!(!resolve(
|
||||
ColorChoice::Auto,
|
||||
Some(env("1")),
|
||||
Some(env("1")),
|
||||
true
|
||||
));
|
||||
// An empty value is not "set" for the purposes of the convention.
|
||||
assert!(resolve(ColorChoice::Auto, Some(env("")), None, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_color_enables_when_set_and_non_empty() {
|
||||
assert!(resolve(ColorChoice::Auto, None, Some(env("1")), false));
|
||||
// Same presence rule as NO_COLOR: `0` is a value, not an opt-out.
|
||||
// <https://force-color.org> keys on presence and non-emptiness only.
|
||||
assert!(resolve(ColorChoice::Auto, None, Some(env("0")), false));
|
||||
assert!(!resolve(ColorChoice::Auto, None, Some(env("")), false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn term_capability_follows_the_console_rule() {
|
||||
assert!(term_supports_ansi(Some(OsStr::new("xterm-256color"))));
|
||||
assert!(term_supports_ansi(Some(OsStr::new("screen"))));
|
||||
assert!(!term_supports_ansi(Some(OsStr::new("dumb"))));
|
||||
// Nothing to suggest a capable terminal, so assume none.
|
||||
assert!(!term_supports_ansi(None));
|
||||
// Empty names no terminal type; treated as unset, unlike `console`.
|
||||
assert!(!term_supports_ansi(Some(OsStr::new(""))));
|
||||
// Only an exact match counts; `dumb-something` is a different terminal.
|
||||
assert!(term_supports_ansi(Some(OsStr::new("dumb-but-color"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_does_not_style_an_incapable_terminal() {
|
||||
// A `dumb` terminal is still a terminal, so `is_terminal()` alone would
|
||||
// wrongly enable color.
|
||||
assert!(!resolve(ColorChoice::Auto, None, None, false));
|
||||
assert!(resolve(ColorChoice::Auto, None, None, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_requests_outrank_terminal_capability() {
|
||||
// `--color always` and FORCE_COLOR are for callers who know better than
|
||||
// the detection, so an incapable terminal must not veto them.
|
||||
assert!(resolve(ColorChoice::Always, None, None, false));
|
||||
assert!(resolve(ColorChoice::Auto, None, Some(env("1")), false));
|
||||
// The negative direction still wins over capability too.
|
||||
assert!(!resolve(ColorChoice::Never, None, None, true));
|
||||
assert!(!resolve(ColorChoice::Auto, Some(env("1")), None, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_does_not_rescue_a_redirected_stream() {
|
||||
// Capability is an additional requirement, not an alternative one.
|
||||
assert!(!resolve(ColorChoice::Auto, None, None, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_resolves_each_stream_independently() {
|
||||
// `openshell ... 2> build.log` from a terminal: stdout is styled, the
|
||||
// log file is not. Resolving both from stdout's answer would put escapes
|
||||
// in the log.
|
||||
assert!(resolve(ColorChoice::Auto, None, None, true));
|
||||
assert!(!resolve(ColorChoice::Auto, None, None, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn painted_requires_both_streams() {
|
||||
// A `Painted` value cannot tell whether it is bound for stdout or
|
||||
// stderr, so it stays plain unless both streams accept escapes.
|
||||
assert!(with_streams(true, true, painted_enabled));
|
||||
assert!(!with_streams(true, false, painted_enabled));
|
||||
assert!(!with_streams(false, true, painted_enabled));
|
||||
assert!(!with_streams(false, false, painted_enabled));
|
||||
|
||||
// The rendered consequence: stderr redirected to a file leaves the
|
||||
// styled text plain rather than writing escapes into it.
|
||||
assert_eq!(
|
||||
with_streams(true, false, || "running".green().to_string()),
|
||||
"running"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_follows_the_terminal() {
|
||||
assert!(resolve(ColorChoice::Auto, None, None, true));
|
||||
assert!(!resolve(ColorChoice::Auto, None, None, false));
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
//! Shared helpers, types, and parsing utilities used across CLI command groups.
|
||||
|
||||
use crate::color::Colorize;
|
||||
use chrono::DateTime;
|
||||
use dialoguer::{Confirm, theme::ColorfulTheme};
|
||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||
@@ -17,7 +18,6 @@ use openshell_core::proto::{
|
||||
};
|
||||
use openshell_core::settings::{self, SettingValueKind};
|
||||
use openshell_providers::builtin_profiles;
|
||||
use owo_colors::OwoColorize;
|
||||
use std::collections::HashMap;
|
||||
use std::io::IsTerminal;
|
||||
use std::process::Command;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use crate::color::Colorize;
|
||||
use crate::tls::{
|
||||
TlsOptions, build_insecure_rustls_config, build_rustls_config, grpc_client,
|
||||
require_tls_materials,
|
||||
@@ -20,7 +21,6 @@ use openshell_bootstrap::{
|
||||
};
|
||||
use openshell_bootstrap::{GatewayMetadataSource, ListedGateway};
|
||||
use openshell_core::proto::{GetGatewayInfoRequest, HealthRequest, ServiceStatus};
|
||||
use owo_colors::OwoColorize;
|
||||
use std::io::IsTerminal;
|
||||
use std::path::PathBuf;
|
||||
use tonic::{Code, Status};
|
||||
@@ -1970,7 +1970,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn gateway_add_registers_plaintext_loopback_gateway_without_local_flag() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
||||
with_tmp_xdg(tmpdir.path(), || {
|
||||
let runtime = tokio::runtime::Runtime::new().expect("create runtime");
|
||||
@@ -2002,7 +2001,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn gateway_add_respects_local_flag_for_plaintext_registrations() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
||||
with_tmp_xdg(tmpdir.path(), || {
|
||||
let runtime = tokio::runtime::Runtime::new().expect("create runtime");
|
||||
@@ -2032,7 +2030,6 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_health_check_supports_plain_http_endpoints() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
|
||||
let addr = listener.local_addr().expect("listener addr");
|
||||
let server = thread::spawn(move || {
|
||||
@@ -2061,7 +2058,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn gateway_add_oidc_rolls_back_on_auth_failure() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
||||
with_tmp_xdg(tmpdir.path(), || {
|
||||
let runtime = tokio::runtime::Runtime::new().expect("create runtime");
|
||||
@@ -2118,7 +2114,6 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn gateway_add_oidc_rollback_keeps_system_active_fallback_userless() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let user = tempfile::tempdir().expect("create user tmpdir");
|
||||
let system = tempfile::tempdir().expect("create system tmpdir");
|
||||
with_tmp_xdg_and_system(user.path(), system.path(), || {
|
||||
@@ -2159,7 +2154,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn gateway_add_cloud_rolls_back_on_auth_failure() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
||||
with_tmp_xdg(tmpdir.path(), || {
|
||||
let _no_browser = EnvVarGuard::set("OPENSHELL_NO_BROWSER", "0");
|
||||
@@ -2217,7 +2211,6 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn gateway_add_cloud_rollback_keeps_system_active_fallback_userless() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let user = tempfile::tempdir().expect("create user tmpdir");
|
||||
let system = tempfile::tempdir().expect("create system tmpdir");
|
||||
with_tmp_xdg_and_system(user.path(), system.path(), || {
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
|
||||
pub mod common;
|
||||
pub mod gateway;
|
||||
pub mod provider;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,8 +39,9 @@ pub fn complete_sandbox_names(_prefix: &OsStr) -> Vec<CompletionCandidate> {
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
label_selector: String::new(),
|
||||
workspace: workspace_from_args(),
|
||||
all_workspaces: false,
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
||||
workspace_from_args(),
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.ok()?;
|
||||
@@ -64,8 +65,9 @@ pub fn complete_provider_names(_prefix: &OsStr) -> Vec<CompletionCandidate> {
|
||||
.list_providers(ListProvidersRequest {
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
workspace: workspace_from_args(),
|
||||
all_workspaces: false,
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
||||
workspace_from_args(),
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
@@ -11,6 +11,7 @@ pub(crate) static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()
|
||||
pub(crate) mod test_utils;
|
||||
|
||||
pub mod auth;
|
||||
pub mod color;
|
||||
pub(crate) mod commands;
|
||||
pub mod completers;
|
||||
pub mod edge_tunnel;
|
||||
|
||||
+754
-288
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,7 @@ use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
use tracing::debug;
|
||||
|
||||
const AUTH_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
const AUTH_TIMEOUT: Duration = Duration::from_mins(2);
|
||||
|
||||
/// OIDC discovery document (subset of fields we need).
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -93,6 +93,7 @@ async fn discover(issuer: &str, insecure: bool) -> Result<OidcDiscovery> {
|
||||
}
|
||||
|
||||
fn http_client(insecure: bool) -> reqwest::Client {
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
let mut builder = reqwest::ClientBuilder::new().redirect(reqwest::redirect::Policy::none());
|
||||
if insecure {
|
||||
builder = builder.danger_accept_invalid_certs(true);
|
||||
|
||||
+1521
-3428
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
|
||||
//! SSH connection and proxy utilities.
|
||||
|
||||
use crate::color::Colorize;
|
||||
use crate::tls::{TlsOptions, grpc_client};
|
||||
use miette::{IntoDiagnostic, Result, WrapErr};
|
||||
#[cfg(unix)]
|
||||
@@ -16,7 +17,6 @@ use openshell_core::proto::{
|
||||
tcp_forward_init,
|
||||
};
|
||||
use openshell_core::{ObjectId, driver_mounts};
|
||||
use owo_colors::OwoColorize;
|
||||
use std::fs;
|
||||
use std::future::Future;
|
||||
use std::io::{IsTerminal, Write};
|
||||
@@ -92,7 +92,7 @@ async fn ssh_session_config(
|
||||
let sandbox = client
|
||||
.get_sandbox(GetSandboxRequest {
|
||||
name: name.to_string(),
|
||||
workspace: workspace.to_string(),
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
|
||||
})
|
||||
.await
|
||||
.into_diagnostic()?
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
use miette::{IntoDiagnostic, Result, WrapErr};
|
||||
use openshell_core::auth::EdgeAuthInterceptor;
|
||||
use openshell_core::net::set_tcp_nodelay_best_effort;
|
||||
use openshell_core::proto::inference_client::InferenceClient;
|
||||
use openshell_core::proto::open_shell_client::OpenShellClient;
|
||||
use rustls::{
|
||||
RootCertStore,
|
||||
@@ -26,8 +25,6 @@ use url::{Host, Url};
|
||||
|
||||
/// Concrete gRPC client type used by all commands.
|
||||
pub type GrpcClient = OpenShellClient<InterceptedService<Channel, EdgeAuthInterceptor>>;
|
||||
/// Concrete inference client type.
|
||||
pub type GrpcInferenceClient = InferenceClient<InterceptedService<Channel, EdgeAuthInterceptor>>;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct TlsOptions {
|
||||
@@ -287,7 +284,7 @@ impl ServerCertVerifier for InsecureServerCertVerifier {
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
rustls::crypto::ring::default_provider()
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
@@ -466,12 +463,6 @@ fn interceptor_from_tls(tls: &TlsOptions) -> Result<EdgeAuthInterceptor> {
|
||||
EdgeAuthInterceptor::new(tls.oidc_token.as_deref(), tls.edge_token.as_deref())
|
||||
}
|
||||
|
||||
pub async fn grpc_inference_client(server: &str, tls: &TlsOptions) -> Result<GrpcInferenceClient> {
|
||||
let channel = build_channel(server, tls).await?;
|
||||
let interceptor = interceptor_from_tls(tls)?;
|
||||
Ok(InferenceClient::with_interceptor(channel, interceptor))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::tls_server_name;
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! End-to-end checks that `openshell` does not write ANSI escape sequences into
|
||||
//! a pipe.
|
||||
//!
|
||||
//! `Command::output` gives the child a pipe rather than a terminal, which is
|
||||
//! exactly the situation these tests care about: a supervisor script parsing
|
||||
//! output. The unit tests in `color` cover the precedence rules; these cover the
|
||||
//! wiring from flag to rendered byte, across both styling paths that reach
|
||||
//! stdout — the styled tables and the `tracing` formatter.
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
const ESC: char = '\u{1b}';
|
||||
const SANDBOX: &str = "openclaw-saw";
|
||||
const PORT: u16 = 8443;
|
||||
|
||||
/// Seed a forward PID file so `forward list` has a row to render.
|
||||
///
|
||||
/// The PID is one that cannot be running, so the row reports `dead`. Status text
|
||||
/// is irrelevant here — both states go through the same styling path.
|
||||
fn config_dir_with_forward(root: &Path) {
|
||||
let forwards = root.join("openshell").join("forwards");
|
||||
std::fs::create_dir_all(&forwards).expect("create forwards dir");
|
||||
std::fs::write(
|
||||
forwards.join(format!("{SANDBOX}-{PORT}.pid")),
|
||||
// Format: <pid>\t<sandbox_id>\t<bind_addr>
|
||||
"4000000\tsbx-test\t0.0.0.0",
|
||||
)
|
||||
.expect("write forward pid file");
|
||||
}
|
||||
|
||||
fn forward_list(config_dir: &Path, args: &[&str], no_color: Option<&str>) -> String {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_openshell"));
|
||||
command
|
||||
.args(["forward", "list"])
|
||||
.args(args)
|
||||
.env("XDG_CONFIG_HOME", config_dir)
|
||||
.env_remove("NO_COLOR")
|
||||
.env_remove("FORCE_COLOR")
|
||||
.env_remove("OPENSHELL_COLOR");
|
||||
if let Some(value) = no_color {
|
||||
command.env("NO_COLOR", value);
|
||||
}
|
||||
|
||||
let output = command.output().expect("run openshell forward list");
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout is utf-8");
|
||||
assert!(
|
||||
stdout.contains(SANDBOX),
|
||||
"expected the seeded forward in the table, got: {stdout:?}"
|
||||
);
|
||||
stdout
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn piped_output_is_free_of_escape_sequences_by_default() {
|
||||
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
||||
config_dir_with_forward(tmpdir.path());
|
||||
|
||||
let stdout = forward_list(tmpdir.path(), &[], None);
|
||||
|
||||
assert!(
|
||||
!stdout.contains(ESC),
|
||||
"piped `forward list` must not emit ANSI escapes, got: {stdout:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_color_environment_variable_is_honored() {
|
||||
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
||||
config_dir_with_forward(tmpdir.path());
|
||||
|
||||
let stdout = forward_list(tmpdir.path(), &[], Some("1"));
|
||||
|
||||
assert!(!stdout.contains(ESC), "NO_COLOR ignored, got: {stdout:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_never_suppresses_escape_sequences() {
|
||||
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
||||
config_dir_with_forward(tmpdir.path());
|
||||
|
||||
let stdout = forward_list(tmpdir.path(), &["--color", "never"], None);
|
||||
|
||||
assert!(
|
||||
!stdout.contains(ESC),
|
||||
"--color never ignored, got: {stdout:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_always_emits_escape_sequences_into_a_pipe() {
|
||||
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
||||
config_dir_with_forward(tmpdir.path());
|
||||
|
||||
// The opt-in escape hatch for callers who pipe into a pager and still want
|
||||
// styling. Also proves the other cases are suppressing real output rather
|
||||
// than rendering an empty table.
|
||||
let stdout = forward_list(tmpdir.path(), &["--color", "always"], None);
|
||||
|
||||
assert!(
|
||||
stdout.contains(ESC),
|
||||
"--color always must still colorize, got: {stdout:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_color_flag_overrides_no_color() {
|
||||
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
||||
config_dir_with_forward(tmpdir.path());
|
||||
|
||||
let stdout = forward_list(tmpdir.path(), &["--color", "always"], Some("1"));
|
||||
|
||||
assert!(
|
||||
stdout.contains(ESC),
|
||||
"--color always must outrank NO_COLOR, got: {stdout:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Run a command that fails to connect, with logging turned up so the `tracing`
|
||||
/// formatter produces output.
|
||||
///
|
||||
/// `tracing_subscriber::fmt` writes to stdout with ANSI enabled and does no
|
||||
/// terminal detection of its own, so this is a second, independent way for
|
||||
/// escapes to reach a pipe.
|
||||
fn failing_connect(args: &[&str]) -> (String, String) {
|
||||
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_openshell"))
|
||||
.args([
|
||||
"sandbox",
|
||||
"list",
|
||||
"--gateway",
|
||||
"test-gateway",
|
||||
// Nothing listens on port 1; the connection is refused immediately.
|
||||
"--gateway-endpoint",
|
||||
"http://127.0.0.1:1",
|
||||
])
|
||||
.args(args)
|
||||
.env("XDG_CONFIG_HOME", tmpdir.path())
|
||||
.env("RUST_LOG", "debug")
|
||||
.env_remove("NO_COLOR")
|
||||
.env_remove("FORCE_COLOR")
|
||||
.env_remove("OPENSHELL_COLOR")
|
||||
.output()
|
||||
.expect("run openshell sandbox list");
|
||||
|
||||
(
|
||||
String::from_utf8_lossy(&output.stdout).into_owned(),
|
||||
String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
fn verbose_log_output(args: &[&str]) -> String {
|
||||
failing_connect(args).0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracing_output_is_free_of_escape_sequences_when_piped() {
|
||||
let plain = verbose_log_output(&[]);
|
||||
let styled = verbose_log_output(&["--color", "always"]);
|
||||
|
||||
// Positive control. If this fails, the command stopped emitting logs at
|
||||
// debug level — fix the invocation rather than deleting the assertion
|
||||
// below, which would otherwise pass vacuously.
|
||||
assert!(
|
||||
styled.contains(ESC),
|
||||
"expected `--color always` to style log output; got: {styled:?}"
|
||||
);
|
||||
assert!(
|
||||
!plain.contains(ESC),
|
||||
"piped log output must not emit ANSI escapes, got: {plain:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Run a command with stdout attached to a pseudo-terminal and stderr on a
|
||||
/// pipe, returning what each stream received.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn run_with_stdout_tty(mut command: Command) -> (String, String) {
|
||||
use std::io::Read;
|
||||
use std::os::fd::{AsRawFd, OwnedFd};
|
||||
|
||||
let pty = nix::pty::openpty(None, None).expect("openpty");
|
||||
let controller: OwnedFd = pty.master;
|
||||
let follower: OwnedFd = pty.slave;
|
||||
|
||||
let mut child = command
|
||||
.stdout(follower.try_clone().expect("dup pty follower"))
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.expect("spawn openshell");
|
||||
// `Command` retains its configured stdio handles after spawning. Drop it so
|
||||
// the controller sees EIO once the child exits.
|
||||
drop(command);
|
||||
|
||||
// Drop every follower handle in this process, or reading the controller
|
||||
// blocks forever instead of returning EIO once the child exits.
|
||||
drop(follower);
|
||||
|
||||
let mut stderr_buf = String::new();
|
||||
child
|
||||
.stderr
|
||||
.take()
|
||||
.expect("stderr pipe")
|
||||
.read_to_string(&mut stderr_buf)
|
||||
.expect("read stderr");
|
||||
child.wait().expect("wait for openshell");
|
||||
|
||||
// The pty read ends with EIO rather than a clean EOF; treat that as done.
|
||||
let mut stdout_buf = Vec::new();
|
||||
let mut chunk = [0u8; 4096];
|
||||
loop {
|
||||
match nix::unistd::read(controller.as_raw_fd(), &mut chunk) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => stdout_buf.extend_from_slice(&chunk[..n]),
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
String::from_utf8_lossy(&stdout_buf).into_owned(),
|
||||
stderr_buf,
|
||||
)
|
||||
}
|
||||
|
||||
/// Run a failing command with stdout attached to a pseudo-terminal and stderr
|
||||
/// on a pipe, returning what each stream received.
|
||||
///
|
||||
/// `Command::output` gives both streams pipes, so it cannot distinguish a
|
||||
/// per-stream decision from a single one resolved off stdout. This asymmetric
|
||||
/// setup is the only way to catch a stream being handed the other stream's
|
||||
/// answer.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn split_streams_stdout_tty(args: &[&str]) -> (String, String) {
|
||||
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_openshell"));
|
||||
command
|
||||
.args([
|
||||
"sandbox",
|
||||
"list",
|
||||
"--gateway",
|
||||
"test-gateway",
|
||||
"--gateway-endpoint",
|
||||
"http://127.0.0.1:1",
|
||||
])
|
||||
.args(args)
|
||||
.env("XDG_CONFIG_HOME", tmpdir.path())
|
||||
.env("RUST_LOG", "debug")
|
||||
// Pin TERM: `auto` now requires a capable terminal, and CI runners
|
||||
// often leave TERM unset, which would make this test's outcome depend
|
||||
// on the ambient environment.
|
||||
.env("TERM", "xterm-256color")
|
||||
.env_remove("NO_COLOR")
|
||||
.env_remove("FORCE_COLOR")
|
||||
.env_remove("OPENSHELL_COLOR");
|
||||
|
||||
run_with_stdout_tty(command)
|
||||
}
|
||||
|
||||
/// Run `forward list` with stdout on a pseudo-terminal, under the given `TERM`,
|
||||
/// and return everything stdout received.
|
||||
///
|
||||
/// When `stderr_on_tty` is true, both streams share the terminal so the
|
||||
/// `owo-colors` table is styled too. Otherwise, stderr is redirected to
|
||||
/// `/dev/null`, which verifies the conservative table behavior.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn forward_list_on_pty(term: &str, args: &[&str], stderr_on_tty: bool) -> String {
|
||||
use std::os::fd::{AsRawFd, OwnedFd};
|
||||
|
||||
let pty = nix::pty::openpty(None, None).expect("openpty");
|
||||
let controller: OwnedFd = pty.master;
|
||||
let follower: OwnedFd = pty.slave;
|
||||
|
||||
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
||||
config_dir_with_forward(tmpdir.path());
|
||||
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_openshell"));
|
||||
command
|
||||
.args(["forward", "list"])
|
||||
.args(args)
|
||||
.env("XDG_CONFIG_HOME", tmpdir.path())
|
||||
.env("TERM", term)
|
||||
.env_remove("NO_COLOR")
|
||||
.env_remove("FORCE_COLOR")
|
||||
.env_remove("OPENSHELL_COLOR")
|
||||
.stdout(follower.try_clone().expect("dup pty follower"));
|
||||
if stderr_on_tty {
|
||||
command.stderr(follower.try_clone().expect("dup pty follower"));
|
||||
} else {
|
||||
command.stderr(std::process::Stdio::null());
|
||||
}
|
||||
let mut child = command.spawn().expect("spawn openshell");
|
||||
// `Command` retains its configured stdio handles after spawning. Drop it so
|
||||
// the controller sees EIO once the child exits.
|
||||
drop(command);
|
||||
|
||||
// Drop every follower handle here, or the controller read never sees EIO.
|
||||
drop(follower);
|
||||
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = [0u8; 4096];
|
||||
loop {
|
||||
match nix::unistd::read(controller.as_raw_fd(), &mut chunk) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => buf.extend_from_slice(&chunk[..n]),
|
||||
}
|
||||
}
|
||||
child.wait().expect("wait for openshell");
|
||||
|
||||
let out = String::from_utf8_lossy(&buf).into_owned();
|
||||
assert!(
|
||||
out.contains(SANDBOX),
|
||||
"expected the seeded forward in the table, got: {out:?}"
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// A terminal that does not render ANSI must not be styled under `auto`.
|
||||
///
|
||||
/// `TERM=dumb` is still a terminal, so an `is_terminal()` check alone reports it
|
||||
/// as styleable. `console` and `miette` apply their own `TERM` checks, but the
|
||||
/// color switch overrides both, so the check has to live here.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn dumb_terminal_is_not_styled_under_auto() {
|
||||
let dumb = forward_list_on_pty("dumb", &[], true);
|
||||
// Positive control: the same session on a capable terminal is styled, so a
|
||||
// plain result below means capability was consulted, not that the pty setup
|
||||
// silently produced nothing.
|
||||
let capable = forward_list_on_pty("xterm-256color", &[], true);
|
||||
|
||||
assert!(
|
||||
capable.contains(ESC),
|
||||
"expected styling on a capable terminal; got: {capable:?}"
|
||||
);
|
||||
assert!(
|
||||
!dumb.contains(ESC),
|
||||
"TERM=dumb must not be styled, got: {dumb:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// An explicit request outranks the capability check, for callers who know
|
||||
/// their terminal better than `TERM` does.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn color_always_overrides_a_dumb_terminal() {
|
||||
let forced = forward_list_on_pty("dumb", &["--color", "always"], true);
|
||||
|
||||
assert!(
|
||||
forced.contains(ESC),
|
||||
"--color always must style even a dumb terminal, got: {forced:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression test for a redirected stream inheriting the other stream's
|
||||
/// terminal check.
|
||||
///
|
||||
/// With stdout on a terminal and stderr redirected, `openshell ... 2> build.log`
|
||||
/// must leave the log free of escapes while stdout stays styled.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn redirected_stderr_stays_plain_while_stdout_is_a_terminal() {
|
||||
let (stdout, stderr) = split_streams_stdout_tty(&[]);
|
||||
|
||||
// Positive control: stdout really is a terminal here, so something must be
|
||||
// styled. Otherwise the stderr assertion could pass for the wrong reason.
|
||||
assert!(
|
||||
stdout.contains(ESC),
|
||||
"expected styled stdout on a pty; got: {stdout:?}"
|
||||
);
|
||||
assert!(
|
||||
!stderr.contains(ESC),
|
||||
"redirected stderr must not receive escapes, got: {stderr:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `Painted` cannot identify its destination stream, so table styling is
|
||||
/// deliberately disabled when either stream is redirected.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn status_table_is_plain_when_stderr_is_redirected() {
|
||||
let stdout = forward_list_on_pty("xterm-256color", &[], false);
|
||||
|
||||
assert!(
|
||||
stdout.contains(SANDBOX),
|
||||
"expected the seeded forward in the table, got: {stdout:?}"
|
||||
);
|
||||
assert!(
|
||||
!stdout.contains(ESC),
|
||||
"STATUS table must stay plain when stderr is redirected, got: {stdout:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_output_follows_the_color_setting() {
|
||||
// miette renders errors to stderr through its own handler. It already
|
||||
// suppresses color when stderr is redirected, but it has no way to learn
|
||||
// about `--color`, so `init` installs a handler built from the setting.
|
||||
let (_, plain) = failing_connect(&[]);
|
||||
let (_, styled) = failing_connect(&["--color", "always"]);
|
||||
|
||||
assert!(
|
||||
styled.contains(ESC),
|
||||
"expected `--color always` to style error output; got: {styled:?}"
|
||||
);
|
||||
assert!(
|
||||
!plain.contains(ESC),
|
||||
"piped error output must not emit ANSI escapes, got: {plain:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_column_is_matchable_by_a_whitespace_anchored_pattern() {
|
||||
// The regression this whole change exists for: a supervisor deciding whether
|
||||
// a forward is alive by matching the STATUS column. Colorized output put an
|
||||
// escape byte immediately before the status word, so a pattern requiring
|
||||
// whitespace ahead of it could never match.
|
||||
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
||||
config_dir_with_forward(tmpdir.path());
|
||||
|
||||
let stdout = forward_list(tmpdir.path(), &[], None);
|
||||
let row = stdout
|
||||
.lines()
|
||||
.find(|line| line.starts_with(SANDBOX))
|
||||
.unwrap_or_else(|| panic!("no row for {SANDBOX} in: {stdout:?}"));
|
||||
|
||||
let status = row
|
||||
.split_whitespace()
|
||||
.next_back()
|
||||
.expect("row has a status column");
|
||||
assert_eq!(status, "dead");
|
||||
|
||||
// Whitespace, not an escape byte, separates the PID from the status.
|
||||
let boundary = row
|
||||
.rfind(status)
|
||||
.and_then(|index| row[..index].chars().next_back())
|
||||
.expect("status is preceded by the rest of the row");
|
||||
assert!(
|
||||
boundary.is_whitespace(),
|
||||
"expected whitespace before the status column, found {boundary:?} in {row:?}"
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,7 @@
|
||||
|
||||
mod helpers;
|
||||
|
||||
use helpers::{
|
||||
EnvVarGuard, build_ca, build_client_cert, build_server_cert, install_rustls_provider,
|
||||
};
|
||||
use helpers::{EnvVarGuard, build_ca, build_client_cert, build_server_cert};
|
||||
use openshell_cli::run;
|
||||
use openshell_cli::tls::TlsOptions;
|
||||
use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer};
|
||||
@@ -81,6 +79,13 @@ impl TestOpenShell {
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl OpenShell for TestOpenShell {
|
||||
async fn begin_rootfs_tar_staging(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::BeginRootfsTarStagingRequest>,
|
||||
) -> Result<Response<openshell_core::proto::BeginRootfsTarStagingResponse>, Status> {
|
||||
Err(Status::unimplemented("not used by this test server"))
|
||||
}
|
||||
|
||||
async fn report_main_process_exit(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::ReportMainProcessExitRequest>,
|
||||
@@ -154,6 +159,8 @@ impl OpenShell for TestOpenShell {
|
||||
Ok(Response::new(ListSandboxesResponse::default()))
|
||||
}
|
||||
|
||||
unimplemented_sandbox_template_rpcs!();
|
||||
|
||||
async fn list_sandbox_providers(
|
||||
&self,
|
||||
_request: tonic::Request<ListSandboxProvidersRequest>,
|
||||
@@ -314,14 +321,30 @@ impl OpenShell for TestOpenShell {
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::ListProviderProfilesRequest>,
|
||||
) -> Result<Response<openshell_core::proto::ListProviderProfilesResponse>, Status> {
|
||||
Err(Status::unimplemented("not implemented in test"))
|
||||
let profiles = openshell_providers::builtin_profiles()
|
||||
.iter()
|
||||
.map(openshell_providers::ProviderTypeProfile::to_proto)
|
||||
.collect();
|
||||
Ok(Response::new(
|
||||
openshell_core::proto::ListProviderProfilesResponse { profiles },
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_provider_profile(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
|
||||
request: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
|
||||
) -> Result<Response<openshell_core::proto::ProviderProfileResponse>, Status> {
|
||||
Err(Status::unimplemented("not implemented in test"))
|
||||
let id = request.into_inner().id;
|
||||
let profile = openshell_providers::builtin_profiles()
|
||||
.iter()
|
||||
.find(|profile| profile.id == id)
|
||||
.ok_or_else(|| Status::not_found("provider profile not found"))?
|
||||
.to_proto();
|
||||
Ok(Response::new(
|
||||
openshell_core::proto::ProviderProfileResponse {
|
||||
profile: Some(profile),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
async fn import_provider_profiles(
|
||||
@@ -708,8 +731,6 @@ struct TestServer {
|
||||
}
|
||||
|
||||
async fn run_server() -> TestServer {
|
||||
install_rustls_provider();
|
||||
|
||||
let (ca, ca_key) = build_ca();
|
||||
let (server_cert, server_key) = build_server_cert(&ca, &ca_key);
|
||||
let (client_cert, client_key) = build_client_cert(&ca, &ca_key);
|
||||
@@ -850,7 +871,7 @@ async fn explicit_provider_name_errors_for_unrecognised_name() {
|
||||
"error should mention the name: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("not a recognized provider type"),
|
||||
msg.contains("no provider profile"),
|
||||
"error should explain why it failed: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Output};
|
||||
|
||||
const DEAD_PID: u32 = u32::MAX;
|
||||
|
||||
fn run_forward_list(config_dir: &Path, args: &[&str]) -> Output {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_openshell"));
|
||||
for (key, _) in std::env::vars().filter(|(key, _)| key.starts_with("OPENSHELL_")) {
|
||||
command.env_remove(key);
|
||||
}
|
||||
command
|
||||
.args(["forward", "list"])
|
||||
.args(args)
|
||||
.env("XDG_CONFIG_HOME", config_dir)
|
||||
.env("HOME", config_dir)
|
||||
.output()
|
||||
.expect("run openshell forward list")
|
||||
}
|
||||
|
||||
fn write_dead_forward(config_dir: &Path) {
|
||||
let forward_dir = config_dir.join("openshell/forwards");
|
||||
fs::create_dir_all(&forward_dir).expect("create forward directory");
|
||||
fs::write(
|
||||
forward_dir.join("my-sandbox-8443.pid"),
|
||||
format!("{DEAD_PID}\tsandbox-id\t0.0.0.0"),
|
||||
)
|
||||
.expect("write forward PID record");
|
||||
}
|
||||
|
||||
fn assert_success(output: &Output) {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"forward list failed:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_list_json_emits_machine_readable_records() {
|
||||
let config_dir = tempfile::tempdir().expect("create config directory");
|
||||
write_dead_forward(config_dir.path());
|
||||
|
||||
let output = run_forward_list(config_dir.path(), &["--output", "json"]);
|
||||
assert_success(&output);
|
||||
assert!(
|
||||
output.stderr.is_empty(),
|
||||
"structured output wrote to stderr"
|
||||
);
|
||||
assert!(
|
||||
!output.stdout.contains(&0x1b),
|
||||
"JSON contained ANSI escapes"
|
||||
);
|
||||
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_slice(&output.stdout).expect("stdout should contain only JSON");
|
||||
assert_eq!(
|
||||
value,
|
||||
serde_json::json!([{
|
||||
"sandbox": "my-sandbox",
|
||||
"bind_address": "0.0.0.0",
|
||||
"port": 8443,
|
||||
"pid": DEAD_PID,
|
||||
"alive": false,
|
||||
}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_list_yaml_emits_equivalent_records() {
|
||||
let config_dir = tempfile::tempdir().expect("create config directory");
|
||||
write_dead_forward(config_dir.path());
|
||||
|
||||
let output = run_forward_list(config_dir.path(), &["-o", "yaml"]);
|
||||
assert_success(&output);
|
||||
assert!(
|
||||
output.stderr.is_empty(),
|
||||
"structured output wrote to stderr"
|
||||
);
|
||||
assert!(
|
||||
!output.stdout.contains(&0x1b),
|
||||
"YAML contained ANSI escapes"
|
||||
);
|
||||
|
||||
let value: serde_json::Value =
|
||||
serde_yml::from_slice(&output.stdout).expect("stdout should contain only YAML");
|
||||
assert_eq!(
|
||||
value,
|
||||
serde_json::json!([{
|
||||
"sandbox": "my-sandbox",
|
||||
"bind_address": "0.0.0.0",
|
||||
"port": 8443,
|
||||
"pid": DEAD_PID,
|
||||
"alive": false,
|
||||
}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_list_structured_output_emits_empty_collections() {
|
||||
let config_dir = tempfile::tempdir().expect("create config directory");
|
||||
|
||||
for format in ["json", "yaml"] {
|
||||
let output = run_forward_list(config_dir.path(), &["--output", format]);
|
||||
assert_success(&output);
|
||||
assert!(output.stderr.is_empty(), "{format} output wrote to stderr");
|
||||
|
||||
let value: serde_json::Value = match format {
|
||||
"json" => serde_json::from_slice(&output.stdout).expect("parse empty JSON collection"),
|
||||
"yaml" => serde_yml::from_slice(&output.stdout).expect("parse empty YAML collection"),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert_eq!(value, serde_json::json!([]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_list_table_output_keeps_existing_columns() {
|
||||
let config_dir = tempfile::tempdir().expect("create config directory");
|
||||
write_dead_forward(config_dir.path());
|
||||
|
||||
let output = run_forward_list(config_dir.path(), &[]);
|
||||
assert_success(&output);
|
||||
assert!(output.stderr.is_empty(), "table output wrote to stderr");
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let rows: Vec<Vec<&str>> = stdout
|
||||
.lines()
|
||||
.map(|line| line.split_whitespace().collect())
|
||||
.collect();
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0], vec!["SANDBOX", "BIND", "PORT", "PID", "STATUS"]);
|
||||
assert_eq!(
|
||||
&rows[1][..4],
|
||||
["my-sandbox", "0.0.0.0", "8443", "4294967295"]
|
||||
);
|
||||
assert!(rows[1][4].contains("dead"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_list_help_documents_output_flag() {
|
||||
let config_dir = tempfile::tempdir().expect("create config directory");
|
||||
let output = run_forward_list(config_dir.path(), &["--help"]);
|
||||
assert_success(&output);
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).expect("help should be UTF-8");
|
||||
assert!(stdout.contains("-o, --output <OUTPUT>"), "{stdout}");
|
||||
assert!(stdout.contains("[default: table]"), "{stdout}");
|
||||
assert!(
|
||||
stdout.contains("possible values: table, yaml, json"),
|
||||
"{stdout}"
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,95 @@
|
||||
//! mod helpers;
|
||||
//! ```
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! unimplemented_sandbox_template_rpcs {
|
||||
() => {
|
||||
fn create_sandbox_template<'life0, 'async_trait>(
|
||||
&'life0 self,
|
||||
_request: tonic::Request<openshell_core::proto::CreateSandboxTemplateRequest>,
|
||||
) -> std::pin::Pin<
|
||||
Box<
|
||||
dyn std::future::Future<
|
||||
Output = Result<
|
||||
tonic::Response<openshell_core::proto::SandboxTemplateResponse>,
|
||||
tonic::Status,
|
||||
>,
|
||||
> + Send
|
||||
+ 'async_trait,
|
||||
>,
|
||||
>
|
||||
where
|
||||
'life0: 'async_trait,
|
||||
Self: 'async_trait,
|
||||
{
|
||||
Box::pin(async { Err(tonic::Status::unimplemented("unused")) })
|
||||
}
|
||||
|
||||
fn get_sandbox_template<'life0, 'async_trait>(
|
||||
&'life0 self,
|
||||
_request: tonic::Request<openshell_core::proto::GetSandboxTemplateRequest>,
|
||||
) -> std::pin::Pin<
|
||||
Box<
|
||||
dyn std::future::Future<
|
||||
Output = Result<
|
||||
tonic::Response<openshell_core::proto::SandboxTemplateResponse>,
|
||||
tonic::Status,
|
||||
>,
|
||||
> + Send
|
||||
+ 'async_trait,
|
||||
>,
|
||||
>
|
||||
where
|
||||
'life0: 'async_trait,
|
||||
Self: 'async_trait,
|
||||
{
|
||||
Box::pin(async { Err(tonic::Status::unimplemented("unused")) })
|
||||
}
|
||||
|
||||
fn list_sandbox_templates<'life0, 'async_trait>(
|
||||
&'life0 self,
|
||||
_request: tonic::Request<openshell_core::proto::ListSandboxTemplatesRequest>,
|
||||
) -> std::pin::Pin<
|
||||
Box<
|
||||
dyn std::future::Future<
|
||||
Output = Result<
|
||||
tonic::Response<openshell_core::proto::ListSandboxTemplatesResponse>,
|
||||
tonic::Status,
|
||||
>,
|
||||
> + Send
|
||||
+ 'async_trait,
|
||||
>,
|
||||
>
|
||||
where
|
||||
'life0: 'async_trait,
|
||||
Self: 'async_trait,
|
||||
{
|
||||
Box::pin(async { Err(tonic::Status::unimplemented("unused")) })
|
||||
}
|
||||
|
||||
fn delete_sandbox_template<'life0, 'async_trait>(
|
||||
&'life0 self,
|
||||
_request: tonic::Request<openshell_core::proto::DeleteSandboxTemplateRequest>,
|
||||
) -> std::pin::Pin<
|
||||
Box<
|
||||
dyn std::future::Future<
|
||||
Output = Result<
|
||||
tonic::Response<openshell_core::proto::DeleteSandboxTemplateResponse>,
|
||||
tonic::Status,
|
||||
>,
|
||||
> + Send
|
||||
+ 'async_trait,
|
||||
>,
|
||||
>
|
||||
where
|
||||
'life0: 'async_trait,
|
||||
Self: 'async_trait,
|
||||
{
|
||||
Box::pin(async { Err(tonic::Status::unimplemented("unused")) })
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
use rcgen::{
|
||||
BasicConstraints, Certificate, CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair,
|
||||
};
|
||||
@@ -71,14 +160,6 @@ impl Drop for EnvVarGuard {
|
||||
|
||||
// ── TLS helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Install the `rustls` ring crypto provider as the process default.
|
||||
///
|
||||
/// Safe to call multiple times — subsequent calls are no-ops.
|
||||
#[allow(dead_code)]
|
||||
pub fn install_rustls_provider() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
}
|
||||
|
||||
/// Generate a self-signed CA certificate and its key pair.
|
||||
#[allow(dead_code)]
|
||||
pub fn build_ca() -> (Certificate, KeyPair) {
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
|
||||
mod helpers;
|
||||
|
||||
use helpers::{
|
||||
EnvVarGuard, build_ca, build_client_cert, build_server_cert, install_rustls_provider,
|
||||
};
|
||||
use helpers::{EnvVarGuard, build_ca, build_client_cert, build_server_cert};
|
||||
use openshell_bootstrap::{get_gateway_metadata, load_active_gateway};
|
||||
use openshell_cli::{
|
||||
run,
|
||||
@@ -34,6 +32,13 @@ struct TestOpenShell;
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl OpenShell for TestOpenShell {
|
||||
async fn begin_rootfs_tar_staging(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::BeginRootfsTarStagingRequest>,
|
||||
) -> Result<Response<openshell_core::proto::BeginRootfsTarStagingResponse>, Status> {
|
||||
Err(Status::unimplemented("not used by this test server"))
|
||||
}
|
||||
|
||||
async fn report_main_process_exit(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::ReportMainProcessExitRequest>,
|
||||
@@ -113,6 +118,8 @@ impl OpenShell for TestOpenShell {
|
||||
))
|
||||
}
|
||||
|
||||
unimplemented_sandbox_template_rpcs!();
|
||||
|
||||
async fn list_sandbox_providers(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::ListSandboxProvidersRequest>,
|
||||
@@ -642,8 +649,6 @@ fn isolated_gateway_add_env(
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_add_mtls_loopback_uses_explicit_gateway_name() {
|
||||
install_rustls_provider();
|
||||
|
||||
let (ca, ca_key) = build_ca();
|
||||
let (server_cert, server_key) = build_server_cert(&ca, &ca_key);
|
||||
let (client_cert, client_key) = build_client_cert(&ca, &ca_key);
|
||||
@@ -686,8 +691,6 @@ async fn gateway_add_mtls_loopback_uses_explicit_gateway_name() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_add_mtls_loopback_without_name_uses_openshell_default() {
|
||||
install_rustls_provider();
|
||||
|
||||
let (ca, ca_key) = build_ca();
|
||||
let (server_cert, server_key) = build_server_cert(&ca, &ca_key);
|
||||
let (client_cert, client_key) = build_client_cert(&ca, &ca_key);
|
||||
@@ -729,8 +732,6 @@ async fn gateway_add_mtls_loopback_without_name_uses_openshell_default() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_add_mtls_loopback_explicit_name_does_not_fallback_to_openshell_certs() {
|
||||
install_rustls_provider();
|
||||
|
||||
let (ca, ca_key) = build_ca();
|
||||
let (client_cert, client_key) = build_client_cert(&ca, &ca_key);
|
||||
let ca_cert = ca.pem();
|
||||
@@ -768,7 +769,6 @@ async fn gateway_add_mtls_loopback_explicit_name_does_not_fallback_to_openshell_
|
||||
#[tokio::test]
|
||||
async fn cli_connects_with_client_cert() {
|
||||
let _env = EnvVarGuard::set(&[]);
|
||||
install_rustls_provider();
|
||||
|
||||
let (ca, ca_key) = build_ca();
|
||||
let (server_cert, server_key) = build_server_cert(&ca, &ca_key);
|
||||
@@ -794,8 +794,6 @@ async fn cli_connects_with_client_cert() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn cli_requires_client_cert_for_https() {
|
||||
install_rustls_provider();
|
||||
|
||||
let (ca, ca_key) = build_ca();
|
||||
let (server_cert, server_key) = build_server_cert(&ca, &ca_key);
|
||||
let ca_cert = ca.pem();
|
||||
@@ -842,7 +840,6 @@ async fn run_server_no_client_auth(
|
||||
#[tokio::test]
|
||||
async fn cli_connects_with_gateway_insecure() {
|
||||
let _env = EnvVarGuard::set(&[]);
|
||||
install_rustls_provider();
|
||||
|
||||
let (ca, ca_key) = build_ca();
|
||||
let (server_cert, server_key) = build_server_cert(&ca, &ca_key);
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
|
||||
mod helpers;
|
||||
|
||||
use helpers::{
|
||||
EnvVarGuard, build_ca, build_client_cert, build_server_cert, install_rustls_provider,
|
||||
};
|
||||
use helpers::{EnvVarGuard, build_ca, build_client_cert, build_server_cert};
|
||||
use openshell_cli::run;
|
||||
use openshell_cli::tls::TlsOptions;
|
||||
use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer};
|
||||
@@ -23,11 +21,12 @@ use openshell_core::proto::{
|
||||
HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse,
|
||||
ListSandboxProvidersRequest, ListSandboxProvidersResponse, ListSandboxesRequest,
|
||||
ListSandboxesResponse, Provider, ProviderCredentialRefresh, ProviderCredentialRefreshStatus,
|
||||
ProviderCredentialRefreshStrategy, ProviderProfile, ProviderProfileCredential,
|
||||
ProviderProfileDiscovery, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse,
|
||||
RotateProviderCredentialRequest, RotateProviderCredentialResponse, Sandbox, SandboxResponse,
|
||||
SandboxStreamEvent, ServiceStatus, SettingValue, SupervisorMessage, UpdateProviderRequest,
|
||||
WatchSandboxRequest, setting_value,
|
||||
ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrant,
|
||||
ProviderCredentialTokenGrantSubjectToken, ProviderCredentialTokenGrantType, ProviderProfile,
|
||||
ProviderProfileCredential, ProviderProfileDiscovery, ProviderResponse, RevokeSshSessionRequest,
|
||||
RevokeSshSessionResponse, RotateProviderCredentialRequest, RotateProviderCredentialResponse,
|
||||
Sandbox, SandboxResponse, SandboxStreamEvent, ServiceStatus, SettingValue, SupervisorMessage,
|
||||
UpdateProviderRequest, WatchSandboxRequest,
|
||||
};
|
||||
use openshell_core::{ObjectId, ObjectName};
|
||||
use std::collections::HashMap;
|
||||
@@ -44,14 +43,17 @@ use tonic::{Response, Status};
|
||||
struct ProviderState {
|
||||
providers: Arc<Mutex<HashMap<String, Provider>>>,
|
||||
profiles: Arc<Mutex<HashMap<String, ProviderProfile>>>,
|
||||
scoped_profiles: Arc<Mutex<HashMap<(String, String), ProviderProfile>>>,
|
||||
refresh_statuses: Arc<Mutex<HashMap<(String, String), ProviderCredentialRefreshStatus>>>,
|
||||
refresh_requests: Arc<Mutex<Vec<ProviderRefreshRequestLog>>>,
|
||||
provider_update_requests: Arc<Mutex<Vec<Provider>>>,
|
||||
deny_provider_reads: Arc<AtomicBool>,
|
||||
delete_provider_requests: Arc<Mutex<Vec<String>>>,
|
||||
delete_provider_profile_requests: Arc<Mutex<Vec<String>>>,
|
||||
fail_configure_refresh_message: Arc<Mutex<Option<String>>>,
|
||||
fail_rotate_refresh_message: Arc<Mutex<Option<String>>>,
|
||||
fail_delete_provider_message: Arc<Mutex<Option<String>>>,
|
||||
fail_delete_provider_profile_message: Arc<Mutex<Option<String>>>,
|
||||
sandbox_providers: Arc<Mutex<HashMap<String, Vec<String>>>>,
|
||||
sandbox_provider_requests: Arc<Mutex<Vec<SandboxProviderRequestLog>>>,
|
||||
global_settings: Arc<Mutex<HashMap<String, SettingValue>>>,
|
||||
@@ -102,6 +104,13 @@ struct TestOpenShell {
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl OpenShell for TestOpenShell {
|
||||
async fn begin_rootfs_tar_staging(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::BeginRootfsTarStagingRequest>,
|
||||
) -> Result<Response<openshell_core::proto::BeginRootfsTarStagingResponse>, Status> {
|
||||
Err(Status::unimplemented("not used by this test server"))
|
||||
}
|
||||
|
||||
async fn report_main_process_exit(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::ReportMainProcessExitRequest>,
|
||||
@@ -181,6 +190,7 @@ impl OpenShell for TestOpenShell {
|
||||
}),
|
||||
spec: None,
|
||||
status: None,
|
||||
..Sandbox::default()
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -192,6 +202,8 @@ impl OpenShell for TestOpenShell {
|
||||
Ok(Response::new(ListSandboxesResponse::default()))
|
||||
}
|
||||
|
||||
unimplemented_sandbox_template_rpcs!();
|
||||
|
||||
async fn list_sandbox_providers(
|
||||
&self,
|
||||
request: tonic::Request<ListSandboxProvidersRequest>,
|
||||
@@ -489,8 +501,18 @@ impl OpenShell for TestOpenShell {
|
||||
&self,
|
||||
request: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
|
||||
) -> Result<Response<openshell_core::proto::ProviderProfileResponse>, Status> {
|
||||
let id = request.into_inner().id;
|
||||
let profile = if let Some(profile) = openshell_providers::builtin_profiles()
|
||||
let request = request.into_inner();
|
||||
let id = request.id;
|
||||
let scoped_profile = self
|
||||
.state
|
||||
.scoped_profiles
|
||||
.lock()
|
||||
.await
|
||||
.get(&(request.workspace, id.clone()))
|
||||
.cloned();
|
||||
let profile = if let Some(profile) = scoped_profile {
|
||||
profile
|
||||
} else if let Some(profile) = openshell_providers::builtin_profiles()
|
||||
.iter()
|
||||
.find(|profile| profile.id == id)
|
||||
{
|
||||
@@ -876,6 +898,20 @@ impl OpenShell for TestOpenShell {
|
||||
request: tonic::Request<openshell_core::proto::DeleteProviderProfileRequest>,
|
||||
) -> Result<Response<openshell_core::proto::DeleteProviderProfileResponse>, Status> {
|
||||
let id = request.into_inner().id;
|
||||
self.state
|
||||
.delete_provider_profile_requests
|
||||
.lock()
|
||||
.await
|
||||
.push(id.clone());
|
||||
let delete_failure = self
|
||||
.state
|
||||
.fail_delete_provider_profile_message
|
||||
.lock()
|
||||
.await
|
||||
.take();
|
||||
if let Some(message) = delete_failure {
|
||||
return Err(Status::internal(message));
|
||||
}
|
||||
let deleted = self.state.profiles.lock().await.remove(&id).is_some();
|
||||
Ok(Response::new(
|
||||
openshell_core::proto::DeleteProviderProfileResponse { deleted },
|
||||
@@ -1124,8 +1160,6 @@ struct TestServer {
|
||||
}
|
||||
|
||||
async fn run_server() -> TestServer {
|
||||
install_rustls_provider();
|
||||
|
||||
let (ca, ca_key) = build_ca();
|
||||
let (server_cert, server_key) = build_server_cert(&ca, &ca_key);
|
||||
let (client_cert, client_key) = build_client_cert(&ca, &ca_key);
|
||||
@@ -1173,15 +1207,132 @@ async fn run_server() -> TestServer {
|
||||
}
|
||||
}
|
||||
|
||||
async fn enable_providers_v2(ts: &TestServer) {
|
||||
ts.state.global_settings.lock().await.insert(
|
||||
openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(),
|
||||
SettingValue {
|
||||
value: Some(setting_value::Value::BoolValue(true)),
|
||||
async fn install_test_profile(ts: &TestServer, id: &str, credential_key: &str) {
|
||||
ts.state.profiles.lock().await.insert(
|
||||
id.to_string(),
|
||||
ProviderProfile {
|
||||
id: id.to_string(),
|
||||
display_name: id.to_string(),
|
||||
credentials: vec![ProviderProfileCredential {
|
||||
name: "access_token".to_string(),
|
||||
env_vars: vec![credential_key.to_string()],
|
||||
required: true,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// A readable provider must carry its stored type and profile workspace into
|
||||
/// the update request. Policy interceptors evaluate the request before the
|
||||
/// gateway merges it with stored state, so an update that omits them cannot be
|
||||
/// authorized against the profile that owns the provider.
|
||||
///
|
||||
/// The stored `profile_workspace` is forwarded verbatim rather than recomputed
|
||||
/// from the request workspace. The gateway treats it as immutable, so deriving
|
||||
/// it here would look like a change and be rejected.
|
||||
#[tokio::test]
|
||||
async fn provider_update_preserves_stored_type_and_profile_workspace_when_readable() {
|
||||
let ts = run_server().await;
|
||||
|
||||
run::provider_create(
|
||||
&ts.endpoint,
|
||||
"my-claude",
|
||||
"claude",
|
||||
false,
|
||||
&["API_KEY=abc".to_string()],
|
||||
false,
|
||||
&[],
|
||||
"default",
|
||||
&ts.tls,
|
||||
)
|
||||
.await
|
||||
.expect("provider create");
|
||||
|
||||
run::provider_update(run::ProviderUpdateOptions {
|
||||
server: &ts.endpoint,
|
||||
name: "my-claude",
|
||||
from_existing: false,
|
||||
from_oidc_token: false,
|
||||
credentials: &["API_KEY=rotated".to_string()],
|
||||
config: &[],
|
||||
credential_expires_at: &[],
|
||||
workspace: "default",
|
||||
tls: &ts.tls,
|
||||
})
|
||||
.await
|
||||
.expect("provider update");
|
||||
|
||||
let requests = ts.state.provider_update_requests.lock().await;
|
||||
let request = requests.last().expect("provider update request");
|
||||
// `claude` normalizes to the canonical `claude-code` at creation, so the
|
||||
// update carries the stored type rather than the alias the caller typed.
|
||||
assert_eq!(request.r#type, "claude-code");
|
||||
// Forwarded verbatim rather than recomputed. The gateway treats
|
||||
// profile_workspace as immutable, so any substitution here would look like
|
||||
// a change and be rejected.
|
||||
let stored = ts.state.providers.lock().await;
|
||||
let stored = stored.get("my-claude").expect("stored provider");
|
||||
assert_eq!(request.profile_workspace, stored.profile_workspace);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_delete_continues_after_entry_failure() {
|
||||
let ts = run_server().await;
|
||||
*ts.state.fail_delete_provider_message.lock().await =
|
||||
Some("simulated provider delete failure".to_string());
|
||||
|
||||
let err = run::provider_delete(
|
||||
&ts.endpoint,
|
||||
&["failing-provider".to_string(), "later-provider".to_string()],
|
||||
"default",
|
||||
&ts.tls,
|
||||
)
|
||||
.await
|
||||
.expect_err("provider delete should report aggregate failure");
|
||||
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("failed to delete 1 provider: failing-provider"),
|
||||
"unexpected error: {msg}"
|
||||
);
|
||||
assert_eq!(
|
||||
ts.state.delete_provider_requests.lock().await.clone(),
|
||||
vec!["failing-provider".to_string(), "later-provider".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_profile_delete_continues_after_entry_failure() {
|
||||
let ts = run_server().await;
|
||||
*ts.state.fail_delete_provider_profile_message.lock().await =
|
||||
Some("simulated provider profile delete failure".to_string());
|
||||
|
||||
let err = run::provider_profile_delete(
|
||||
&ts.endpoint,
|
||||
&["failing-profile".to_string(), "later-profile".to_string()],
|
||||
"default",
|
||||
&ts.tls,
|
||||
)
|
||||
.await
|
||||
.expect_err("provider profile delete should report aggregate failure");
|
||||
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("failed to delete 1 provider profile: failing-profile"),
|
||||
"unexpected error: {msg}"
|
||||
);
|
||||
assert_eq!(
|
||||
ts.state
|
||||
.delete_provider_profile_requests
|
||||
.lock()
|
||||
.await
|
||||
.clone(),
|
||||
vec!["failing-profile".to_string(), "later-profile".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_cli_run_functions_support_full_crud_flow() {
|
||||
let ts = run_server().await;
|
||||
@@ -1362,11 +1513,12 @@ async fn provider_list_json_empty() {
|
||||
#[tokio::test]
|
||||
async fn provider_refresh_cli_run_functions_wire_requests() {
|
||||
let ts = run_server().await;
|
||||
install_test_profile(&ts, "custom-graph", "MS_GRAPH_ACCESS_TOKEN").await;
|
||||
|
||||
run::provider_create(
|
||||
&ts.endpoint,
|
||||
"my-graph",
|
||||
"outlook",
|
||||
"custom-graph",
|
||||
false,
|
||||
&["MS_GRAPH_ACCESS_TOKEN=token".to_string()],
|
||||
false,
|
||||
@@ -1451,11 +1603,12 @@ async fn provider_refresh_cli_run_functions_wire_requests() {
|
||||
#[tokio::test]
|
||||
async fn provider_refresh_configure_reads_secret_material_from_env_off_argv() {
|
||||
let ts = run_server().await;
|
||||
install_test_profile(&ts, "custom-chat", "GOOGLE_CHAT_ACCESS_TOKEN").await;
|
||||
|
||||
run::provider_create(
|
||||
&ts.endpoint,
|
||||
"gc-bridge",
|
||||
"outlook",
|
||||
"custom-chat",
|
||||
false,
|
||||
&["GOOGLE_CHAT_ACCESS_TOKEN=pending".to_string()],
|
||||
false,
|
||||
@@ -1606,7 +1759,7 @@ async fn provider_create_allows_empty_credentials_for_gateway_refresh_profiles()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_create_requires_runtime_credentials_for_empty_gateway_refresh_profiles() {
|
||||
async fn provider_create_allows_no_source_for_runtime_resolved_profiles() {
|
||||
let ts = run_server().await;
|
||||
ts.state.profiles.lock().await.insert(
|
||||
"custom-refresh".to_string(),
|
||||
@@ -1626,7 +1779,7 @@ async fn provider_create_requires_runtime_credentials_for_empty_gateway_refresh_
|
||||
},
|
||||
);
|
||||
|
||||
let err = run::provider_create(
|
||||
run::provider_create(
|
||||
&ts.endpoint,
|
||||
"custom-refresh-provider",
|
||||
"custom-refresh",
|
||||
@@ -1638,11 +1791,10 @@ async fn provider_create_requires_runtime_credentials_for_empty_gateway_refresh_
|
||||
&ts.tls,
|
||||
)
|
||||
.await
|
||||
.expect_err("empty runtime-resolved providers should require an explicit source");
|
||||
.expect("runtime-resolved provider should not require a credential source");
|
||||
|
||||
assert!(err.to_string().contains("--runtime-credentials"));
|
||||
assert!(
|
||||
!ts.state
|
||||
ts.state
|
||||
.providers
|
||||
.lock()
|
||||
.await
|
||||
@@ -1650,6 +1802,36 @@ async fn provider_create_requires_runtime_credentials_for_empty_gateway_refresh_
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_create_allows_credentialless_policy_profile() {
|
||||
let ts = run_server().await;
|
||||
|
||||
run::provider_create(
|
||||
&ts.endpoint,
|
||||
"pypi",
|
||||
"pypi",
|
||||
false,
|
||||
&[],
|
||||
false,
|
||||
&[],
|
||||
"default",
|
||||
&ts.tls,
|
||||
)
|
||||
.await
|
||||
.expect("credential-less provider create");
|
||||
|
||||
let provider = ts
|
||||
.state
|
||||
.providers
|
||||
.lock()
|
||||
.await
|
||||
.get("pypi")
|
||||
.cloned()
|
||||
.expect("pypi provider");
|
||||
assert!(provider.credentials.is_empty());
|
||||
assert_eq!(provider.r#type, "pypi");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sandbox_provider_cli_run_functions_wire_requests_and_idempotent_results() {
|
||||
let ts = run_server().await;
|
||||
@@ -1686,7 +1868,7 @@ async fn sandbox_provider_cli_run_functions_wire_requests_and_idempotent_results
|
||||
)
|
||||
.await
|
||||
.expect("sandbox provider attach is idempotent");
|
||||
run::sandbox_provider_list(&ts.endpoint, "dev-sandbox", "default", &ts.tls)
|
||||
run::sandbox_provider_list(&ts.endpoint, "dev-sandbox", "table", "default", &ts.tls)
|
||||
.await
|
||||
.expect("sandbox provider list");
|
||||
run::sandbox_provider_detach(
|
||||
@@ -1858,6 +2040,21 @@ binaries: [/usr/bin/custom]
|
||||
.expect("custom provider should be stored");
|
||||
assert_eq!(provider.r#type, "custom-api");
|
||||
|
||||
let mut custom_alt_profile = ts
|
||||
.state
|
||||
.profiles
|
||||
.lock()
|
||||
.await
|
||||
.get("custom-api")
|
||||
.cloned()
|
||||
.expect("custom-api profile should be stored");
|
||||
custom_alt_profile.id = "custom-alt".to_string();
|
||||
ts.state
|
||||
.profiles
|
||||
.lock()
|
||||
.await
|
||||
.insert("custom-alt".to_string(), custom_alt_profile);
|
||||
|
||||
run::provider_delete(
|
||||
&ts.endpoint,
|
||||
&["custom-provider".to_string()],
|
||||
@@ -1866,15 +2063,22 @@ binaries: [/usr/bin/custom]
|
||||
)
|
||||
.await
|
||||
.expect("custom provider delete");
|
||||
run::provider_profile_delete(&ts.endpoint, "custom-api", "default", &ts.tls)
|
||||
.await
|
||||
.expect("profile delete");
|
||||
run::provider_profile_delete(
|
||||
&ts.endpoint,
|
||||
&["custom-api".to_string(), "custom-alt".to_string()],
|
||||
"default",
|
||||
&ts.tls,
|
||||
)
|
||||
.await
|
||||
.expect("profile delete");
|
||||
let profiles = ts.state.profiles.lock().await;
|
||||
assert!(!profiles.contains_key("custom-api"));
|
||||
assert!(!profiles.contains_key("custom-alt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_create_from_existing_uses_profile_discovery_when_v2_enabled() {
|
||||
async fn provider_create_from_existing_uses_profile_discovery() {
|
||||
let ts = run_server().await;
|
||||
enable_providers_v2(&ts).await;
|
||||
ts.state.profiles.lock().await.insert(
|
||||
"custom-discovery".to_string(),
|
||||
ProviderProfile {
|
||||
@@ -1924,7 +2128,7 @@ async fn provider_create_from_existing_uses_profile_discovery_when_v2_enabled()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_create_from_existing_uses_registry_discovery_when_v2_disabled() {
|
||||
async fn provider_create_from_existing_uses_builtin_profile_discovery() {
|
||||
let ts = run_server().await;
|
||||
let _env = EnvVarGuard::set(&[("OPENAI_API_KEY", "legacy-openai-secret")]);
|
||||
|
||||
@@ -1958,9 +2162,8 @@ async fn provider_create_from_existing_uses_registry_discovery_when_v2_disabled(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_create_from_existing_vertex_discovers_credentials_and_config_when_v2_enabled() {
|
||||
async fn provider_create_from_existing_vertex_discovers_credentials_and_config() {
|
||||
let ts = run_server().await;
|
||||
enable_providers_v2(&ts).await;
|
||||
let _env = EnvVarGuard::set(&[
|
||||
("VERTEX_AI_TOKEN", "ya29.vertex-v2-fallback"),
|
||||
("VERTEX_AI_PROJECT_ID", "vertex-v2-project"),
|
||||
@@ -2021,9 +2224,8 @@ async fn provider_create_from_existing_vertex_discovers_credentials_and_config_w
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_create_from_existing_requires_profile_when_v2_enabled() {
|
||||
async fn provider_create_from_existing_requires_profile() {
|
||||
let ts = run_server().await;
|
||||
enable_providers_v2(&ts).await;
|
||||
// Use "generic" which is a normalised type but has no built-in provider
|
||||
// profile, so v2 profile-based discovery fails with the expected message.
|
||||
let _env = EnvVarGuard::set(&[("GENERIC_API_KEY", "some-secret")]);
|
||||
@@ -2044,7 +2246,7 @@ async fn provider_create_from_existing_requires_profile_when_v2_enabled() {
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("providers v2 discovery requires a provider profile"),
|
||||
.contains("import a matching profile before using this provider type"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
assert!(!ts.state.providers.lock().await.contains_key("v2-generic"));
|
||||
@@ -2053,7 +2255,6 @@ async fn provider_create_from_existing_requires_profile_when_v2_enabled() {
|
||||
#[tokio::test]
|
||||
async fn provider_create_from_existing_fails_when_profile_discovery_finds_nothing() {
|
||||
let ts = run_server().await;
|
||||
enable_providers_v2(&ts).await;
|
||||
ts.state.profiles.lock().await.insert(
|
||||
"empty-discovery".to_string(),
|
||||
ProviderProfile {
|
||||
@@ -2101,9 +2302,8 @@ async fn provider_create_from_existing_fails_when_profile_discovery_finds_nothin
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_update_from_existing_uses_profile_discovery_when_v2_enabled() {
|
||||
async fn provider_update_from_existing_uses_profile_discovery() {
|
||||
let ts = run_server().await;
|
||||
enable_providers_v2(&ts).await;
|
||||
ts.state.profiles.lock().await.insert(
|
||||
"custom-update-discovery".to_string(),
|
||||
ProviderProfile {
|
||||
@@ -2167,6 +2367,147 @@ async fn provider_update_from_existing_uses_profile_discovery_when_v2_enabled()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_update_from_existing_preserves_global_profile_scope() {
|
||||
let ts = run_server().await;
|
||||
let profile_id = "shadowed-update-discovery";
|
||||
for (profile_workspace, env_var) in [
|
||||
("", "GLOBAL_UPDATE_DISCOVERY_API_KEY"),
|
||||
("default", "WORKSPACE_UPDATE_DISCOVERY_API_KEY"),
|
||||
] {
|
||||
ts.state.scoped_profiles.lock().await.insert(
|
||||
(profile_workspace.to_string(), profile_id.to_string()),
|
||||
ProviderProfile {
|
||||
id: profile_id.to_string(),
|
||||
credentials: vec![ProviderProfileCredential {
|
||||
name: "api_key".to_string(),
|
||||
env_vars: vec![env_var.to_string()],
|
||||
required: true,
|
||||
..Default::default()
|
||||
}],
|
||||
discovery: Some(ProviderProfileDiscovery {
|
||||
credentials: vec!["api_key".to_string()],
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
ts.state.providers.lock().await.insert(
|
||||
"global-update".to_string(),
|
||||
Provider {
|
||||
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
||||
id: "id-global-update".to_string(),
|
||||
name: "global-update".to_string(),
|
||||
workspace: "default".to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
r#type: profile_id.to_string(),
|
||||
profile_workspace: String::new(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let _env = EnvVarGuard::set(&[
|
||||
("GLOBAL_UPDATE_DISCOVERY_API_KEY", "global-secret"),
|
||||
("WORKSPACE_UPDATE_DISCOVERY_API_KEY", "workspace-secret"),
|
||||
]);
|
||||
|
||||
run::provider_update(run::ProviderUpdateOptions {
|
||||
server: &ts.endpoint,
|
||||
name: "global-update",
|
||||
from_existing: true,
|
||||
from_oidc_token: false,
|
||||
credentials: &[],
|
||||
config: &[],
|
||||
credential_expires_at: &[],
|
||||
workspace: "default",
|
||||
tls: &ts.tls,
|
||||
})
|
||||
.await
|
||||
.expect("global profile-backed provider update --from-existing");
|
||||
|
||||
let provider = ts
|
||||
.state
|
||||
.providers
|
||||
.lock()
|
||||
.await
|
||||
.get("global-update")
|
||||
.cloned()
|
||||
.expect("global provider should still be stored");
|
||||
assert_eq!(
|
||||
provider.credentials.get("GLOBAL_UPDATE_DISCOVERY_API_KEY"),
|
||||
Some(&"global-secret".to_string())
|
||||
);
|
||||
assert!(
|
||||
!provider
|
||||
.credentials
|
||||
.contains_key("WORKSPACE_UPDATE_DISCOVERY_API_KEY")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_update_from_oidc_token_preserves_global_profile_scope() {
|
||||
let ts = run_server().await;
|
||||
let profile_id = "shadowed-oidc-update";
|
||||
for (profile_workspace, subject_credential) in [
|
||||
("", "GLOBAL_SUBJECT_TOKEN"),
|
||||
("default", "WORKSPACE_SUBJECT_TOKEN"),
|
||||
] {
|
||||
ts.state.scoped_profiles.lock().await.insert(
|
||||
(profile_workspace.to_string(), profile_id.to_string()),
|
||||
ProviderProfile {
|
||||
id: profile_id.to_string(),
|
||||
credentials: vec![ProviderProfileCredential {
|
||||
name: "dynamic_token".to_string(),
|
||||
token_grant: Some(ProviderCredentialTokenGrant {
|
||||
grant_type: ProviderCredentialTokenGrantType::TokenExchange as i32,
|
||||
subject_token: Some(ProviderCredentialTokenGrantSubjectToken {
|
||||
source: "provider_credential".to_string(),
|
||||
credential: subject_credential.to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
ts.state.providers.lock().await.insert(
|
||||
"global-oidc-update".to_string(),
|
||||
Provider {
|
||||
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
||||
id: "id-global-oidc-update".to_string(),
|
||||
name: "global-oidc-update".to_string(),
|
||||
workspace: "default".to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
r#type: profile_id.to_string(),
|
||||
profile_workspace: String::new(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let err = run::provider_update(run::ProviderUpdateOptions {
|
||||
server: &ts.endpoint,
|
||||
name: "global-oidc-update",
|
||||
from_existing: false,
|
||||
from_oidc_token: true,
|
||||
credentials: &["GLOBAL_SUBJECT_TOKEN".to_string()],
|
||||
config: &[],
|
||||
credential_expires_at: &[],
|
||||
workspace: "default",
|
||||
tls: &ts.tls,
|
||||
})
|
||||
.await
|
||||
.expect_err("unnamed test gateway should stop after profile validation");
|
||||
|
||||
assert!(
|
||||
err.to_string().contains("active named OIDC gateway"),
|
||||
"global profile should accept GLOBAL_SUBJECT_TOKEN before OIDC loading: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_profile_import_from_directory_imports_supported_profile_files() {
|
||||
let ts = run_server().await;
|
||||
@@ -2363,11 +2704,11 @@ async fn provider_create_rejects_key_only_credentials_without_local_env_value()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_create_supports_generic_type_and_env_lookup_credentials() {
|
||||
async fn provider_create_rejects_profileless_generic_type() {
|
||||
let ts = run_server().await;
|
||||
let _guard = EnvVarGuard::set(&[("NAV_GENERIC_TEST_KEY", "generic-value")]);
|
||||
|
||||
run::provider_create(
|
||||
let err = run::provider_create(
|
||||
&ts.endpoint,
|
||||
"my-generic",
|
||||
"generic",
|
||||
@@ -2379,24 +2720,12 @@ async fn provider_create_supports_generic_type_and_env_lookup_credentials() {
|
||||
&ts.tls,
|
||||
)
|
||||
.await
|
||||
.expect("provider create");
|
||||
.expect_err("profileless generic provider creation should fail");
|
||||
|
||||
let mut client = openshell_cli::tls::grpc_client(&ts.endpoint, &ts.tls)
|
||||
.await
|
||||
.expect("grpc client should connect");
|
||||
let response = client
|
||||
.get_provider(GetProviderRequest {
|
||||
name: "my-generic".to_string(),
|
||||
workspace: String::new(),
|
||||
})
|
||||
.await
|
||||
.expect("get provider should succeed")
|
||||
.into_inner();
|
||||
let provider = response.provider.expect("provider should exist");
|
||||
assert_eq!(provider.r#type, "generic");
|
||||
assert_eq!(
|
||||
provider.credentials.get("NAV_GENERIC_TEST_KEY"),
|
||||
Some(&"generic-value".to_string())
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("provider profile 'generic' not found"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2435,6 +2764,34 @@ async fn provider_create_sends_inline_credentials() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_create_prefers_exact_imported_alias_profile() {
|
||||
let ts = run_server().await;
|
||||
install_test_profile(&ts, "gh", "GITHUB_TOKEN").await;
|
||||
|
||||
run::provider_create_with_options(run::ProviderCreateOptions {
|
||||
server: &ts.endpoint,
|
||||
name: "enterprise-github",
|
||||
provider_type: "gh",
|
||||
credentials: &["GITHUB_TOKEN=test-token".to_string()],
|
||||
credential_source: run::ProviderCreateCredentialSource::ExplicitCredentials,
|
||||
config: &[],
|
||||
workspace: "default",
|
||||
profile_workspace: "default",
|
||||
tls: &ts.tls,
|
||||
})
|
||||
.await
|
||||
.expect("create provider from exact imported alias profile");
|
||||
|
||||
let stored = ts.state.providers.lock().await;
|
||||
let provider = stored.get("enterprise-github").expect("provider");
|
||||
assert_eq!(provider.r#type, "gh");
|
||||
assert_eq!(
|
||||
provider.credentials.get("GITHUB_TOKEN").map(String::as_str),
|
||||
Some("test-token")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_create_rejects_combined_from_existing_and_credentials() {
|
||||
let ts = run_server().await;
|
||||
@@ -2515,14 +2872,14 @@ async fn provider_create_rejects_combined_from_gcloud_adc_and_credentials() {
|
||||
#[tokio::test]
|
||||
async fn provider_create_rejects_empty_env_var_for_key_only_credential() {
|
||||
let ts = run_server().await;
|
||||
let _guard = EnvVarGuard::set(&[("NAV_EMPTY_ENV_KEY", "")]);
|
||||
let _guard = EnvVarGuard::set(&[("NVIDIA_API_KEY", "")]);
|
||||
|
||||
let err = run::provider_create(
|
||||
&ts.endpoint,
|
||||
"bad-provider",
|
||||
"generic",
|
||||
"nvidia",
|
||||
false,
|
||||
&["NAV_EMPTY_ENV_KEY".to_string()],
|
||||
&["NVIDIA_API_KEY".to_string()],
|
||||
false,
|
||||
&[],
|
||||
"default",
|
||||
@@ -2533,7 +2890,7 @@ async fn provider_create_rejects_empty_env_var_for_key_only_credential() {
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("requires local env var 'NAV_EMPTY_ENV_KEY' to be set to a non-empty value"),
|
||||
.contains("requires local env var 'NVIDIA_API_KEY' to be set to a non-empty value"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
@@ -2563,7 +2920,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(),
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
||||
})
|
||||
.await
|
||||
.expect("get provider should succeed")
|
||||
@@ -2928,7 +3285,6 @@ async fn provider_create_from_gcloud_adc_rolls_back_provider_when_initial_rotate
|
||||
#[tokio::test]
|
||||
async fn provider_create_from_existing_vertex_config_only_reports_missing_vertex_credentials() {
|
||||
let ts = run_server().await;
|
||||
enable_providers_v2(&ts).await;
|
||||
let _env = EnvVarGuard::set(&[
|
||||
("VERTEX_AI_PROJECT_ID", "vertex-config-only-project"),
|
||||
("VERTEX_AI_REGION", "us-central1"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user