From e3dc0112087b5048259310536f15fbabdc0b78eb Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:36:19 +0000 Subject: [PATCH 001/505] fix(provider): isolate unbound static credentials (#2862) Signed-off-by: Prekshi Vyas --- crates/openshell-server/src/grpc/policy.rs | 85 ++++++++++++++++++++-- 1 file changed, 80 insertions(+), 5 deletions(-) diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index e9650a3560..9ac8a42786 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -3092,7 +3092,28 @@ pub(super) async fn handle_get_sandbox_provider_environment( ) .await?; - if !supports_static_credential_bindings { + if supports_static_credential_bindings { + let unbound_static_keys = provider_environment + .static_credential_keys + .iter() + .filter(|key| { + !provider_environment + .static_credential_bindings + .contains_key(*key) + }) + .cloned() + .collect::>(); + for key in unbound_static_keys { + warn!( + sandbox_id = %sandbox_id, + key = %key, + "withholding unbound static provider credential from binding-capable supervisor" + ); + provider_environment.environment.remove(&key); + provider_environment.credential_expires_at_ms.remove(&key); + provider_environment.static_credential_keys.remove(&key); + } + } else { for key in &provider_environment.static_credential_keys { provider_environment.environment.remove(key); provider_environment.credential_expires_at_ms.remove(key); @@ -9738,6 +9759,60 @@ mod tests { assert!(response.static_credential_bindings.is_empty()); } + #[tokio::test] + async fn provider_environment_withholds_unbound_static_credentials_independently() { + use openshell_core::proto::GetSandboxProviderEnvironmentRequest; + + let state = test_server_state().await; + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + let mut profileless_openai = test_provider("gateway-openai", "openai"); + profileless_openai.credentials = + HashMap::from([("OPENAI_API_KEY".to_string(), "openai-secret".to_string())]); + state.store.put_message(&profileless_openai).await.unwrap(); + state + .store + .put_message(&test_sandbox( + "sb-unbound-provider-env", + "unbound-provider-env", + test_policy_with_rule("sandbox_only", "sandbox.example.com"), + vec!["work-github".to_string(), "gateway-openai".to_string()], + )) + .await + .unwrap(); + + let response = handle_get_sandbox_provider_environment( + &state, + with_user(Request::new(GetSandboxProviderEnvironmentRequest { + sandbox_id: "sb-unbound-provider-env".to_string(), + supports_static_credential_bindings: true, + })), + ) + .await + .unwrap() + .into_inner(); + + assert!(!response.environment.contains_key("OPENAI_API_KEY")); + assert!( + !response + .static_credential_bindings + .contains_key("OPENAI_API_KEY") + ); + assert_eq!( + response.environment.get("GITHUB_TOKEN"), + Some(&"ghp-test".to_string()) + ); + assert!( + response + .static_credential_bindings + .get("GITHUB_TOKEN") + .is_some_and(|binding| !binding.endpoints.is_empty()) + ); + } + #[tokio::test] async fn provider_environment_uses_policy_binding_for_endpointless_profile() { use openshell_core::proto::{ @@ -10031,15 +10106,15 @@ mod tests { .expect("mixed snapshot must be returned") .into_inner(); - assert_eq!( - response.environment.get("INVALID_TOKEN"), - Some(&"static-secret".to_string()) + assert!( + !response.environment.contains_key("INVALID_TOKEN"), + "an unbound static credential must be withheld before the supervisor snapshot" ); assert!( !response .static_credential_bindings .contains_key("INVALID_TOKEN"), - "incomplete static metadata must reach the supervisor for fail-closed rejection" + "an unbound static credential must not emit incomplete binding metadata" ); assert!( !response.dynamic_credentials.is_empty(), From 40d1b4866691be03b5617089739c7f2325487479 Mon Sep 17 00:00:00 2001 From: grs Date: Mon, 24 Aug 2026 05:42:30 +0000 Subject: [PATCH 002/505] feat(provider): support for SPIFFE backed token exchange (#1970) * feat(provider): add ability to request token exchange instead of client credentials as OAuth grant_type Signed-off-by: Gordon Sim * test(proxy): add further tests for token exchange Signed-off-by: Gordon Sim * test(provider): add runnable example for token exchange Signed-off-by: Gordon Sim * test(e2e): cover Podman token exchange grants Signed-off-by: Gordon Sim * refactor(oauth): extract duplicated functionality from server and supervisor Signed-off-by: Gordon Sim * fix(provider): evict nearest-to-expiry entry from intermediate token cache Signed-off-by: Gordon Sim * doc(supervisor): add podman example for token exchange Signed-off-by: Gordon Sim * fix(provider): withhold token-exchange subject credentials Signed-off-by: Gordon Sim --------- Signed-off-by: Gordon Sim --- .../skills/debug-openshell-cluster/SKILL.md | 12 +- Cargo.lock | 277 ++- Cargo.toml | 2 +- architecture/sandbox.md | 29 +- crates/openshell-cli/src/main.rs | 74 +- crates/openshell-cli/src/oidc_auth.rs | 22 +- crates/openshell-cli/src/run.rs | 301 ++- .../tests/ensure_providers_integration.rs | 10 +- .../openshell-cli/tests/mtls_integration.rs | 16 +- .../tests/provider_commands_integration.rs | 104 +- .../sandbox_create_lifecycle_integration.rs | 10 +- .../sandbox_name_fallback_integration.rs | 10 +- crates/openshell-core/Cargo.toml | 4 +- crates/openshell-core/src/driver_utils.rs | 3 + crates/openshell-core/src/grpc_client.rs | 67 +- crates/openshell-core/src/lib.rs | 3 + crates/openshell-core/src/oauth.rs | 766 ++++++ crates/openshell-core/src/spiffe.rs | 177 ++ crates/openshell-driver-podman/src/config.rs | 8 + .../openshell-driver-podman/src/container.rs | 74 + crates/openshell-providers/src/profiles.rs | 456 +++- crates/openshell-sdk/tests/client_mock.rs | 7 + crates/openshell-server/Cargo.toml | 3 +- crates/openshell-server/src/auth/oidc.rs | 6 + .../openshell-server/src/auth/sandbox_jwt.rs | 8 + .../src/auth/sandbox_methods.rs | 3 + crates/openshell-server/src/grpc/mod.rs | 10 +- crates/openshell-server/src/grpc/policy.rs | 122 + crates/openshell-server/src/grpc/provider.rs | 678 +++++- crates/openshell-server/src/lib.rs | 4 + .../openshell-server/src/provider_refresh.rs | 2 + crates/openshell-server/tests/common/mod.rs | 12 +- .../tests/supervisor_relay_integration.rs | 6 + .../openshell-supervisor-network/Cargo.toml | 2 +- .../src/l7/relay.rs | 302 +++ .../src/l7/token_grant_injection.rs | 148 +- .../openshell-supervisor-network/src/lib.rs | 1 - .../openshell-supervisor-network/src/proxy.rs | 159 +- .../src/spiffe_endpoint.rs | 17 - .../src/token_grant.rs | 702 ++---- .../src/process.rs | 217 +- .../openshell-supervisor-process/src/run.rs | 12 +- .../openshell-supervisor-process/src/ssh.rs | 50 +- deploy/helm/openshell/README.md | 19 +- deploy/helm/openshell/README.md.gotmpl | 15 +- .../openshell/templates/_gateway-workload.tpl | 18 +- .../openshell/tests/gateway_config_test.yaml | 36 +- deploy/helm/openshell/values.yaml | 12 +- docs/kubernetes/access-control.mdx | 8 +- docs/reference/gateway-config.mdx | 8 + docs/sandboxes/manage-providers.mdx | 29 + docs/sandboxes/providers-v2.mdx | 57 +- e2e/rust/Cargo.lock | 571 ++++- e2e/rust/Cargo.toml | 10 + e2e/rust/e2e-podman.sh | 7 + e2e/rust/tests/provider_token_exchange.rs | 907 +++++++ e2e/with-podman-gateway.sh | 13 + examples/spiffe-token-exchange-demo/README.md | 232 ++ examples/spiffe-token-exchange-demo/demo.sh | 229 ++ .../k8s/kustomization.yaml | 20 + .../k8s/protected-service.js | 120 + .../k8s/spiffe-helper.conf | 8 + .../k8s/token-issuer.js | 342 +++ .../k8s/workloads.yaml | 230 ++ .../podman/README.md | 316 +++ .../spiffe-token-exchange-demo/podman/demo.sh | 675 ++++++ .../podman/provider-profile.yaml | 58 + .../podman/spire/agent.conf | 37 + .../podman/spire/common.sh | 157 ++ .../podman/spire/oidc-discovery-provider.conf | 19 + .../podman/spire/register-gateway.sh | 40 + .../podman/spire/register-sandbox.sh | 43 + .../podman/spire/server.conf | 39 + .../podman/spire/start-agent.sh | 78 + .../podman/spire/start-server-oidc.sh | 80 + .../podman/start-gateway.sh | 233 ++ .../provider-profile.yaml | 53 + proto/openshell.proto | 60 + .../v1/internal/converter/coverage_test.go | 3 + .../v1/internal/converter/profile.go | 52 + .../v1/internal/converter/profile_test.go | 26 + sdk/go/openshell/v1/types/profile.go | 19 + sdk/go/proto/openshellv1/openshell.pb.go | 2151 ++++++++++------- sdk/go/proto/openshellv1/openshell_grpc.pb.go | 42 + 84 files changed, 10154 insertions(+), 1814 deletions(-) create mode 100644 crates/openshell-core/src/oauth.rs create mode 100644 crates/openshell-core/src/spiffe.rs delete mode 100644 crates/openshell-supervisor-network/src/spiffe_endpoint.rs create mode 100644 e2e/rust/tests/provider_token_exchange.rs create mode 100644 examples/spiffe-token-exchange-demo/README.md create mode 100755 examples/spiffe-token-exchange-demo/demo.sh create mode 100644 examples/spiffe-token-exchange-demo/k8s/kustomization.yaml create mode 100644 examples/spiffe-token-exchange-demo/k8s/protected-service.js create mode 100644 examples/spiffe-token-exchange-demo/k8s/spiffe-helper.conf create mode 100644 examples/spiffe-token-exchange-demo/k8s/token-issuer.js create mode 100644 examples/spiffe-token-exchange-demo/k8s/workloads.yaml create mode 100644 examples/spiffe-token-exchange-demo/podman/README.md create mode 100755 examples/spiffe-token-exchange-demo/podman/demo.sh create mode 100644 examples/spiffe-token-exchange-demo/podman/provider-profile.yaml create mode 100644 examples/spiffe-token-exchange-demo/podman/spire/agent.conf create mode 100755 examples/spiffe-token-exchange-demo/podman/spire/common.sh create mode 100644 examples/spiffe-token-exchange-demo/podman/spire/oidc-discovery-provider.conf create mode 100755 examples/spiffe-token-exchange-demo/podman/spire/register-gateway.sh create mode 100755 examples/spiffe-token-exchange-demo/podman/spire/register-sandbox.sh create mode 100644 examples/spiffe-token-exchange-demo/podman/spire/server.conf create mode 100755 examples/spiffe-token-exchange-demo/podman/spire/start-agent.sh create mode 100755 examples/spiffe-token-exchange-demo/podman/spire/start-server-oidc.sh create mode 100755 examples/spiffe-token-exchange-demo/podman/start-gateway.sh create mode 100644 examples/spiffe-token-exchange-demo/provider-profile.yaml diff --git a/.agents/skills/debug-openshell-cluster/SKILL.md b/.agents/skills/debug-openshell-cluster/SKILL.md index 8e51a73932..3fc53df904 100644 --- a/.agents/skills/debug-openshell-cluster/SKILL.md +++ b/.agents/skills/debug-openshell-cluster/SKILL.md @@ -375,14 +375,20 @@ kubectl -n openshell get statefulset openshell -o jsonpath='{.spec.template.spec If `server.providerTokenGrants.spiffe.enabled=true`, the gateway should still render `[openshell.gateway.gateway_jwt]` and mount the `sandbox-jwt` Secret. -SPIRE is used only by sandbox pods for dynamic provider token grants. Verify -that SPIRE is installed, the CSI driver is available, and the Kubernetes driver -config includes `provider_spiffe_workload_api_socket_path`: +SPIRE is used by both the gateway and sandbox supervisors for dynamic provider +token grants. The gateway pod must mount the `spiffe-workload-api` CSI volume +and set `OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET`; sandbox pods must +receive the matching Workload API socket from the Kubernetes driver config. +The gateway verifies supervisor JWT-SVIDs from JWT bundles fetched through this +Workload API socket, not from the SPIRE OIDC discovery endpoint. +Verify that SPIRE is installed, the CSI driver is available, and the Kubernetes +driver config includes `provider_spiffe_workload_api_socket_path`: ```bash helm -n openshell get values openshell | grep -E 'providerTokenGrants|workloadApiSocketPath' kubectl get pods -A | grep -E 'spire|spiffe' kubectl -n openshell get configmap openshell-config -o yaml | grep provider_spiffe_workload_api_socket_path +kubectl -n openshell get pod -l app.kubernetes.io/name=helm-chart -o jsonpath="{.items[*].spec.containers[*].env[?(@.name==\"OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET\")].value}{\"\n\"}" ``` Sandbox pods using provider token grants should have an diff --git a/Cargo.lock b/Cargo.lock index a9eee33af9..809b0192b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -690,6 +690,12 @@ dependencies = [ "backtrace", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base16ct" version = "1.0.0" @@ -1284,6 +1290,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array 0.14.7", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-bigint" version = "0.7.5" @@ -1328,7 +1346,7 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" dependencies = [ - "crypto-bigint", + "crypto-bigint 0.7.5", "rand_core 0.10.1", ] @@ -1351,6 +1369,22 @@ dependencies = [ "subtle", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + [[package]] name = "curve25519-dalek" version = "5.0.0" @@ -1361,7 +1395,7 @@ dependencies = [ "cpufeatures 0.3.0", "curve25519-dalek-derive", "digest 0.11.2", - "fiat-crypto", + "fiat-crypto 0.3.0", "rand_core 0.10.1", "rustc_version", "subtle", @@ -1611,6 +1645,20 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "signature 2.2.0", + "spki 0.7.3", +] + [[package]] name = "ecdsa" version = "0.17.0" @@ -1619,13 +1667,23 @@ checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ "der 0.8.0", "digest 0.11.2", - "elliptic-curve", - "rfc6979", + "elliptic-curve 0.14.1", + "rfc6979 0.6.0", "signature 3.0.0", "spki 0.8.0", "zeroize", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + [[package]] name = "ed25519" version = "3.0.0" @@ -1636,14 +1694,28 @@ dependencies = [ "signature 3.0.0", ] +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "ed25519-dalek" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ - "curve25519-dalek", - "ed25519", + "curve25519-dalek 5.0.0", + "ed25519 3.0.0", "rand_core 0.10.1", "serde", "sha2 0.11.0", @@ -1661,24 +1733,45 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct 0.2.0", + "crypto-bigint 0.5.5", + "digest 0.10.7", + "ff 0.13.1", + "generic-array 0.14.7", + "group 0.13.0", + "hkdf 0.12.4", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1 0.7.3", + "subtle", + "zeroize", +] + [[package]] name = "elliptic-curve" version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" dependencies = [ - "base16ct", - "crypto-bigint", + "base16ct 1.0.0", + "crypto-bigint 0.7.5", "crypto-common 0.2.2", "digest 0.11.2", - "ff", - "group", + "ff 0.14.0", + "group 0.14.0", "hkdf 0.13.0", "hybrid-array", "pem-rfc7468 1.0.0", "pkcs8 0.11.0", "rand_core 0.10.1", - "sec1", + "sec1 0.8.1", "subtle", "zeroize", ] @@ -1765,6 +1858,16 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "ff" version = "0.14.0" @@ -1775,6 +1878,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "fiat-crypto" version = "0.3.0" @@ -1974,6 +2083,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2075,13 +2185,24 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff 0.13.1", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "group" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" dependencies = [ - "ff", + "ff 0.14.0", "rand_core 0.10.1", "subtle", ] @@ -2923,11 +3044,18 @@ checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" dependencies = [ "aws-lc-rs", "base64 0.22.1", + "ed25519-dalek 2.2.0", "getrandom 0.2.17", + "hmac 0.12.1", "js-sys", + "p256 0.13.2", + "p384 0.13.1", "pem", + "rand 0.8.6", + "rsa 0.9.10", "serde", "serde_json", + "sha2 0.10.9", "signature 2.2.0", "simple_asn1", ] @@ -4229,6 +4357,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "socket2 0.6.3", + "spiffe", "sqlx", "tempfile", "thiserror 2.0.18", @@ -4511,30 +4640,54 @@ version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "sha2 0.10.9", +] + [[package]] name = "p256" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" dependencies = [ - "ecdsa", - "elliptic-curve", + "ecdsa 0.17.0", + "elliptic-curve 0.14.1", "primefield", - "primeorder", + "primeorder 0.14.0", "sha2 0.11.0", ] +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "sha2 0.10.9", +] + [[package]] name = "p384" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" dependencies = [ - "ecdsa", - "elliptic-curve", - "fiat-crypto", + "ecdsa 0.17.0", + "elliptic-curve 0.14.1", + "fiat-crypto 0.3.0", "primefield", - "primeorder", + "primeorder 0.14.0", "sha2 0.11.0", ] @@ -4544,11 +4697,11 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449" dependencies = [ - "base16ct", - "ecdsa", - "elliptic-curve", + "base16ct 1.0.0", + "ecdsa 0.17.0", + "elliptic-curve 0.14.1", "primefield", - "primeorder", + "primeorder 0.14.0", "sha2 0.11.0", ] @@ -4923,21 +5076,30 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" dependencies = [ - "crypto-bigint", + "crypto-bigint 0.7.5", "crypto-common 0.2.2", - "ff", + "ff 0.14.0", "rand_core 0.10.1", "subtle", "zeroize", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve 0.13.8", +] + [[package]] name = "primeorder" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" dependencies = [ - "elliptic-curve", + "elliptic-curve 0.14.1", "once_cell", "primefield", "serdect", @@ -5497,13 +5659,23 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "rfc6979" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" dependencies = [ - "crypto-bigint", + "crypto-bigint 0.7.5", "hmac 0.13.0", ] @@ -5560,7 +5732,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" dependencies = [ "const-oid 0.10.2", - "crypto-bigint", + "crypto-bigint 0.7.5", "crypto-primes", "digest 0.11.2", "pkcs1 0.8.0-rc.4", @@ -5586,16 +5758,16 @@ dependencies = [ "bytes", "cbc", "cipher", - "crypto-bigint", + "crypto-bigint 0.7.5", "ctr", - "curve25519-dalek", + "curve25519-dalek 5.0.0", "data-encoding", "delegate", "der 0.8.0", "digest 0.11.2", - "ecdsa", - "ed25519-dalek", - "elliptic-curve", + "ecdsa 0.17.0", + "ed25519-dalek 3.0.0", + "elliptic-curve 0.14.1", "enum_dispatch", "flate2", "futures", @@ -5612,8 +5784,8 @@ dependencies = [ "ml-kem", "module-lattice", "num-bigint", - "p256", - "p384", + "p256 0.14.0", + "p384 0.14.0", "p521", "pageant", "pbkdf2", @@ -5628,7 +5800,7 @@ dependencies = [ "russh-util", "salsa20", "scrypt", - "sec1", + "sec1 0.8.1", "sha1 0.11.0", "sha2 0.11.0", "sha3 0.12.0", @@ -5930,13 +6102,27 @@ dependencies = [ "untrusted 0.9.0", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct 0.2.0", + "der 0.7.10", + "generic-array 0.14.7", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + [[package]] name = "sec1" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ - "base16ct", + "base16ct 1.0.0", "ctutils", "der 0.8.0", "hybrid-array", @@ -6133,7 +6319,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9af4a3e75ebd5599b30d4de5768e00b5095d518a79fefc3ecbaf77e665d1ec06" dependencies = [ - "base16ct", + "base16ct 1.0.0", "serde", ] @@ -6367,6 +6553,7 @@ dependencies = [ "fastrand", "futures", "hyper-util", + "jsonwebtoken", "log", "prost", "prost-types", @@ -6633,7 +6820,7 @@ checksum = "7b54d0ed0498daf3f78d82e00e28c8eec9d75a067c4cfbcc7a0f7d0f4077749e" dependencies = [ "base64ct", "bytes", - "crypto-bigint", + "crypto-bigint 0.7.5", "ctutils", "digest 0.11.2", "pem-rfc7468 1.0.0", @@ -6649,15 +6836,15 @@ dependencies = [ "argon2", "bcrypt-pbkdf", "ctutils", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "hex", "hmac 0.13.0", - "p256", - "p384", + "p256 0.14.0", + "p384 0.14.0", "p521", "rand_core 0.10.1", "rsa 0.10.0-rc.18", - "sec1", + "sec1 0.8.1", "sha1 0.11.0", "sha2 0.11.0", "signature 3.0.0", @@ -8431,8 +8618,8 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1" dependencies = [ - "ff", - "group", + "ff 0.14.0", + "group 0.14.0", "hybrid-array", ] diff --git a/Cargo.toml b/Cargo.toml index c484ec95b1..57b77d0716 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -105,7 +105,7 @@ rand = "0.9" jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } getrandom = "0.3" ring = "0.17" -spiffe = { version = "0.15", default-features = false, features = ["workload-api-jwt", "tracing"] } +spiffe = { version = "0.15", default-features = false, features = ["workload-api-jwt", "jwt-verify-rust-crypto", "tracing"] } # Filesystem embedding include_dir = "0.7" diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 6274f4b2bf..4f48c31951 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -317,12 +317,29 @@ beginning with `v_` or `s<64 lowercase hex characters>_` are reserved for those placeholder namespaces. Provider profiles can also declare dynamic token grants. For matching HTTP -endpoints, the supervisor obtains a SPIFFE JWT-SVID from the local Workload API, -exchanges it for an OAuth2 access token, caches the token, and injects it as an -`Authorization: Bearer` header before forwarding the request. Token grant -endpoints are HTTPS-only except for loopback and Kubernetes service DNS hosts, -and returned access tokens must be bearer-compatible before they are cached or -injected. Token caching follows response-derived and profile override TTL rules. +endpoints, the supervisor obtains or exchanges OAuth2 access tokens, caches +them, and injects them before forwarding the request. `client_credentials` +grants use the supervisor SPIFFE JWT-SVID directly as the client assertion. +`token_exchange` grants ask the gateway to broker an intermediate token using a +stored provider subject credential and the gateway's own SPIFFE JWT-SVID; the +supervisor then exchanges that intermediate token for the final upstream token +using its own JWT-SVID. The gateway validates that its own JWT-SVID has the +requested audience, a SPIFFE subject, and a non-expired `exp` claim when +present. It also validates that the stored subject credential is declared by the +provider profile, and that the supervisor JWT-SVID is a well-formed +three-segment JWT with a SPIFFE subject in the same trust domain as the gateway +SVID. The gateway verifies the supervisor JWT-SVID signature with JWT bundles +fetched from its SPIFFE Workload API. Token grant endpoints are HTTPS-only +except for loopback and Kubernetes service DNS hosts, and returned access tokens +must be bearer-compatible before they are cached or injected. Token response +lifetimes are capped and cached with an expiry margin unless a profile supplies +an explicit cache TTL override. Cache entries are scoped by the sandbox provider +environment revision so provider credential updates miss the old token cache +without changing endpoint matching semantics. Gateway-brokered intermediate +tokens are cached separately by provider resource version, supervisor SPIFFE +subject, and gateway SPIFFE subject, and their cache lifetime is capped by the +intermediate token response, stored subject-token expiry, and supervisor SVID +expiry. For AWS endpoints that require request-level signing, the proxy supports SigV4 re-signing. When `credential_signing: sigv4` is set on an L7 endpoint, the proxy diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index de057b5f82..aaabf26625 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -811,7 +811,7 @@ impl From for openshell_cli::ssh::Editor { #[derive(Subcommand, Debug)] enum ProviderCommands { /// Create a provider config. - #[command(group = clap::ArgGroup::new("cred_source").required(true).args(["from_existing", "credentials", "from_gcloud_adc", "runtime_credentials"]), help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + #[command(group = clap::ArgGroup::new("cred_source").required(true).multiple(true).args(["from_existing", "credentials", "from_gcloud_adc", "runtime_credentials", "from_oidc_token"]), help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Create { /// Provider name. #[arg(long)] @@ -822,7 +822,7 @@ enum ProviderCommands { provider_type: String, /// Load provider credentials/config from existing local state. - #[arg(long, conflicts_with_all = ["credentials", "from_gcloud_adc", "runtime_credentials"])] + #[arg(long, conflicts_with_all = ["credentials", "from_gcloud_adc", "runtime_credentials", "from_oidc_token"])] from_existing: bool, /// Provider credential pair (`KEY=VALUE`) or env lookup key (`KEY`). @@ -836,11 +836,15 @@ enum ProviderCommands { /// Configure credentials from gcloud Application Default Credentials /// (`~/.config/gcloud/application_default_credentials.json`). /// Valid for providers whose profile declares an ADC-compatible credential. - #[arg(long, group = "cred_source", conflicts_with_all = ["from_existing", "credentials", "runtime_credentials"])] + #[arg(long, group = "cred_source", conflicts_with_all = ["from_existing", "credentials", "runtime_credentials", "from_oidc_token"])] from_gcloud_adc: bool, + /// Store the active gateway OIDC access token as the named provider credential. + #[arg(long, group = "cred_source", conflicts_with_all = ["from_existing", "from_gcloud_adc", "runtime_credentials"])] + from_oidc_token: bool, + /// Create a provider whose required credentials are resolved at runtime by the gateway/sandbox. - #[arg(long, conflicts_with_all = ["from_existing", "credentials", "from_gcloud_adc"])] + #[arg(long, conflicts_with_all = ["from_existing", "credentials", "from_gcloud_adc", "from_oidc_token"])] runtime_credentials: bool, /// Provider config key/value pair. @@ -913,9 +917,13 @@ enum ProviderCommands { name: String, /// Re-discover credentials from existing local state (e.g. env vars, config files). - #[arg(long, conflicts_with = "credentials")] + #[arg(long, conflicts_with_all = ["credentials", "from_oidc_token"])] from_existing: bool, + /// Store the active gateway OIDC access token as the named provider credential. + #[arg(long, conflicts_with = "from_existing")] + from_oidc_token: bool, + /// Provider credential pair (`KEY=VALUE`) or env lookup key (`KEY`). #[arg( long = "credential", @@ -3369,24 +3377,34 @@ async fn run_async() -> Result<()> { from_existing, credentials, from_gcloud_adc, + from_oidc_token, runtime_credentials, config, global_profile, } => { let profile_ws = if global_profile { "" } else { &cli.workspace }; - run::provider_create_with_options( - endpoint, - &name, - provider_type.as_str(), - from_existing, - &credentials, - from_gcloud_adc, - runtime_credentials, - &config, - &cli.workspace, - profile_ws, - &tls, - ) + let credential_source = if from_existing { + run::ProviderCreateCredentialSource::Existing + } else if from_gcloud_adc { + run::ProviderCreateCredentialSource::GcloudAdc + } else if from_oidc_token { + run::ProviderCreateCredentialSource::OidcToken + } else if runtime_credentials { + run::ProviderCreateCredentialSource::Runtime + } else { + run::ProviderCreateCredentialSource::ExplicitCredentials + }; + run::provider_create_with_options(run::ProviderCreateOptions { + server: endpoint, + name: &name, + provider_type: provider_type.as_str(), + credentials: &credentials, + credential_source, + config: &config, + workspace: &cli.workspace, + profile_workspace: profile_ws, + tls: &tls, + }) .await?; } ProviderCommands::Refresh(command) => match command { @@ -3539,20 +3557,22 @@ async fn run_async() -> Result<()> { ProviderCommands::Update { name, from_existing, + from_oidc_token, credentials, config, credential_expires_at, } => { - run::provider_update( - endpoint, - &name, + run::provider_update(run::ProviderUpdateOptions { + server: endpoint, + name: &name, from_existing, - &credentials, - &config, - &credential_expires_at, - &cli.workspace, - &tls, - ) + from_oidc_token, + credentials: &credentials, + config: &config, + credential_expires_at: &credential_expires_at, + workspace: &cli.workspace, + tls: &tls, + }) .await?; } ProviderCommands::Delete { names } => { diff --git a/crates/openshell-cli/src/oidc_auth.rs b/crates/openshell-cli/src/oidc_auth.rs index 2a5c41a5fa..7fbdb51004 100644 --- a/crates/openshell-cli/src/oidc_auth.rs +++ b/crates/openshell-cli/src/oidc_auth.rs @@ -512,10 +512,11 @@ fn bundle_from_refresh_output( } } -/// Ensure we have a valid OIDC token for the given gateway, refreshing if needed. -/// -/// Returns the access token string. -pub async fn ensure_valid_oidc_token(gateway_name: &str, insecure: bool) -> Result { +/// Ensure we have a valid OIDC token bundle for the given gateway, refreshing if needed. +pub async fn ensure_valid_oidc_token_bundle( + gateway_name: &str, + insecure: bool, +) -> Result { let bundle = openshell_bootstrap::oidc_token::load_oidc_token(gateway_name).ok_or_else(|| { miette::miette!( @@ -525,7 +526,7 @@ pub async fn ensure_valid_oidc_token(gateway_name: &str, insecure: bool) -> Resu })?; if !openshell_bootstrap::oidc_token::is_token_expired(&bundle) { - return Ok(bundle.access_token); + return Ok(bundle); } debug!( @@ -537,7 +538,16 @@ pub async fn ensure_valid_oidc_token(gateway_name: &str, insecure: bool) -> Resu .and_then(|metadata| metadata.oidc_scopes); let refreshed = oidc_refresh_token(&bundle, scopes.as_deref(), insecure).await?; openshell_bootstrap::oidc_token::store_oidc_token(gateway_name, &refreshed)?; - Ok(refreshed.access_token) + Ok(refreshed) +} + +/// Ensure we have a valid OIDC token for the given gateway, refreshing if needed. +/// +/// Returns the access token string. +pub async fn ensure_valid_oidc_token(gateway_name: &str, insecure: bool) -> Result { + Ok(ensure_valid_oidc_token_bundle(gateway_name, insecure) + .await? + .access_token) } // ── Helpers ────────────────────────────────────────────────────────── diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 5147ee2831..af8b7d5fd2 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -47,13 +47,14 @@ use openshell_core::proto::{ LintProviderProfilesRequest, ListProviderProfilesRequest, ListProvidersRequest, ListSandboxPoliciesRequest, ListSandboxProvidersRequest, ListSandboxesRequest, ListServicesRequest, PolicySource, PolicyStatus, Provider, ProviderCredentialRefreshStatus, - ProviderCredentialRefreshStrategy, ProviderProfile, ProviderProfileDiagnostic, - ProviderProfileImportItem, RejectDraftChunkRequest, ResourceRequirements, - RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox, SandboxPhase, SandboxPolicy, - SandboxSpec, SandboxTemplate, ServiceEndpointResponse, SetInferenceRouteRequest, SettingScope, - StartSandboxRequest, StopSandboxRequest, TcpForwardFrame, TcpForwardInit, TcpRelayTarget, - UpdateConfigRequest, UpdateProviderProfilesRequest, UpdateProviderRequest, WatchSandboxRequest, - exec_sandbox_event, setting_value, tcp_forward_init, + ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrantType, ProviderProfile, + ProviderProfileDiagnostic, ProviderProfileImportItem, RejectDraftChunkRequest, + ResourceRequirements, RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox, + SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, ServiceEndpointResponse, + SetInferenceRouteRequest, SettingScope, StartSandboxRequest, StopSandboxRequest, + TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest, + UpdateProviderProfilesRequest, UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, + setting_value, tcp_forward_init, }; use openshell_core::settings; use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; @@ -3373,6 +3374,126 @@ fn missing_credentials_error(provider_type: &str) -> miette::Report { ) } +async fn provider_credential_from_oidc_token( + credentials: &[String], + profile: Option<&ProviderProfile>, + tls: &TlsOptions, +) -> Result<(HashMap, HashMap)> { + let credential_key = oidc_subject_credential_key(credentials, profile)?; + + let gateway_name = tls.gateway_name().ok_or_else(|| { + miette::miette!("--from-oidc-token requires an active named OIDC gateway") + })?; + let bundle = + crate::oidc_auth::ensure_valid_oidc_token_bundle(gateway_name, tls.gateway_insecure) + .await + .map_err(|err| { + miette::miette!( + "failed to load or refresh OIDC token for gateway '{gateway_name}' while preparing provider credential: {err}" + ) + })?; + + let mut credential_map = HashMap::new(); + credential_map.insert(credential_key.clone(), bundle.access_token); + + let mut credential_expires_at_ms = HashMap::new(); + if let Some(expires_at) = bundle.expires_at { + let expires_at_ms = i64::try_from(expires_at) + .unwrap_or(i64::MAX / 1000) + .saturating_mul(1000); + credential_expires_at_ms.insert(credential_key, expires_at_ms); + } + + Ok((credential_map, credential_expires_at_ms)) +} + +fn oidc_subject_credential_key( + credentials: &[String], + profile: Option<&ProviderProfile>, +) -> Result { + if credentials.len() > 1 { + return Err(miette::miette!( + "--from-oidc-token accepts at most one --credential KEY destination" + )); + } + + if let Some(credential) = credentials.first() { + let credential = credential.trim(); + if credential.is_empty() || credential.contains('=') { + return Err(miette::miette!( + "--from-oidc-token requires --credential KEY without an inline value" + )); + } + if let Some(profile) = profile { + ensure_profile_declares_subject_credential(profile, credential)?; + } + return Ok(credential.to_string()); + } + + let Some(profile) = profile else { + return Err(miette::miette!( + "--from-oidc-token requires --credential KEY when the provider profile is unavailable" + )); + }; + + infer_oidc_subject_credential_from_profile(profile) +} + +fn ensure_profile_declares_subject_credential( + profile: &ProviderProfile, + credential: &str, +) -> Result<()> { + let matches = token_exchange_subject_credentials(profile); + if matches.iter().any(|candidate| candidate == credential) { + return Ok(()); + } + Err(miette::miette!( + "credential '{credential}' is not declared as a token-exchange subject credential in provider profile '{}'; expected one of: {}", + profile.id, + matches.join(", ") + )) +} + +fn infer_oidc_subject_credential_from_profile(profile: &ProviderProfile) -> Result { + let matches = token_exchange_subject_credentials(profile); + match matches.as_slice() { + [credential] => Ok(credential.clone()), + [] => Err(miette::miette!( + "provider profile '{}' does not declare a token-exchange subject credential; pass --credential KEY", + profile.id + )), + _ => Err(miette::miette!( + "provider profile '{}' declares multiple token-exchange subject credentials ({}); pass --credential KEY", + profile.id, + matches.join(", ") + )), + } +} + +fn token_exchange_subject_credentials(profile: &ProviderProfile) -> Vec { + let mut matches = Vec::new(); + for credential in &profile.credentials { + let Some(token_grant) = credential.token_grant.as_ref() else { + continue; + }; + if ProviderCredentialTokenGrantType::try_from(token_grant.grant_type).ok() + != Some(ProviderCredentialTokenGrantType::TokenExchange) + { + continue; + } + let Some(subject_token) = token_grant.subject_token.as_ref() else { + continue; + }; + if subject_token.source != "provider_credential" || subject_token.credential.is_empty() { + continue; + } + if !matches.contains(&subject_token.credential) { + matches.push(subject_token.credential.clone()); + } + } + matches +} + #[allow(clippy::too_many_arguments)] pub async fn provider_create( server: &str, @@ -3385,44 +3506,77 @@ pub async fn provider_create( workspace: &str, tls: &TlsOptions, ) -> Result<()> { - provider_create_with_options( + let credential_source = match (from_existing, from_gcloud_adc) { + (true, true) => { + return Err(miette::miette!( + "--from-gcloud-adc cannot be combined with --from-existing, --from-oidc-token, or --credential; it also cannot be combined with --runtime-credentials" + )); + } + (true, false) => ProviderCreateCredentialSource::Existing, + (false, true) => ProviderCreateCredentialSource::GcloudAdc, + (false, false) => ProviderCreateCredentialSource::ExplicitCredentials, + }; + provider_create_with_options(ProviderCreateOptions { server, name, provider_type, - from_existing, credentials, - from_gcloud_adc, - false, + credential_source, config, workspace, - workspace, + profile_workspace: workspace, tls, - ) + }) .await } -#[allow(clippy::too_many_arguments)] -pub async fn provider_create_with_options( - server: &str, - name: &str, - provider_type: &str, - from_existing: bool, - credentials: &[String], - from_gcloud_adc: bool, - runtime_credentials: bool, - config: &[String], - workspace: &str, - profile_workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - if from_gcloud_adc && (from_existing || !credentials.is_empty() || runtime_credentials) { +pub struct ProviderCreateOptions<'a> { + pub server: &'a str, + pub name: &'a str, + pub provider_type: &'a str, + pub credentials: &'a [String], + pub credential_source: ProviderCreateCredentialSource, + pub config: &'a [String], + pub workspace: &'a str, + pub profile_workspace: &'a str, + pub tls: &'a TlsOptions, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProviderCreateCredentialSource { + ExplicitCredentials, + Existing, + GcloudAdc, + OidcToken, + Runtime, +} + +pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) -> Result<()> { + let ProviderCreateOptions { + server, + name, + provider_type, + credentials, + credential_source, + config, + workspace, + profile_workspace, + tls, + } = options; + + let from_existing = credential_source == ProviderCreateCredentialSource::Existing; + let from_gcloud_adc = credential_source == ProviderCreateCredentialSource::GcloudAdc; + let from_oidc_token = credential_source == ProviderCreateCredentialSource::OidcToken; + let runtime_credentials = credential_source == ProviderCreateCredentialSource::Runtime; + + if from_gcloud_adc && !credentials.is_empty() { return Err(miette::miette!( - "--from-gcloud-adc cannot be combined with --from-existing, --credential, or --runtime-credentials" + "--from-gcloud-adc cannot be combined with --from-existing, --from-oidc-token, or --credential; it also cannot be combined with --runtime-credentials" )); } - if from_existing && (!credentials.is_empty() || runtime_credentials) { + if from_existing && !credentials.is_empty() { return Err(miette::miette!( - "--from-existing cannot be combined with --credential or --runtime-credentials" + "--from-existing cannot be combined with --credential" )); } if runtime_credentials && !credentials.is_empty() { @@ -3492,7 +3646,17 @@ pub async fn provider_create_with_options( None }; - let mut credential_map = parse_credential_pairs(credentials)?; + let oidc_profile = if from_oidc_token { + Some(fetch_provider_profile(&mut client, &provider_type, profile_workspace).await?) + } else { + None + }; + + let (mut credential_map, oidc_credential_expires_at_ms) = if from_oidc_token { + provider_credential_from_oidc_token(credentials, oidc_profile.as_ref(), tls).await? + } else { + (parse_credential_pairs(credentials)?, HashMap::new()) + }; let mut config_map = parse_key_value_pairs(config, "--config")?; if from_existing { @@ -3566,7 +3730,7 @@ pub async fn provider_create_with_options( r#type: provider_type.clone(), credentials: credential_map, config: config_map, - credential_expires_at_ms: HashMap::new(), + credential_expires_at_ms: oidc_credential_expires_at_ms, profile_workspace: profile_workspace.to_string(), credential_handles: HashMap::new(), }), @@ -4594,28 +4758,73 @@ fn print_provider_type_row( ); } -#[allow(clippy::too_many_arguments)] -pub async fn provider_update( - server: &str, - name: &str, - from_existing: bool, - credentials: &[String], - config: &[String], - credential_expires_at: &[String], - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { +pub struct ProviderUpdateOptions<'a> { + pub server: &'a str, + pub name: &'a str, + pub from_existing: bool, + pub from_oidc_token: bool, + pub credentials: &'a [String], + pub config: &'a [String], + pub credential_expires_at: &'a [String], + pub workspace: &'a str, + pub tls: &'a TlsOptions, +} + +pub async fn provider_update(options: ProviderUpdateOptions<'_>) -> Result<()> { + let ProviderUpdateOptions { + server, + name, + from_existing, + from_oidc_token, + credentials, + config, + credential_expires_at, + workspace, + tls, + } = options; + if from_existing && !credentials.is_empty() { return Err(miette::miette!( "--from-existing cannot be combined with --credential" )); } + if from_existing && from_oidc_token { + return Err(miette::miette!( + "--from-existing cannot be combined with --from-oidc-token" + )); + } let mut client = grpc_client(server, tls).await?; - let mut credential_map = parse_credential_pairs(credentials)?; + let oidc_profile = if from_oidc_token { + let existing = client + .get_provider(GetProviderRequest { + name: name.to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner() + .provider + .ok_or_else(|| miette::miette!("provider '{name}' not found"))?; + let profile_workspace = if existing.profile_workspace.is_empty() { + workspace + } else { + &existing.profile_workspace + }; + Some(fetch_provider_profile(&mut client, &existing.r#type, profile_workspace).await?) + } else { + None + }; + + let (mut credential_map, oidc_credential_expires_at_ms) = if from_oidc_token { + provider_credential_from_oidc_token(credentials, oidc_profile.as_ref(), tls).await? + } else { + (parse_credential_pairs(credentials)?, HashMap::new()) + }; let mut config_map = parse_key_value_pairs(config, "--config")?; - let credential_expires_at_ms = parse_credential_expiry_pairs(credential_expires_at)?; + let mut credential_expires_at_ms = parse_credential_expiry_pairs(credential_expires_at)?; + credential_expires_at_ms.extend(oidc_credential_expires_at_ms); if from_existing { // Fetch the existing provider to discover its type for credential lookup. diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 831f9912bb..8192989375 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -17,7 +17,8 @@ use openshell_core::proto::{ AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DetachSandboxProviderRequest, DetachSandboxProviderResponse, ExecSandboxEvent, + DetachSandboxProviderRequest, DetachSandboxProviderResponse, + ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxProviderEnvironmentRequest, @@ -241,6 +242,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(RevokeSshSessionResponse::default())) } + async fn exchange_provider_subject_token( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn create_provider( &self, request: tonic::Request, diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index 1a2a159b56..91d520d1af 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -13,10 +13,11 @@ use openshell_cli::{ }; use openshell_core::proto::{ CreateProviderRequest, CreateSshSessionRequest, CreateSshSessionResponse, - DeleteProviderRequest, DeleteProviderResponse, ExecSandboxEvent, ExecSandboxInput, - ExecSandboxRequest, GetProviderRequest, HealthRequest, HealthResponse, ListProvidersRequest, - ListProvidersResponse, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, - ServiceStatus, UpdateProviderRequest, + DeleteProviderRequest, DeleteProviderResponse, ExchangeProviderSubjectTokenRequest, + ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, + GetProviderRequest, HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, + ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, ServiceStatus, + UpdateProviderRequest, open_shell_server::{OpenShell, OpenShellServer}, }; use tempfile::tempdir; @@ -213,6 +214,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(RevokeSshSessionResponse::default())) } + async fn exchange_provider_subject_token( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn create_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index f810c52d54..c349bbdbb4 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -14,7 +14,8 @@ use openshell_core::proto::{ CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRefreshRequest, DeleteProviderRefreshResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DetachSandboxProviderRequest, DetachSandboxProviderResponse, ExecSandboxEvent, + DetachSandboxProviderRequest, DetachSandboxProviderResponse, + ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRefreshStatusRequest, GetProviderRefreshStatusResponse, GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, @@ -372,6 +373,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(RevokeSshSessionResponse::default())) } + async fn exchange_provider_subject_token( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn create_provider( &self, request: tonic::Request, @@ -1186,16 +1194,17 @@ async fn provider_cli_run_functions_support_full_crud_flow() { .await .expect("provider list"); - run::provider_update( - &ts.endpoint, - "my-claude", - false, - &["API_KEY=rotated".to_string()], - &["profile=prod".to_string()], - &[], - "default", - &ts.tls, - ) + 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: &["profile=prod".to_string()], + credential_expires_at: &[], + workspace: "default", + tls: &ts.tls, + }) .await .expect("provider update"); @@ -1544,19 +1553,17 @@ async fn provider_create_allows_empty_credentials_for_gateway_refresh_profiles() }, ); - run::provider_create_with_options( - &ts.endpoint, - "custom-refresh-provider", - "custom-refresh", - false, - &[], - false, - true, - &[], - "default", - "default", - &ts.tls, - ) + run::provider_create_with_options(run::ProviderCreateOptions { + server: &ts.endpoint, + name: "custom-refresh-provider", + provider_type: "custom-refresh", + credentials: &[], + credential_source: run::ProviderCreateCredentialSource::Runtime, + config: &[], + workspace: "default", + profile_workspace: "default", + tls: &ts.tls, + }) .await .expect("provider create"); @@ -2100,16 +2107,17 @@ async fn provider_update_from_existing_uses_profile_discovery_when_v2_enabled() ); let _env = EnvVarGuard::set(&[("CUSTOM_UPDATE_DISCOVERY_API_KEY", "updated-profile-secret")]); - run::provider_update( - &ts.endpoint, - "custom-update", - true, - &[], - &[], - &[], - "default", - &ts.tls, - ) + run::provider_update(run::ProviderUpdateOptions { + server: &ts.endpoint, + name: "custom-update", + from_existing: true, + from_oidc_token: false, + credentials: &[], + config: &[], + credential_expires_at: &[], + workspace: "default", + tls: &ts.tls, + }) .await .expect("profile-backed provider update --from-existing"); @@ -2364,19 +2372,17 @@ async fn provider_create_supports_generic_type_and_env_lookup_credentials() { async fn provider_create_sends_inline_credentials() { let ts = run_server().await; - run::provider_create_with_options( - &ts.endpoint, - "openai-inline", - "openai", - false, - &["OPENAI_API_KEY=sk-test".to_string()], - false, - false, - &[], - "default", - "default", - &ts.tls, - ) + run::provider_create_with_options(run::ProviderCreateOptions { + server: &ts.endpoint, + name: "openai-inline", + provider_type: "openai", + credentials: &["OPENAI_API_KEY=sk-test".to_string()], + credential_source: run::ProviderCreateCredentialSource::ExplicitCredentials, + config: &[], + workspace: "default", + profile_workspace: "default", + tls: &ts.tls, + }) .await .expect("provider create with inline credential"); @@ -2442,7 +2448,7 @@ async fn provider_create_rejects_combined_from_gcloud_adc_and_from_existing() { assert!( err.to_string() - .contains("--from-gcloud-adc cannot be combined with --from-existing, --credential"), + .contains("--from-gcloud-adc cannot be combined with --from-existing, --from-oidc-token, or --credential"), "unexpected error: {err}" ); assert!(ts.state.providers.lock().await.is_empty()); @@ -2468,7 +2474,7 @@ async fn provider_create_rejects_combined_from_gcloud_adc_and_credentials() { assert!( err.to_string() - .contains("--from-gcloud-adc cannot be combined with --from-existing, --credential"), + .contains("--from-gcloud-adc cannot be combined with --from-existing, --from-oidc-token, or --credential"), "unexpected error: {err}" ); assert!(ts.state.providers.lock().await.is_empty()); diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index c7ac19ce93..bcc07619ee 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -16,7 +16,8 @@ use openshell_core::proto::{ AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DetachSandboxProviderRequest, DetachSandboxProviderResponse, ExecSandboxEvent, + DetachSandboxProviderRequest, DetachSandboxProviderResponse, + ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxProviderEnvironmentRequest, @@ -283,6 +284,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(RevokeSshSessionResponse::default())) } + async fn exchange_provider_subject_token( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn create_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index acc2909972..7e2cf74f50 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -14,7 +14,8 @@ use openshell_core::proto::{ AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DetachSandboxProviderRequest, DetachSandboxProviderResponse, ExecSandboxEvent, + DetachSandboxProviderRequest, DetachSandboxProviderResponse, + ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, @@ -276,6 +277,13 @@ impl OpenShell for TestOpenShell { )) } + async fn exchange_provider_subject_token( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn create_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-core/Cargo.toml b/crates/openshell-core/Cargo.toml index 0e2ad24ff0..d8483bd2cb 100644 --- a/crates/openshell-core/Cargo.toml +++ b/crates/openshell-core/Cargo.toml @@ -43,7 +43,9 @@ default = ["telemetry"] ## `--no-default-features` (plus any other features you need) for a build that ## contains no telemetry endpoint, no HTTP client, and no emission code at all. driver-extraction = ["dep:tar", "dep:tempfile"] -telemetry = ["dep:reqwest", "dep:chrono"] +telemetry = ["dep:reqwest", "dep:chrono", "reqwest?/blocking"] +## OAuth2 token request helpers used by supervisor and gateway. +oauth = ["dep:reqwest"] [build-dependencies] tonic-prost-build = { workspace = true } diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index 8f88c0cd4a..b66502df99 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -348,6 +348,9 @@ pub fn read_upstream_proxy_credential_file(path: &str) -> Result Ok(buf) } +/// Container-side directory where the provider SPIFFE Workload API socket is mounted. +pub const PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR: &str = "/spiffe-workload-api"; + /// Return the XDG state path for a driver's sandbox JWT token file. /// /// The resulting path is `$XDG_STATE_HOME/openshell/[/]//sandbox.jwt`. diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index e5f246e4c0..54f0db6902 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -23,12 +23,12 @@ use std::sync::{Arc, OnceLock, RwLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crate::proto::{ - DenialSummary, GetDraftPolicyRequest, GetInferenceBundleRequest, GetInferenceBundleResponse, - GetSandboxConfigRequest, GetSandboxProviderEnvironmentRequest, IssueSandboxTokenRequest, - NetworkActivitySummary, PolicyChunk, PolicySource, PolicyStatus, RefreshSandboxTokenRequest, - ReportPolicyStatusRequest, SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest, - SubmitPolicyAnalysisResponse, UpdateConfigRequest, inference_client::InferenceClient, - open_shell_client::OpenShellClient, + DenialSummary, ExchangeProviderSubjectTokenRequest, GetDraftPolicyRequest, + GetInferenceBundleRequest, GetInferenceBundleResponse, GetSandboxConfigRequest, + GetSandboxProviderEnvironmentRequest, IssueSandboxTokenRequest, NetworkActivitySummary, + PolicyChunk, PolicySource, PolicyStatus, RefreshSandboxTokenRequest, ReportPolicyStatusRequest, + SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, + UpdateConfigRequest, inference_client::InferenceClient, open_shell_client::OpenShellClient, }; use crate::sandbox_env; use miette::{IntoDiagnostic, Result, WrapErr}; @@ -860,6 +860,55 @@ pub async fn fetch_provider_environment( }) } +pub async fn exchange_provider_subject_token( + endpoint: &str, + sandbox_id: &str, + provider: &str, + credential_key: &str, + supervisor_jwt_svid: &str, +) -> Result { + debug!( + endpoint = %endpoint, + sandbox_id = %sandbox_id, + provider = %provider, + credential_key = %credential_key, + "Exchanging provider subject token through gateway" + ); + + let mut client = connect(endpoint).await?; + let response = client + .exchange_provider_subject_token(ExchangeProviderSubjectTokenRequest { + sandbox_id: sandbox_id.to_string(), + provider: provider.to_string(), + credential_key: credential_key.to_string(), + supervisor_jwt_svid: supervisor_jwt_svid.to_string(), + }) + .await + .map_err(provider_subject_token_exchange_status)?; + let inner = response.into_inner(); + Ok(ProviderSubjectTokenExchangeResult { + access_token: inner.access_token, + expires_in: inner.expires_in, + token_type: inner.token_type, + }) +} + +fn provider_subject_token_exchange_status(status: Status) -> miette::Report { + let message = status.message(); + if message.is_empty() { + miette::miette!( + "gateway ExchangeProviderSubjectToken failed with status {}", + status.code() + ) + } else { + miette::miette!( + "gateway ExchangeProviderSubjectToken failed with status {}: {}", + status.code(), + message + ) + } +} + /// A reusable gRPC client for the `OpenShell` service. /// /// Wraps a tonic channel connected once and reused for policy polling @@ -969,6 +1018,12 @@ pub struct ProviderEnvironmentResult { pub non_secret_environment_keys: Vec, } +pub struct ProviderSubjectTokenExchangeResult { + pub access_token: String, + pub expires_in: i64, + pub token_type: String, +} + impl CachedOpenShellClient { pub async fn connect(endpoint: &str) -> Result { Self::connect_with_credentials(endpoint, ExtensionCredentialStore::new()).await diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 96be19e1e2..315bb319bb 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -31,6 +31,8 @@ pub mod jwt; pub mod metadata; pub mod middleware; pub mod net; +#[cfg(feature = "oauth")] +pub mod oauth; pub mod operator_namespace_allowlist; pub mod paths; pub mod policy; @@ -42,6 +44,7 @@ pub mod provider_credentials; pub mod sandbox_env; pub mod secrets; pub mod settings; +pub mod spiffe; pub mod telemetry; pub mod time; pub mod transport_errors; diff --git a/crates/openshell-core/src/oauth.rs b/crates/openshell-core/src/oauth.rs new file mode 100644 index 0000000000..c68ecfa6b4 --- /dev/null +++ b/crates/openshell-core/src/oauth.rs @@ -0,0 +1,766 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared `OAuth2` token request helpers. +//! +//! Provides [`post_oauth_token_grant`] (client credentials) and +//! [`post_oauth_token_exchange`] (RFC 8693 token exchange) — typed functions +//! that assemble form parameters, POST to a validated `OAuth2` token endpoint, +//! parse the JSON response, and validate the returned access token. + +use std::net::IpAddr; + +use miette::{IntoDiagnostic, Result, WrapErr}; +use serde::Deserialize; + +pub const DEFAULT_CLIENT_ASSERTION_TYPE: &str = + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; +pub const ACCESS_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:access_token"; +const TOKEN_EXCHANGE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:token-exchange"; + +const MAX_OAUTH_ERROR_FIELD_LEN: usize = 256; + +/// `OAuth2` token response. +#[derive(Debug, Clone)] +pub struct OAuthTokenResponse { + pub access_token: String, + pub expires_in: i64, + pub token_type: String, +} + +#[derive(Debug, Deserialize)] +struct RawTokenResponse { + access_token: String, + #[serde(default)] + expires_in: i64, + #[serde(default)] + token_type: String, +} + +#[derive(Debug, Deserialize)] +struct OAuthErrorResponse { + error: Option, + error_description: Option, +} + +async fn post_oauth_token_request( + client: &reqwest::Client, + token_endpoint: &str, + mut form_params: Vec<(&str, &str)>, + audience: &str, + scopes: &[String], +) -> Result { + let token_endpoint_url = parse_token_endpoint_url(token_endpoint)?; + + let audience_param; + if !audience.is_empty() { + audience_param = audience.to_string(); + form_params.push(("audience", &audience_param)); + } + + let scope_param; + if !scopes.is_empty() { + scope_param = scopes.join(" "); + form_params.push(("scope", &scope_param)); + } + + let response = client + .post(token_endpoint_url) + .form(&form_params) + .send() + .await + .into_diagnostic() + .wrap_err_with(|| format!("failed to POST to token endpoint {token_endpoint}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "".to_string()); + return Err(miette::miette!("{}", failure_message(status, &body))); + } + + let raw = response + .json::() + .await + .into_diagnostic() + .wrap_err("failed to parse token response as JSON")?; + validate_access_token(&raw.access_token)?; + Ok(OAuthTokenResponse { + access_token: raw.access_token, + expires_in: raw.expires_in, + token_type: raw.token_type, + }) +} + +/// Client credentials grant form fields. +pub struct TokenGrantParams<'a> { + pub client_assertion: &'a str, + pub client_assertion_type: &'a str, + pub audience: &'a str, + pub scopes: &'a [String], +} + +/// POST a client credentials grant request to an `OAuth2` token endpoint. +/// +/// Applies the default for `client_assertion_type` and conditionally includes +/// `audience` and `scope`. +pub async fn post_oauth_token_grant( + client: &reqwest::Client, + token_endpoint: &str, + params: &TokenGrantParams<'_>, +) -> Result { + let client_assertion_type = effective_client_assertion_type(params.client_assertion_type); + let form_params = vec![ + ("grant_type", "client_credentials"), + ("client_assertion_type", client_assertion_type), + ("client_assertion", params.client_assertion), + ]; + post_oauth_token_request( + client, + token_endpoint, + form_params, + params.audience, + params.scopes, + ) + .await +} + +/// RFC 8693 token-exchange form fields. +pub struct TokenExchangeParams<'a> { + pub client_assertion: &'a str, + pub client_assertion_type: &'a str, + pub subject_token: &'a str, + pub subject_token_type: &'a str, + pub audience: &'a str, + pub scopes: &'a [String], + pub requested_token_type: &'a str, +} + +/// POST an RFC 8693 token-exchange request to an `OAuth2` token endpoint. +/// +/// Assembles the standard token-exchange form fields, applies defaults for +/// `client_assertion_type`, `subject_token_type`, and `requested_token_type`, +/// and conditionally includes `audience` and `scope`. +pub async fn post_oauth_token_exchange( + client: &reqwest::Client, + token_endpoint: &str, + params: &TokenExchangeParams<'_>, +) -> Result { + let client_assertion_type = effective_client_assertion_type(params.client_assertion_type); + let subject_token_type = effective_token_type(params.subject_token_type); + let requested_token_type = effective_token_type(params.requested_token_type); + let form_params = vec![ + ("grant_type", TOKEN_EXCHANGE_GRANT_TYPE), + ("client_assertion_type", client_assertion_type), + ("client_assertion", params.client_assertion), + ("subject_token", params.subject_token), + ("subject_token_type", subject_token_type), + ("requested_token_type", requested_token_type), + ]; + post_oauth_token_request( + client, + token_endpoint, + form_params, + params.audience, + params.scopes, + ) + .await +} + +pub fn effective_client_assertion_type(client_assertion_type: &str) -> &str { + if client_assertion_type.trim().is_empty() { + DEFAULT_CLIENT_ASSERTION_TYPE + } else { + client_assertion_type + } +} + +pub fn effective_token_type(token_type: &str) -> &str { + if token_type.trim().is_empty() { + ACCESS_TOKEN_TYPE + } else { + token_type + } +} + +fn parse_token_endpoint_url(token_endpoint: &str) -> Result { + let url = reqwest::Url::parse(token_endpoint) + .into_diagnostic() + .wrap_err("token_endpoint must be an absolute URL")?; + if token_endpoint_transport_allowed(&url) { + return Ok(url); + } + Err(miette::miette!( + "token_endpoint must use https, except http for loopback or in-cluster service hosts" + )) +} + +fn token_endpoint_transport_allowed(url: &reqwest::Url) -> bool { + match url.scheme() { + "https" => true, + "http" => url + .host_str() + .is_some_and(|host| is_loopback_host(host) || is_kubernetes_service_host(host)), + _ => false, + } +} + +fn is_loopback_host(host: &str) -> bool { + let host = host.trim_matches(['[', ']']); + if host.eq_ignore_ascii_case("localhost") { + return true; + } + match host.parse::() { + Ok(IpAddr::V4(v4)) => v4.is_loopback(), + Ok(IpAddr::V6(v6)) => { + v6.is_loopback() || v6.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback()) + } + Err(_) => false, + } +} + +fn is_kubernetes_service_host(host: &str) -> bool { + let host = host.trim_end_matches('.').to_ascii_lowercase(); + let labels = host.split('.').collect::>(); + let is_service_name = labels.len() == 3 && labels[2] == "svc"; + let is_cluster_local_service = + labels.len() == 5 && labels[2] == "svc" && labels[3] == "cluster" && labels[4] == "local"; + (is_service_name || is_cluster_local_service) && labels.iter().all(|label| !label.is_empty()) +} + +pub fn validate_access_token(token: &str) -> Result<()> { + if token.is_empty() || !is_token68(token) { + return Err(miette::miette!( + "token grant returned a malformed access token" + )); + } + Ok(()) +} + +fn is_token68(token: &str) -> bool { + let mut padding_started = false; + let mut saw_value = false; + for byte in token.bytes() { + if byte == b'=' { + padding_started = true; + continue; + } + if padding_started || !is_token68_value_byte(byte) { + return false; + } + saw_value = true; + } + saw_value +} + +fn is_token68_value_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'+' | b'/') +} + +fn failure_message(status: reqwest::StatusCode, body: &str) -> String { + let Ok(error_response) = serde_json::from_str::(body) else { + return format!("token grant failed with status {status}"); + }; + let error = error_response + .error + .as_deref() + .map(sanitize_oauth_error_field) + .filter(|value| !value.is_empty()); + let description = error_response + .error_description + .as_deref() + .map(sanitize_oauth_error_field) + .filter(|value| !value.is_empty()); + match (error, description) { + (Some(error), Some(description)) => { + format!( + "token grant failed with status {status}: error={error}; error_description={description}" + ) + } + (Some(error), None) => { + format!("token grant failed with status {status}: error={error}") + } + (None, Some(description)) => { + format!("token grant failed with status {status}: error_description={description}") + } + (None, None) => format!("token grant failed with status {status}"), + } +} + +fn sanitize_oauth_error_field(value: &str) -> String { + value + .chars() + .map(|ch| if ch.is_control() { ' ' } else { ch }) + .take(MAX_OAUTH_ERROR_FIELD_LEN) + .collect::() + .trim() + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + #[derive(Debug)] + struct CapturedRequest { + form: HashMap, + } + + fn test_client() -> reqwest::Client { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("test client") + } + + async fn token_endpoint_once( + status: &str, + body: &str, + ) -> (String, tokio::task::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind token endpoint"); + let addr = listener.local_addr().expect("token endpoint local addr"); + let status = status.to_string(); + let body = body.to_string(); + let handle = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept"); + let mut buf = Vec::new(); + let mut chunk = [0u8; 512]; + let mut expected_len = None; + loop { + let n = stream.read(&mut chunk).await.expect("read"); + assert!(n > 0); + buf.extend_from_slice(&chunk[..n]); + if expected_len.is_none() + && let Some(header_end) = header_end(&buf) + { + let headers = String::from_utf8_lossy(&buf[..header_end]); + let cl = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + expected_len = Some(header_end + cl); + } + if expected_len.is_some_and(|len| buf.len() >= len) { + break; + } + } + let header_end = header_end(&buf).unwrap(); + let form_body = String::from_utf8_lossy(&buf[header_end..]).to_string(); + let form = parse_form_body(&form_body); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len(), + ); + stream.write_all(response.as_bytes()).await.expect("write"); + CapturedRequest { form } + }); + (format!("http://{addr}/token"), handle) + } + + async fn token_endpoint_redirect_once(location: &str) -> (String, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind token endpoint"); + let addr = listener.local_addr().expect("token endpoint local addr"); + let location = location.to_string(); + let handle = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept"); + let mut buf = vec![0u8; 512]; + let _ = stream.read(&mut buf).await; + let response = format!( + "HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + stream.write_all(response.as_bytes()).await.expect("write"); + }); + (format!("http://{addr}/token"), handle) + } + + fn header_end(buf: &[u8]) -> Option { + buf.windows(4) + .position(|w| w == b"\r\n\r\n") + .map(|idx| idx + 4) + } + + fn parse_form_body(body: &str) -> HashMap { + body.split('&') + .filter(|part| !part.is_empty()) + .filter_map(|part| { + let (name, value) = part.split_once('=')?; + Some((decode_form_component(name), decode_form_component(value))) + }) + .collect() + } + + fn decode_form_component(value: &str) -> String { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut idx = 0; + while idx < bytes.len() { + match bytes[idx] { + b'+' => { + decoded.push(b' '); + idx += 1; + } + b'%' if idx + 2 < bytes.len() => { + let hex = &value[idx + 1..idx + 3]; + if let Ok(byte) = u8::from_str_radix(hex, 16) { + decoded.push(byte); + idx += 3; + } else { + decoded.push(bytes[idx]); + idx += 1; + } + } + byte => { + decoded.push(byte); + idx += 1; + } + } + } + String::from_utf8(decoded).expect("form body should be UTF-8") + } + + fn empty_grant_params() -> TokenGrantParams<'static> { + TokenGrantParams { + client_assertion: "jwt-svid-token", + client_assertion_type: "", + audience: "", + scopes: &[], + } + } + + #[tokio::test] + async fn posts_form_params_and_parses_success_response() { + let (endpoint, request) = token_endpoint_once( + "200 OK", + r#"{"access_token":"access-123","token_type":"Bearer","expires_in":42}"#, + ) + .await; + let client = test_client(); + + let response = post_oauth_token_grant(&client, &endpoint, &empty_grant_params()) + .await + .expect("should succeed"); + let request = request.await.expect("endpoint task"); + + assert_eq!(response.access_token, "access-123"); + assert_eq!(response.expires_in, 42); + assert_eq!(response.token_type, "Bearer"); + assert_eq!( + request.form.get("grant_type").map(String::as_str), + Some("client_credentials") + ); + assert_eq!( + request.form.get("client_assertion").map(String::as_str), + Some("jwt-svid-token") + ); + } + + #[tokio::test] + async fn rejects_malformed_access_token() { + let (endpoint, request) = token_endpoint_once( + "200 OK", + r#"{"access_token":"access-123\r\nX-Injected: yes"}"#, + ) + .await; + let client = test_client(); + + let err = post_oauth_token_grant(&client, &endpoint, &empty_grant_params()) + .await + .expect_err("malformed token should fail"); + let _ = request.await; + + assert_eq!( + err.to_string(), + "token grant returned a malformed access token" + ); + } + + #[tokio::test] + async fn does_not_follow_redirects() { + let (endpoint, handle) = token_endpoint_redirect_once("http://127.0.0.1:1/stolen").await; + let client = test_client(); + + let err = post_oauth_token_grant(&client, &endpoint, &empty_grant_params()) + .await + .expect_err("redirect should fail"); + let _ = handle.await; + + assert_eq!(err.to_string(), "token grant failed with status 302 Found"); + } + + #[tokio::test] + async fn reports_sanitized_oauth_error() { + let (endpoint, request) = token_endpoint_once( + "401 Unauthorized", + r#"{"error":"invalid_client","error_description":"bad assertion"}"#, + ) + .await; + let client = test_client(); + + let err = post_oauth_token_grant(&client, &endpoint, &empty_grant_params()) + .await + .expect_err("should fail on OAuth error"); + let _ = request.await; + + assert_eq!( + err.to_string(), + "token grant failed with status 401 Unauthorized: error=invalid_client; error_description=bad assertion" + ); + } + + #[tokio::test] + async fn does_not_echo_unstructured_error_body() { + let (endpoint, request) = token_endpoint_once( + "500 Internal Server Error", + "internal stack trace with implementation details", + ) + .await; + let client = test_client(); + + let err = post_oauth_token_grant(&client, &endpoint, &empty_grant_params()) + .await + .expect_err("should fail on server error"); + let _ = request.await; + let message = err.to_string(); + + assert_eq!( + message, + "token grant failed with status 500 Internal Server Error" + ); + assert!(!message.contains("stack trace")); + } + + #[tokio::test] + async fn reports_malformed_success_json() { + let (endpoint, request) = token_endpoint_once("200 OK", r#"{"access_token":42"#).await; + let client = test_client(); + + let err = post_oauth_token_grant(&client, &endpoint, &empty_grant_params()) + .await + .expect_err("should fail on malformed JSON"); + let _ = request.await; + + assert!( + err.to_string() + .contains("failed to parse token response as JSON") + ); + } + + #[tokio::test] + async fn token_exchange_posts_rfc8693_form_fields() { + let (endpoint, request) = token_endpoint_once( + "200 OK", + r#"{"access_token":"exchanged-token","token_type":"Bearer","expires_in":60}"#, + ) + .await; + let client = test_client(); + let scopes = vec!["read".to_string(), "write".to_string()]; + + let response = post_oauth_token_exchange( + &client, + &endpoint, + &TokenExchangeParams { + client_assertion: "jwt-svid-token", + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe", + subject_token: "subject-access-token", + subject_token_type: ACCESS_TOKEN_TYPE, + audience: "api://resource", + scopes: &scopes, + requested_token_type: "urn:ietf:params:oauth:token-type:id_token", + }, + ) + .await + .expect("token exchange should succeed"); + let request = request.await.expect("endpoint task"); + + assert_eq!(response.access_token, "exchanged-token"); + assert_eq!(response.expires_in, 60); + assert_eq!( + request.form.get("grant_type").map(String::as_str), + Some(TOKEN_EXCHANGE_GRANT_TYPE) + ); + assert_eq!( + request + .form + .get("client_assertion_type") + .map(String::as_str), + Some("urn:ietf:params:oauth:client-assertion-type:jwt-spiffe") + ); + assert_eq!( + request.form.get("client_assertion").map(String::as_str), + Some("jwt-svid-token") + ); + assert_eq!( + request.form.get("subject_token").map(String::as_str), + Some("subject-access-token") + ); + assert_eq!( + request.form.get("subject_token_type").map(String::as_str), + Some(ACCESS_TOKEN_TYPE) + ); + assert_eq!( + request.form.get("audience").map(String::as_str), + Some("api://resource") + ); + assert_eq!( + request.form.get("scope").map(String::as_str), + Some("read write") + ); + assert_eq!( + request.form.get("requested_token_type").map(String::as_str), + Some("urn:ietf:params:oauth:token-type:id_token") + ); + } + + #[tokio::test] + async fn token_exchange_applies_defaults_for_empty_type_fields() { + let (endpoint, request) = + token_endpoint_once("200 OK", r#"{"access_token":"exchanged-token"}"#).await; + let client = test_client(); + + post_oauth_token_exchange( + &client, + &endpoint, + &TokenExchangeParams { + client_assertion: "jwt-svid-token", + client_assertion_type: "", + subject_token: "subject-token", + subject_token_type: "", + audience: "", + scopes: &[], + requested_token_type: "", + }, + ) + .await + .expect("token exchange should succeed"); + let request = request.await.expect("endpoint task"); + + assert_eq!( + request + .form + .get("client_assertion_type") + .map(String::as_str), + Some(DEFAULT_CLIENT_ASSERTION_TYPE) + ); + assert_eq!( + request.form.get("subject_token_type").map(String::as_str), + Some(ACCESS_TOKEN_TYPE) + ); + assert_eq!( + request.form.get("requested_token_type").map(String::as_str), + Some(ACCESS_TOKEN_TYPE) + ); + assert!(!request.form.contains_key("audience")); + assert!(!request.form.contains_key("scope")); + } + + #[test] + fn token_endpoint_url_allows_https_loopback_and_in_cluster_http() { + for endpoint in [ + "https://auth.example.com/token", + "http://127.0.0.1:8080/token", + "http://[::1]:8080/token", + "http://token-issuer.default.svc.cluster.local/token", + "http://token-issuer.default.svc/token", + ] { + parse_token_endpoint_url(endpoint).expect("should be allowed"); + } + } + + #[test] + fn token_endpoint_url_rejects_plain_http_non_cluster_hosts() { + for endpoint in [ + "http://auth.example.com/token", + "http://keycloak/realms/openshell/protocol/openid-connect/token", + "http://token-issuer.default.svc.evil.com/token", + "ftp://auth.example.com/token", + "/relative/token", + ] { + assert!( + parse_token_endpoint_url(endpoint).is_err(), + "should be rejected: {endpoint}" + ); + } + } + + #[test] + fn validate_access_token_accepts_token68_values() { + for token in [ + "abcXYZ123-._~+/", + "eyJhbGciOiJSUzI1NiJ9.payload.sig", + "token==", + ] { + validate_access_token(token).expect("should be accepted"); + } + } + + #[test] + fn validate_access_token_rejects_non_token68_values() { + for token in [ + "", + "token with spaces", + "token\r\nX-Injected: yes", + "token\u{7f}", + "tokené", + "token=continued", + "==", + ] { + let err = validate_access_token(token).expect_err("should be rejected"); + assert_eq!( + err.to_string(), + "token grant returned a malformed access token" + ); + } + } + + #[test] + fn failure_message_reports_oauth_error_fields() { + let message = failure_message( + reqwest::StatusCode::UNAUTHORIZED, + r#"{"error":"invalid_client","error_description":"Invalid client credentials"}"#, + ); + assert_eq!( + message, + "token grant failed with status 401 Unauthorized: error=invalid_client; error_description=Invalid client credentials" + ); + } + + #[test] + fn failure_message_omits_unstructured_response_body() { + let message = failure_message( + reqwest::StatusCode::INTERNAL_SERVER_ERROR, + "internal error containing implementation details", + ); + assert_eq!( + message, + "token grant failed with status 500 Internal Server Error" + ); + } + + #[test] + fn failure_message_sanitizes_oauth_error_fields() { + let long_description = "a".repeat(MAX_OAUTH_ERROR_FIELD_LEN + 20); + let body = + format!(r#"{{"error":"invalid_client\n","error_description":"{long_description}"}}"#); + let message = failure_message(reqwest::StatusCode::UNAUTHORIZED, &body); + assert!(!message.contains('\n')); + assert!(message.contains("error=invalid_client")); + assert!(message.contains(&"a".repeat(MAX_OAUTH_ERROR_FIELD_LEN))); + assert!(!message.contains(&"a".repeat(MAX_OAUTH_ERROR_FIELD_LEN + 1))); + } +} diff --git a/crates/openshell-core/src/spiffe.rs b/crates/openshell-core/src/spiffe.rs new file mode 100644 index 0000000000..c288dfd81a --- /dev/null +++ b/crates/openshell-core/src/spiffe.rs @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared SPIFFE helpers used by the gateway and sandbox supervisor. + +use std::path::Path; + +use base64::Engine as _; +use serde::Deserialize; + +/// SPIFFE JWT-SVID claims used by `OpenShell` token exchange flows. +#[derive(Debug, Clone, Deserialize)] +pub struct SpiffeJwtClaims { + pub iss: String, + pub sub: String, + pub aud: AudienceClaim, + #[serde(default)] + pub exp: i64, +} + +/// JWT `aud` claim representation accepted by SPIFFE JWT-SVIDs. +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum AudienceClaim { + One(String), + Many(Vec), +} + +impl AudienceClaim { + pub fn contains(&self, expected: &str) -> bool { + match self { + Self::One(value) => value == expected, + Self::Many(values) => values.iter().any(|value| value == expected), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum JwtSvidParseError { + #[error("invalid JWT-SVID format")] + Format, + #[error("invalid JWT-SVID payload encoding")] + PayloadEncoding, + #[error("invalid JWT-SVID payload")] + Payload, +} + +/// Convert a path to a SPIFFE Workload API endpoint URL. +/// +/// If the path already has a scheme (`unix:` or `tcp:`), use it as-is. +/// Otherwise, assume it is a Unix socket path and prepend `unix:`. +pub fn workload_api_endpoint(path: &Path) -> String { + let path = path.to_string_lossy(); + if path.starts_with("unix:") || path.starts_with("tcp:") { + path.into_owned() + } else { + format!("unix:{path}") + } +} + +/// # Security +/// +/// This function decodes the JWT payload without verifying the signature. +/// Callers must separately verify the JWT signature before trusting the claims. +pub fn parse_unverified_jwt_svid_claims(token: &str) -> Result { + let segments = token.split('.').collect::>(); + if segments.len() != 3 || segments.iter().any(|segment| segment.is_empty()) { + return Err(JwtSvidParseError::Format); + } + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(segments[1]) + .map_err(|_| JwtSvidParseError::PayloadEncoding)?; + serde_json::from_slice::(&decoded).map_err(|_| JwtSvidParseError::Payload) +} + +pub fn trust_domain(subject: &str) -> Option<&str> { + let rest = subject.strip_prefix("spiffe://")?; + let (trust_domain, _) = rest.split_once('/').unwrap_or((rest, "")); + (!trust_domain.is_empty()).then_some(trust_domain) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unsigned_svid_fixture(issuer: &str, subject: &str, audience: serde_json::Value) -> String { + let header = serde_json::json!({ "alg": "RS256", "kid": "test-key" }); + let payload = serde_json::json!({ + "iss": issuer, + "sub": subject, + "aud": audience, + "exp": 4_102_444_800_i64 + }); + let encoded_header = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&header).expect("serialize header")); + let encoded_payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&payload).expect("serialize payload")); + format!("{encoded_header}.{encoded_payload}.signature") + } + + #[test] + fn workload_api_endpoint_preserves_explicit_scheme() { + assert_eq!( + workload_api_endpoint(Path::new("unix:/run/spire/agent.sock")), + "unix:/run/spire/agent.sock" + ); + assert_eq!( + workload_api_endpoint(Path::new("tcp:127.0.0.1:8081")), + "tcp:127.0.0.1:8081" + ); + } + + #[test] + fn workload_api_endpoint_defaults_to_unix_socket() { + assert_eq!( + workload_api_endpoint(Path::new("/run/spire/agent.sock")), + "unix:/run/spire/agent.sock" + ); + } + + #[test] + fn parse_unverified_jwt_svid_claims_accepts_string_audience() { + let token = unsigned_svid_fixture( + "https://spiffe.example.test", + "spiffe://openshell/openshell/sandbox/sb-a", + serde_json::json!("https://auth.example.com"), + ); + + let claims = parse_unverified_jwt_svid_claims(&token).expect("valid claims"); + + assert_eq!(claims.iss, "https://spiffe.example.test"); + assert_eq!(claims.sub, "spiffe://openshell/openshell/sandbox/sb-a"); + assert!(claims.aud.contains("https://auth.example.com")); + assert!(!claims.aud.contains("https://other.example.com")); + } + + #[test] + fn parse_unverified_jwt_svid_claims_accepts_array_audience() { + let token = unsigned_svid_fixture( + "https://spiffe.example.test", + "spiffe://openshell/openshell/sandbox/sb-a", + serde_json::json!(["https://auth.example.com", "https://other.example.com"]), + ); + + let claims = parse_unverified_jwt_svid_claims(&token).expect("valid claims"); + + assert!(claims.aud.contains("https://auth.example.com")); + assert!(claims.aud.contains("https://other.example.com")); + } + + #[test] + fn parse_unverified_jwt_svid_claims_rejects_truncated_jwt() { + assert!(matches!( + parse_unverified_jwt_svid_claims("header.payload"), + Err(JwtSvidParseError::Format) + )); + } + + #[test] + fn parse_unverified_jwt_svid_claims_rejects_empty_jwt_segments() { + assert!(matches!( + parse_unverified_jwt_svid_claims("header..signature"), + Err(JwtSvidParseError::Format) + )); + } + + #[test] + fn trust_domain_extracts_domain_from_spiffe_id() { + assert_eq!( + trust_domain("spiffe://openshell/openshell/sandbox/sb-a"), + Some("openshell") + ); + assert_eq!(trust_domain("spiffe://openshell"), Some("openshell")); + assert_eq!(trust_domain("not-a-spiffe-id"), None); + assert_eq!(trust_domain("spiffe:///empty"), None); + } +} diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index 783611e507..b3a3c548e5 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -126,6 +126,9 @@ pub struct PodmanComputeConfig { /// `template.driver_config`. #[serde(default)] pub enable_bind_mounts: bool, + /// Host path to a SPIFFE Workload API Unix socket exposed to sandbox + /// supervisors for provider token exchange client assertions. + pub provider_spiffe_workload_api_socket: Option, /// Health check interval in seconds for sandbox containers. /// /// Podman runs the health check command at this interval to determine @@ -503,6 +506,7 @@ impl Default for PodmanComputeConfig { guest_tls_key: None, sandbox_pids_limit: DEFAULT_SANDBOX_PIDS_LIMIT, enable_bind_mounts: false, + provider_spiffe_workload_api_socket: None, health_check_interval_secs: DEFAULT_HEALTH_CHECK_INTERVAL_SECS, https_proxy: None, no_proxy: None, @@ -534,6 +538,10 @@ impl std::fmt::Debug for PodmanComputeConfig { .field("guest_tls_key", &self.guest_tls_key) .field("sandbox_pids_limit", &self.sandbox_pids_limit) .field("enable_bind_mounts", &self.enable_bind_mounts) + .field( + "provider_spiffe_workload_api_socket", + &self.provider_spiffe_workload_api_socket, + ) .field( "health_check_interval_secs", &self.health_check_interval_secs, diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 6df5d54f9b..cb73e4b7e7 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -64,6 +64,8 @@ const TLS_KEY_MOUNT_PATH: &str = openshell_core::driver_utils::TLS_KEY_MOUNT_PAT const SANDBOX_TOKEN_MOUNT_PATH: &str = openshell_core::driver_utils::SANDBOX_TOKEN_MOUNT_PATH; const UPSTREAM_PROXY_AUTH_MOUNT_PATH: &str = openshell_core::driver_utils::UPSTREAM_PROXY_AUTH_MOUNT_PATH; +const PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR: &str = + openshell_core::driver_utils::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR; /// Directory inside sandbox containers where the supervisor binary is mounted. const SUPERVISOR_MOUNT_DIR: &str = openshell_core::driver_utils::SUPERVISOR_CONTAINER_DIR; @@ -555,6 +557,13 @@ fn build_env( ); } + if let Some(socket_path) = provider_spiffe_workload_api_socket_env_value(config) { + env.insert( + openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET.into(), + socket_path, + ); + } + env.remove(openshell_core::sandbox_env::SANDBOX_TOKEN); env.remove(openshell_core::sandbox_env::SANDBOX_TOKEN_FILE); // Prevent user-supplied environment from overriding the TLS server name @@ -1300,6 +1309,17 @@ pub fn build_container_spec_for_image( options: opts, }); } + if let Some(path) = provider_spiffe_workload_api_socket_mount_source(config) { + // No SELinux relabel - the SPIRE agent socket is shared host + // infrastructure and must keep its existing SELinux context. + let ro = vec!["ro".into(), "rbind".into()]; + m.push(Mount { + kind: "bind".into(), + source: path.display().to_string(), + destination: PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR.into(), + options: ro, + }); + } m.extend(user_mounts.mounts); m }, @@ -1346,6 +1366,33 @@ pub fn build_container_spec_for_image( Ok(serde_json::to_value(container_spec).expect("ContainerSpec serialization cannot fail")) } +fn provider_spiffe_workload_api_socket_env_value(config: &PodmanComputeConfig) -> Option { + let host_path = config.provider_spiffe_workload_api_socket.as_ref()?; + let raw = host_path.to_str()?; + if raw.starts_with("tcp:") { + return Some(raw.to_string()); + } + let host_path = raw + .strip_prefix("unix:") + .map_or(host_path.as_path(), Path::new); + let file_name = host_path.file_name()?.to_str()?; + Some(format!( + "{PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR}/{file_name}" + )) +} + +fn provider_spiffe_workload_api_socket_mount_source(config: &PodmanComputeConfig) -> Option<&Path> { + let host_path = config.provider_spiffe_workload_api_socket.as_ref()?; + let raw = host_path.to_str()?; + if raw.starts_with("tcp:") { + return None; + } + let host_path = raw + .strip_prefix("unix:") + .map_or(host_path.as_path(), Path::new); + host_path.parent() +} + fn hostadd_entries(config: &PodmanComputeConfig) -> Vec { let host_gateway_ip = config.host_gateway_ip.trim(); if host_gateway_ip.is_empty() { @@ -2969,6 +3016,33 @@ mod tests { ); } + #[test] + fn container_spec_includes_provider_spiffe_socket_when_configured() { + let sandbox = test_sandbox("spiffe-id", "spiffe-name"); + let mut config = test_config(); + config.provider_spiffe_workload_api_socket = + Some(std::path::PathBuf::from("/host/spire-agent.sock")); + + let spec = build_container_spec(&sandbox, &config); + + let env_map = spec["env"].as_object().expect("env should be an object"); + assert_eq!( + env_map + .get(openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET) + .and_then(|v| v.as_str()), + Some("/spiffe-workload-api/spire-agent.sock"), + ); + + let mounts = spec["mounts"] + .as_array() + .expect("mounts should be an array"); + assert!(mounts.iter().any(|m| { + m["type"].as_str() == Some("bind") + && m["source"].as_str() == Some("/host") + && m["destination"].as_str() == Some(PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR) + })); + } + #[test] fn container_spec_omits_tls_without_config() { let sandbox = test_sandbox("notls-id", "notls-name"); diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 243d34bc1c..6e87826c4d 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -9,7 +9,8 @@ use openshell_core::proto::{ GraphqlOperation, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, McpOptions, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProviderCredentialRefresh, ProviderCredentialRefreshMaterial, ProviderCredentialRefreshOutput, - ProviderCredentialRefreshStrategy, ProviderProfile, ProviderProfileCategory, + ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrantSubjectToken, + ProviderCredentialTokenGrantType, ProviderProfile, ProviderProfileCategory, ProviderProfileCredential, ProviderProfileDiscovery, }; use openshell_core::secrets::uses_reserved_revision_namespace; @@ -113,6 +114,13 @@ pub struct CredentialProfile { #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct TokenGrantProfile { + #[serde( + default = "default_token_grant_type", + deserialize_with = "deserialize_token_grant_type", + serialize_with = "serialize_token_grant_type", + skip_serializing_if = "is_client_credentials_grant" + )] + pub grant_type: ProviderCredentialTokenGrantType, pub token_endpoint: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub audience: String, @@ -126,6 +134,18 @@ pub struct TokenGrantProfile { pub cache_ttl_seconds: i64, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub audience_overrides: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject_token: Option, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub requested_token_type: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct TokenGrantSubjectTokenProfile { + pub source: String, + pub credential: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub subject_token_type: String, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] @@ -839,6 +859,26 @@ fn default_refresh_strategy() -> ProviderCredentialRefreshStrategy { ProviderCredentialRefreshStrategy::Unspecified } +fn default_token_grant_type() -> ProviderCredentialTokenGrantType { + ProviderCredentialTokenGrantType::ClientCredentials +} + +fn effective_token_grant_type( + grant_type: ProviderCredentialTokenGrantType, +) -> ProviderCredentialTokenGrantType { + match grant_type { + ProviderCredentialTokenGrantType::Unspecified => { + ProviderCredentialTokenGrantType::ClientCredentials + } + other => other, + } +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +fn is_client_credentials_grant(value: &ProviderCredentialTokenGrantType) -> bool { + effective_token_grant_type(*value) == ProviderCredentialTokenGrantType::ClientCredentials +} + fn deserialize_category<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, @@ -881,6 +921,28 @@ where serializer.serialize_str(provider_refresh_strategy_to_yaml(*strategy)) } +fn deserialize_token_grant_type<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let raw = String::deserialize(deserializer)?; + provider_token_grant_type_from_yaml(&raw) + .ok_or_else(|| de::Error::custom(format!("unsupported provider token grant type: {raw}"))) +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +fn serialize_token_grant_type( + grant_type: &ProviderCredentialTokenGrantType, + serializer: S, +) -> Result +where + S: Serializer, +{ + serializer.serialize_str(provider_token_grant_type_to_yaml(*grant_type)) +} + #[must_use] pub fn provider_profile_category_from_yaml(raw: &str) -> Option { match raw.trim().to_ascii_lowercase().replace('-', "_").as_str() { @@ -941,6 +1003,26 @@ pub fn provider_refresh_strategy_to_yaml( } } +#[must_use] +pub fn provider_token_grant_type_from_yaml(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().replace('-', "_").as_str() { + "" | "client_credentials" => Some(ProviderCredentialTokenGrantType::ClientCredentials), + "token_exchange" => Some(ProviderCredentialTokenGrantType::TokenExchange), + _ => None, + } +} + +#[must_use] +pub fn provider_token_grant_type_to_yaml( + grant_type: ProviderCredentialTokenGrantType, +) -> &'static str { + match grant_type { + ProviderCredentialTokenGrantType::TokenExchange => "token_exchange", + ProviderCredentialTokenGrantType::ClientCredentials + | ProviderCredentialTokenGrantType::Unspecified => "client_credentials", + } +} + fn credential_refresh_from_proto(refresh: &ProviderCredentialRefresh) -> CredentialRefreshProfile { CredentialRefreshProfile { strategy: ProviderCredentialRefreshStrategy::try_from(refresh.strategy) @@ -1002,6 +1084,10 @@ fn token_grant_from_proto( token_grant: &openshell_core::proto::ProviderCredentialTokenGrant, ) -> TokenGrantProfile { TokenGrantProfile { + grant_type: effective_token_grant_type( + ProviderCredentialTokenGrantType::try_from(token_grant.grant_type) + .unwrap_or(ProviderCredentialTokenGrantType::ClientCredentials), + ), token_endpoint: token_grant.token_endpoint.clone(), audience: token_grant.audience.clone(), jwt_svid_audience: token_grant.jwt_svid_audience.clone(), @@ -1013,6 +1099,11 @@ fn token_grant_from_proto( .iter() .map(token_grant_audience_override_from_proto) .collect(), + subject_token: token_grant + .subject_token + .as_ref() + .map(token_grant_subject_token_from_proto), + requested_token_type: token_grant.requested_token_type.clone(), } } @@ -1020,6 +1111,7 @@ fn token_grant_to_proto( token_grant: &TokenGrantProfile, ) -> openshell_core::proto::ProviderCredentialTokenGrant { openshell_core::proto::ProviderCredentialTokenGrant { + grant_type: token_grant.grant_type as i32, token_endpoint: token_grant.token_endpoint.clone(), audience: token_grant.audience.clone(), jwt_svid_audience: token_grant.jwt_svid_audience.clone(), @@ -1031,6 +1123,31 @@ fn token_grant_to_proto( .iter() .map(token_grant_audience_override_to_proto) .collect(), + subject_token: token_grant + .subject_token + .as_ref() + .map(token_grant_subject_token_to_proto), + requested_token_type: token_grant.requested_token_type.clone(), + } +} + +fn token_grant_subject_token_from_proto( + subject_token: &ProviderCredentialTokenGrantSubjectToken, +) -> TokenGrantSubjectTokenProfile { + TokenGrantSubjectTokenProfile { + source: subject_token.source.clone(), + credential: subject_token.credential.clone(), + subject_token_type: subject_token.subject_token_type.clone(), + } +} + +fn token_grant_subject_token_to_proto( + subject_token: &TokenGrantSubjectTokenProfile, +) -> ProviderCredentialTokenGrantSubjectToken { + ProviderCredentialTokenGrantSubjectToken { + source: subject_token.source.clone(), + credential: subject_token.credential.clone(), + subject_token_type: subject_token.subject_token_type.clone(), } } @@ -1571,6 +1688,10 @@ pub fn validate_profile_set( } } + diagnostics.extend(validate_broker_only_subject_credentials( + source, profile_id, profile, + )); + let mut env_vars = HashSet::new(); for credential in &profile.credentials { for env_var in &credential.env_vars { @@ -1842,6 +1963,12 @@ pub fn validate_profile_set( message, )); } + diagnostics.extend(validate_token_grant_subject_token( + source, + profile_id, + credential, + &credential_names, + )); diagnostics.extend(validate_token_grant_audience_overrides( source, profile_id, @@ -2328,6 +2455,133 @@ struct TokenGrantOverrideBinding { score: u32, } +fn validate_token_grant_subject_token( + source: &str, + profile_id: &str, + credential: &CredentialProfile, + credential_names: &HashSet, +) -> Vec { + let Some(token_grant) = credential.token_grant.as_ref() else { + return Vec::new(); + }; + let grant_type = effective_token_grant_type(token_grant.grant_type); + let mut diagnostics = Vec::new(); + + match grant_type { + ProviderCredentialTokenGrantType::ClientCredentials => { + if token_grant.subject_token.is_some() { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.token_grant.subject_token", + "subject_token is only valid for token_exchange grants", + )); + } + } + ProviderCredentialTokenGrantType::TokenExchange => { + let Some(subject_token) = token_grant.subject_token.as_ref() else { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.token_grant.subject_token", + "token_exchange grants require subject_token", + )); + return diagnostics; + }; + + let source_value = subject_token.source.trim(); + if source_value != "provider_credential" { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.token_grant.subject_token.source", + "subject_token.source must be provider_credential", + )); + } + + let subject_credential = subject_token.credential.trim(); + if subject_credential.is_empty() { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.token_grant.subject_token.credential", + "subject_token.credential is required", + )); + } else if !credential_names.contains(subject_credential) { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.token_grant.subject_token.credential", + format!("unknown subject token credential: {subject_credential}"), + )); + } + } + ProviderCredentialTokenGrantType::Unspecified => { + unreachable!("effective_token_grant_type must normalize unspecified token grant type") + } + } + + diagnostics +} + +fn validate_broker_only_subject_credentials( + source: &str, + profile_id: &str, + profile: &ProviderTypeProfile, +) -> Vec { + let subject_credentials = token_exchange_subject_credential_names(profile); + if subject_credentials.is_empty() { + return Vec::new(); + } + + let mut diagnostics = Vec::new(); + for credential in &profile.credentials { + let credential_name = credential.name.trim(); + if !subject_credentials.contains(credential_name) { + continue; + } + if credential_has_workload_injection_metadata(credential) { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.token_grant.subject_token.credential", + format!( + "subject token credential '{credential_name}' is broker-only and cannot declare workload injection metadata" + ), + )); + } + } + diagnostics +} + +fn token_exchange_subject_credential_names(profile: &ProviderTypeProfile) -> HashSet<&str> { + profile + .credentials + .iter() + .filter_map(|credential| credential.token_grant.as_ref()) + .filter(|token_grant| { + effective_token_grant_type(token_grant.grant_type) + == ProviderCredentialTokenGrantType::TokenExchange + }) + .filter_map(|token_grant| token_grant.subject_token.as_ref()) + .filter(|subject_token| subject_token.source.trim() == "provider_credential") + .filter_map(|subject_token| { + let credential = subject_token.credential.trim(); + (!credential.is_empty()).then_some(credential) + }) + .collect() +} + +fn credential_has_workload_injection_metadata(credential: &CredentialProfile) -> bool { + !credential.env_vars.is_empty() + || !credential.auth_style.trim().is_empty() + || !credential.header_name.trim().is_empty() + || !credential.query_param.trim().is_empty() + || !credential.path_template.trim().is_empty() + || credential.refresh.is_some() + || credential.token_grant.is_some() +} + fn validate_token_grant_audience_overrides( source: &str, profile_id: &str, @@ -2657,7 +2911,7 @@ pub fn builtin_profiles() -> &'static [ProviderTypeProfile] { mod tests { use std::collections::HashMap; - use openshell_core::proto::ProviderProfileCategory; + use openshell_core::proto::{ProviderCredentialTokenGrantType, ProviderProfileCategory}; use super::{ DiscoveryProfile, L7AllowProfile, L7QueryMatcherProfile, ProfileError, ProviderTypeProfile, @@ -3241,6 +3495,204 @@ credentials: ); } + #[test] + fn token_exchange_grant_round_trips_through_proto_and_yaml() { + let profile = parse_profile_yaml( + r" +id: keycloak-token-exchange +display_name: Keycloak Token Exchange +credentials: + - name: USER_OIDC_TOKEN + required: true + - name: access_token + auth_style: bearer + header_name: Authorization + token_grant: + grant_type: token_exchange + token_endpoint: https://keycloak.example.com/realms/openshell/protocol/openid-connect/token + subject_token: + source: provider_credential + credential: USER_OIDC_TOKEN + subject_token_type: urn:ietf:params:oauth:token-type:access_token + jwt_svid_audience: https://keycloak.example.com/realms/openshell + client_assertion_type: urn:ietf:params:oauth:client-assertion-type:jwt-bearer + audience: https://graph.example.com + scopes: [graph.read] + requested_token_type: urn:ietf:params:oauth:token-type:access_token +", + ) + .expect("profile should parse"); + + let diagnostics = + validate_profile_set(&[("keycloak-token-exchange.yaml".to_string(), profile.clone())]); + assert!( + diagnostics.is_empty(), + "unexpected diagnostics: {diagnostics:?}" + ); + + let token_grant = profile.credentials[1] + .token_grant + .as_ref() + .expect("token grant should parse"); + assert_eq!( + token_grant.grant_type, + ProviderCredentialTokenGrantType::TokenExchange + ); + assert_eq!( + token_grant + .subject_token + .as_ref() + .map(|subject| subject.credential.as_str()), + Some("USER_OIDC_TOKEN") + ); + + let from_proto = ProviderTypeProfile::from_proto(&profile.to_proto()); + assert_eq!( + from_proto.credentials[1].token_grant, + profile.credentials[1].token_grant + ); + + let exported = profile_to_yaml(&from_proto).expect("yaml"); + assert!(exported.contains("grant_type: token_exchange")); + assert!(exported.contains("subject_token:")); + let reparsed = parse_profile_yaml(&exported).expect("re-parse"); + assert_eq!( + reparsed.credentials[1].token_grant, + profile.credentials[1].token_grant + ); + } + + #[test] + fn validate_profile_set_rejects_token_exchange_without_subject_token() { + let profile = parse_profile_yaml( + r" +id: missing-subject-token +display_name: Missing Subject Token +credentials: + - name: access_token + auth_style: bearer + header_name: Authorization + token_grant: + grant_type: token_exchange + token_endpoint: https://keycloak.example.com/realms/openshell/protocol/openid-connect/token +", + ) + .expect("profile should parse"); + + let diagnostics = validate_profile_set(&[("missing.yaml".to_string(), profile)]); + let diagnostic = diagnostics + .iter() + .find(|diagnostic| diagnostic.field == "credentials.token_grant.subject_token") + .expect("expected subject_token diagnostic"); + assert_eq!( + diagnostic.message, + "token_exchange grants require subject_token" + ); + } + + #[test] + fn validate_profile_set_rejects_token_exchange_unknown_subject_credential() { + let profile = parse_profile_yaml( + r" +id: unknown-subject-token +display_name: Unknown Subject Token +credentials: + - name: access_token + auth_style: bearer + header_name: Authorization + token_grant: + grant_type: token_exchange + token_endpoint: https://keycloak.example.com/realms/openshell/protocol/openid-connect/token + subject_token: + source: provider_credential + credential: USER_OIDC_TOKEN +", + ) + .expect("profile should parse"); + + let diagnostics = validate_profile_set(&[("unknown.yaml".to_string(), profile)]); + let diagnostic = diagnostics + .iter() + .find(|diagnostic| { + diagnostic.field == "credentials.token_grant.subject_token.credential" + }) + .expect("expected subject token credential diagnostic"); + assert!( + diagnostic + .message + .contains("unknown subject token credential: USER_OIDC_TOKEN") + ); + } + + #[test] + fn validate_profile_set_rejects_injectable_token_exchange_subject_credential() { + let profile = parse_profile_yaml( + r" +id: injectable-subject-token +display_name: Injectable Subject Token +credentials: + - name: USER_OIDC_TOKEN + auth_style: header + header_name: X-Subject-Token + - name: access_token + auth_style: bearer + header_name: Authorization + token_grant: + grant_type: token_exchange + token_endpoint: https://keycloak.example.com/realms/openshell/protocol/openid-connect/token + subject_token: + source: provider_credential + credential: USER_OIDC_TOKEN +", + ) + .expect("profile should parse"); + + let diagnostics = validate_profile_set(&[("injectable.yaml".to_string(), profile)]); + let diagnostic = diagnostics + .iter() + .find(|diagnostic| { + diagnostic.field == "credentials.token_grant.subject_token.credential" + && diagnostic.message.contains("broker-only") + }) + .expect("expected broker-only subject token diagnostic"); + assert!( + diagnostic + .message + .contains("cannot declare workload injection metadata") + ); + } + + #[test] + fn validate_profile_set_rejects_subject_token_on_client_credentials_grant() { + let profile = parse_profile_yaml( + r" +id: misplaced-subject-token +display_name: Misplaced Subject Token +credentials: + - name: USER_OIDC_TOKEN + - name: access_token + auth_style: bearer + header_name: Authorization + token_grant: + token_endpoint: https://keycloak.example.com/realms/openshell/protocol/openid-connect/token + subject_token: + source: provider_credential + credential: USER_OIDC_TOKEN +", + ) + .expect("profile should parse"); + + let diagnostics = validate_profile_set(&[("misplaced.yaml".to_string(), profile)]); + let diagnostic = diagnostics + .iter() + .find(|diagnostic| diagnostic.field == "credentials.token_grant.subject_token") + .expect("expected subject_token diagnostic"); + assert_eq!( + diagnostic.message, + "subject_token is only valid for token_exchange grants" + ); + } + #[test] fn validate_profile_set_rejects_plain_http_token_endpoint() { for token_endpoint in [ diff --git a/crates/openshell-sdk/tests/client_mock.rs b/crates/openshell-sdk/tests/client_mock.rs index c5467b809d..4b06b9c1e5 100644 --- a/crates/openshell-sdk/tests/client_mock.rs +++ b/crates/openshell-sdk/tests/client_mock.rs @@ -499,6 +499,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(proto::GetGatewayConfigResponse::default())) } + async fn exchange_provider_subject_token( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn update_config( &self, _: tonic::Request, diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 215f6abfa4..9bcf18baca 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -16,7 +16,7 @@ path = "src/main.rs" [dependencies] openshell-bootstrap = { path = "../openshell-bootstrap" } -openshell-core = { path = "../openshell-core", default-features = false } +openshell-core = { path = "../openshell-core", default-features = false, features = ["oauth"] } openshell-driver-db-credstore = { path = "../openshell-driver-db-credstore" } openshell-driver-kubernetes-secrets = { path = "../openshell-driver-kubernetes-secrets" } openshell-driver-vault = { path = "../openshell-driver-vault" } @@ -100,6 +100,7 @@ uuid = { workspace = true } hmac = "0.12" sha2 = { workspace = true } jsonwebtoken = { workspace = true } +spiffe = { workspace = true } async-trait = "0.1" url = { workspace = true } glob = { workspace = true } diff --git a/crates/openshell-server/src/auth/oidc.rs b/crates/openshell-server/src/auth/oidc.rs index fd599b501a..475c52ebec 100644 --- a/crates/openshell-server/src/auth/oidc.rs +++ b/crates/openshell-server/src/auth/oidc.rs @@ -240,6 +240,7 @@ impl JwksCache { let Some(ref kid) = key.kid else { continue; }; + crate::install_jsonwebtoken_crypto_provider(); match DecodingKey::from_rsa_components(&key.n, &key.e) { Ok(dk) => { new_keys.insert(kid.clone(), dk); @@ -286,6 +287,8 @@ impl JwksCache { /// This is the authentication step — it verifies the caller's identity /// but does not check authorization (that's `authz::AuthzPolicy::check`). pub async fn validate_token(&self, token: &str) -> Result { + crate::install_jsonwebtoken_crypto_provider(); + self.refresh_if_stale().await.map_err(|e| { warn!(error = %e, "JWKS refresh failed"); Status::internal("OIDC key refresh failed") @@ -568,6 +571,8 @@ mod tests { /// Sign `claims` with the test key, tagging the header with `kid`. fn mint_rs256(claims: &serde_json::Value, kid: &str) -> String { + crate::install_jsonwebtoken_crypto_provider(); + let mut header = jsonwebtoken::Header::new(Algorithm::RS256); header.kid = Some(kid.to_owned()); let key = jsonwebtoken::EncodingKey::from_rsa_pem(TEST_RSA_KEY.private_pem.as_bytes()) @@ -682,6 +687,7 @@ mod tests { let cache = cache_with_mock_issuer(&server).await; let other = TestRsaKey::generate(); + crate::install_jsonwebtoken_crypto_provider(); let mut header = jsonwebtoken::Header::new(Algorithm::RS256); header.kid = Some(TEST_KID.to_owned()); let token = jsonwebtoken::encode( diff --git a/crates/openshell-server/src/auth/sandbox_jwt.rs b/crates/openshell-server/src/auth/sandbox_jwt.rs index 3d66824f20..9dc10b8401 100644 --- a/crates/openshell-server/src/auth/sandbox_jwt.rs +++ b/crates/openshell-server/src/auth/sandbox_jwt.rs @@ -112,6 +112,8 @@ impl SandboxJwtIssuer { gateway_id: &str, ttl: Duration, ) -> Result { + crate::install_jsonwebtoken_crypto_provider(); + let encoding_key = EncodingKey::from_ed_pem(signing_key_pem) .map_err(|e| format!("failed to parse Ed25519 signing key PEM: {e}"))?; let identity = format!("openshell-gateway:{gateway_id}"); @@ -127,6 +129,8 @@ impl SandboxJwtIssuer { /// Mint a fresh token for `sandbox_id`. #[allow(clippy::result_large_err)] // `tonic::Status` is the natural error here pub fn mint(&self, sandbox_id: &str) -> Result { + crate::install_jsonwebtoken_crypto_provider(); + let now = now_secs(); let exp = if self.ttl.is_zero() { 0 @@ -248,6 +252,8 @@ impl std::fmt::Debug for SandboxJwtAuthenticator { impl SandboxJwtAuthenticator { pub fn from_pem(public_key_pem: &[u8], kid: String, gateway_id: &str) -> Result { + crate::install_jsonwebtoken_crypto_provider(); + let decoding_key = DecodingKey::from_ed_pem(public_key_pem) .map_err(|e| format!("failed to parse Ed25519 public key PEM: {e}"))?; let jwks = GatewayJwks::from_public_key_pem(public_key_pem, kid.clone())?; @@ -276,6 +282,8 @@ impl SandboxJwtAuthenticator { #[allow(clippy::result_large_err)] fn validate_bearer(&self, token: &str) -> Result, Status> { + crate::install_jsonwebtoken_crypto_provider(); + let header = decode_header(token).map_err(|e| { debug!(error = %e, "sandbox JWT header decode failed"); Status::unauthenticated("invalid token") diff --git a/crates/openshell-server/src/auth/sandbox_methods.rs b/crates/openshell-server/src/auth/sandbox_methods.rs index 5cc9e3693a..89f34d1253 100644 --- a/crates/openshell-server/src/auth/sandbox_methods.rs +++ b/crates/openshell-server/src/auth/sandbox_methods.rs @@ -32,6 +32,9 @@ mod tests { assert!(is_sandbox_callable( "/openshell.inference.v1.Inference/GetInferenceBundle" )); + assert!(is_sandbox_callable( + "/openshell.v1.OpenShell/ExchangeProviderSubjectToken" + )); } #[test] diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index 2f9b2a5ef5..f502369bcf 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -23,7 +23,8 @@ use openshell_core::proto::{ DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, DeleteServiceRequest, DeleteServiceResponse, DeleteWorkspaceRequest, DeleteWorkspaceResponse, DetachSandboxProviderRequest, DetachSandboxProviderResponse, EditDraftChunkRequest, - EditDraftChunkResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, + EditDraftChunkResponse, ExchangeProviderSubjectTokenRequest, + ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, ExposeServiceRequest, GatewayMessage, GetCurrentUserRequest, GetCurrentUserResponse, GetDraftHistoryRequest, GetDraftHistoryResponse, GetDraftPolicyRequest, GetDraftPolicyResponse, GetGatewayConfigRequest, GetGatewayConfigResponse, GetGatewayInfoRequest, @@ -543,6 +544,13 @@ impl OpenShell for OpenShellService { policy::handle_get_sandbox_provider_environment(&self.state, request).await } + async fn exchange_provider_subject_token( + &self, + request: Request, + ) -> Result, Status> { + provider::handle_exchange_provider_subject_token(&self.state, request).await + } + async fn update_config( &self, request: Request, diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 9ac8a42786..114b43aba0 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -10122,6 +10122,128 @@ mod tests { ); } + #[tokio::test] + async fn provider_environment_withholds_token_exchange_subject_credential() { + use openshell_core::proto::{ + GetSandboxProviderEnvironmentRequest, ProviderCredentialTokenGrant, + ProviderCredentialTokenGrantSubjectToken, ProviderCredentialTokenGrantType, + ProviderProfile, ProviderProfileCategory, ProviderProfileCredential, + StoredProviderProfile, + }; + + let state = test_server_state().await; + let profile = StoredProviderProfile { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "profile-token-exchange-subject".to_string(), + name: "token-exchange-subject".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + profile: Some(ProviderProfile { + id: "token-exchange-subject".to_string(), + display_name: "Token Exchange Subject".to_string(), + category: ProviderProfileCategory::Other as i32, + credentials: vec![ + ProviderProfileCredential { + name: "subject_token".to_string(), + ..Default::default() + }, + ProviderProfileCredential { + name: "access_token".to_string(), + auth_style: "bearer".to_string(), + header_name: "authorization".to_string(), + token_grant: Some(ProviderCredentialTokenGrant { + grant_type: ProviderCredentialTokenGrantType::TokenExchange as i32, + token_endpoint: "https://auth.example.test/token".to_string(), + audience: "api://exchange".to_string(), + subject_token: Some(ProviderCredentialTokenGrantSubjectToken { + source: "provider_credential".to_string(), + credential: "subject_token".to_string(), + subject_token_type: "urn:ietf:params:oauth:token-type:access_token" + .to_string(), + }), + ..Default::default() + }), + ..Default::default() + }, + ], + endpoints: vec![NetworkEndpoint { + host: "api.exchange.example.test".to_string(), + port: 443, + protocol: "rest".to_string(), + access: "full".to_string(), + ..Default::default() + }], + ..Default::default() + }), + }; + state.store.put_message(&profile).await.unwrap(); + + let mut provider = test_provider("exchange-provider", "token-exchange-subject"); + provider.credentials = HashMap::from([( + "subject_token".to_string(), + "raw-gateway-oidc-token".to_string(), + )]); + provider.credential_expires_at_ms = + HashMap::from([("subject_token".to_string(), current_time_ms() + 60_000)]); + state.store.put_message(&provider).await.unwrap(); + state + .store + .put_message(&test_sandbox( + "sb-token-exchange-subject", + "token-exchange-subject", + test_policy_with_rule("sandbox_only", "sandbox.example.com"), + vec!["exchange-provider".to_string()], + )) + .await + .unwrap(); + + let response = handle_get_sandbox_provider_environment( + &state, + with_user(Request::new(GetSandboxProviderEnvironmentRequest { + sandbox_id: "sb-token-exchange-subject".to_string(), + supports_static_credential_bindings: true, + })), + ) + .await + .unwrap() + .into_inner(); + + assert!( + !response.environment.contains_key("subject_token"), + "token-exchange subject credentials must not enter sandbox environment material" + ); + assert!( + !response + .static_credential_bindings + .contains_key("subject_token"), + "token-exchange subject credentials must not own workload placeholders" + ); + assert!( + !response + .credential_expires_at_ms + .contains_key("subject_token"), + "withheld subject credentials must not emit sandbox expiry metadata" + ); + let dynamic_access_token = response + .dynamic_credentials + .values() + .find(|credential| credential.name == "access_token") + .expect("dynamic access_token credential should remain available"); + assert_eq!( + dynamic_access_token + .token_grant + .as_ref() + .and_then(|grant| grant.subject_token.as_ref()) + .map(|subject| subject.credential.as_str()), + Some("subject_token") + ); + } + #[tokio::test] async fn provider_env_revision_changes_on_consecutive_provider_updates_without_delay() { use openshell_core::proto::GetSandboxProviderEnvironmentRequest; diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index add09ea7ca..e20edc055f 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -17,9 +17,9 @@ use crate::provider_profile_sources::{ use openshell_core::metadata::ObjectWorkspace; use openshell_core::proto::{ CredentialHandle, Provider, ProviderCredentialRefreshStrategy, - ProviderCredentialTokenGrantAudienceOverride, ProviderProfile, ProviderProfileCredential, - Sandbox, StaticCredentialBinding, StaticCredentialEndpointBinding, - StoredProviderCredentialRefreshState, + ProviderCredentialTokenGrantAudienceOverride, ProviderCredentialTokenGrantType, + ProviderProfile, ProviderProfileCredential, Sandbox, StaticCredentialBinding, + StaticCredentialEndpointBinding, StoredProviderCredentialRefreshState, }; use openshell_core::telemetry::{ LifecycleOperation, ProviderProfile as TelemetryProviderProfile, TelemetryOutcome, @@ -36,6 +36,8 @@ use super::{ MAX_MAP_KEY_LEN, MAX_MAP_VALUE_LEN, MAX_PAGE_SIZE, MAX_PROVIDER_CONFIG_ENTRIES, clamp_limit, }; +const GATEWAY_SPIFFE_WORKLOAD_API_SOCKET: &str = "OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET"; + // --------------------------------------------------------------------------- // CRUD helpers // --------------------------------------------------------------------------- @@ -1096,11 +1098,15 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); let profile = get_provider_type_profile_for_scope(catalog, profile_id, &provider.profile_workspace); - let profile_endpoints = profile.as_ref().map(|profile| { + let profile_proto = profile.as_ref().map(ProviderTypeProfile::to_proto); + let broker_only_credential_keys = profile_proto + .as_ref() + .map(broker_only_provider_credential_keys) + .unwrap_or_default(); + let profile_endpoints = profile_proto.as_ref().map(|profile| { profile - .to_proto() .endpoints - .into_iter() + .iter() .flat_map(|endpoint| { endpoint_ports(endpoint.port, &endpoint.ports) .into_iter() @@ -1137,7 +1143,9 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin let refresh_epochs = refresh_authorization_epochs_by_key(record)?; for (key, value) in &provider.credentials { - if is_non_injectable_provider_credential(provider, key) { + if is_non_injectable_provider_credential(provider, key) + || broker_only_credential_keys.contains(key) + { warn!( provider_name = %name, key = %key, @@ -1207,7 +1215,9 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin .resolve_provider_handles(provider, now_ms) .await?; for (key, value) in resolved_refs.values { - if is_non_injectable_provider_credential(provider, &key) { + if is_non_injectable_provider_credential(provider, &key) + || broker_only_credential_keys.contains(&key) + { warn!( provider_name = %name, key = %key, @@ -1796,7 +1806,7 @@ async fn validate_provider_environment_keys_unique_at( &mut seen_credentials, &mut seen_plugin_config, &provider_name, - active_provider_environment_keys(store, &provider, now_ms).await?, + active_provider_environment_keys(store, catalog, &provider, now_ms).await?, provider_plugin_environment_keys(&provider), )?; dynamic_bindings.extend(dynamic_token_grant_bindings_for_provider_with_catalog( @@ -1824,6 +1834,7 @@ async fn validate_provider_environment_records_unique_at( &record.name, active_provider_environment_keys_for_identity( store, + catalog, provider, &record.object_id, now_ms, @@ -2055,20 +2066,30 @@ fn validate_dynamic_token_grant_bindings_unambiguous( async fn active_provider_environment_keys( store: &Store, + catalog: &EffectiveProviderProfileCatalog, provider: &Provider, now_ms: i64, ) -> Result, Status> { - active_provider_environment_keys_for_identity(store, provider, provider.object_id(), now_ms) - .await + active_provider_environment_keys_for_identity( + store, + catalog, + provider, + provider.object_id(), + now_ms, + ) + .await } async fn active_provider_environment_keys_for_identity( store: &Store, + catalog: &EffectiveProviderProfileCatalog, provider: &Provider, provider_identity: &str, now_ms: i64, ) -> Result, Status> { - let mut keys = active_provider_credential_keys(provider, now_ms); + let broker_only_credential_keys = + broker_only_provider_credential_keys_for_provider(catalog, provider); + let mut keys = active_provider_credential_keys(provider, now_ms, &broker_only_credential_keys); if !provider_identity.is_empty() { for state in crate::provider_refresh::list_refresh_states_for_provider(store, provider_identity) @@ -2079,6 +2100,7 @@ async fn active_provider_environment_keys_for_identity( keys.extend( std::iter::once(state.credential_key) .chain(state.additional_output_keys.into_values()) + .filter(|key| !broker_only_credential_keys.contains(key)) .filter(|key| is_valid_env_key(key)), ); } @@ -2088,11 +2110,16 @@ async fn active_provider_environment_keys_for_identity( Ok(keys) } -fn active_provider_credential_keys(provider: &Provider, now_ms: i64) -> Vec { +fn active_provider_credential_keys( + provider: &Provider, + now_ms: i64, + broker_only_credential_keys: &HashSet, +) -> Vec { let mut keys: Vec = provider .credentials .keys() .filter(|key| !is_non_injectable_provider_credential(provider, key)) + .filter(|key| !broker_only_credential_keys.contains(*key)) .filter(|key| is_valid_env_key(key)) .filter(|key| provider_credential_not_expired(provider, key, now_ms)) .cloned() @@ -2102,6 +2129,7 @@ fn active_provider_credential_keys(provider: &Provider, now_ms: i64) -> Vec Vec HashSet { + let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); + get_provider_type_profile_for_scope(catalog, profile_id, &provider.profile_workspace) + .as_ref() + .map(ProviderTypeProfile::to_proto) + .map(|profile| broker_only_provider_credential_keys(&profile)) + .unwrap_or_default() +} + +fn broker_only_provider_credential_keys(profile: &ProviderProfile) -> HashSet { + profile + .credentials + .iter() + .filter_map(|credential| credential.token_grant.as_ref()) + .filter(|token_grant| { + ProviderCredentialTokenGrantType::try_from(token_grant.grant_type).is_ok_and( + |grant_type| grant_type == ProviderCredentialTokenGrantType::TokenExchange, + ) + }) + .filter_map(|token_grant| token_grant.subject_token.as_ref()) + .filter(|subject_token| subject_token.source.trim() == "provider_credential") + .filter_map(|subject_token| { + let key = subject_token.credential.trim(); + (!key.is_empty()).then(|| key.to_string()) + }) + .collect() +} + fn provider_credential_not_expired(provider: &Provider, key: &str, now_ms: i64) -> bool { provider .credential_expires_at_ms @@ -2152,6 +2211,7 @@ use openshell_core::proto::{ ConfigureProviderRefreshRequest, ConfigureProviderRefreshResponse, CreateProviderRequest, DeleteProviderProfileRequest, DeleteProviderProfileResponse, DeleteProviderRefreshRequest, DeleteProviderRefreshResponse, DeleteProviderRequest, DeleteProviderResponse, + ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, GetProviderProfileRequest, GetProviderRefreshStatusRequest, GetProviderRefreshStatusResponse, GetProviderRequest, ImportProviderProfilesRequest, ImportProviderProfilesResponse, LintProviderProfilesRequest, LintProviderProfilesResponse, ListProviderProfilesRequest, @@ -2161,16 +2221,104 @@ use openshell_core::proto::{ StoredProviderProfile, UpdateProviderProfilesRequest, UpdateProviderProfilesResponse, UpdateProviderRequest, }; +use openshell_core::spiffe::{ + JwtSvidParseError, SpiffeJwtClaims, parse_unverified_jwt_svid_claims, + trust_domain as spiffe_trust_domain, workload_api_endpoint, +}; use openshell_providers::{ CredentialRefreshProfile, ProfileValidationDiagnostic, ProviderTypeProfile, normalize_profile_id, normalize_provider_type, strategy_output_env_key, strategy_output_spec, strategy_primary_env_key, validate_profile_set, }; -use std::sync::Arc; +use std::sync::{Arc, LazyLock, RwLock}; use tonic::{Request, Response}; use crate::auth::principal::Principal; use crate::auth::workspace_authz::{MinWorkspaceRole, authorize_workspace, require_platform_admin}; +use openshell_core::oauth::{ + self, TokenExchangeParams, effective_client_assertion_type, effective_token_type, +}; +const DEFAULT_INTERMEDIATE_TOKEN_CACHE_TTL_SECONDS: i64 = 300; +const MAX_INTERMEDIATE_TOKEN_CACHE_TTL_SECONDS: i64 = 3600; +const INTERMEDIATE_TOKEN_CACHE_EXPIRY_SKEW_SECONDS: i64 = 30; +const MAX_INTERMEDIATE_TOKEN_CACHE_ENTRIES: usize = 1024; + +static TOKEN_EXCHANGE_HTTP_CLIENT: LazyLock> = + LazyLock::new(|| { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .connect_timeout(std::time::Duration::from_secs(30)) + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|err| { + format!("provider token exchange HTTP client configuration failed: {err}") + }) + }); +static INTERMEDIATE_TOKEN_CACHE: LazyLock = + LazyLock::new(IntermediateTokenCache::new); + +fn token_exchange_http_client() -> Result<&'static reqwest::Client, Status> { + TOKEN_EXCHANGE_HTTP_CLIENT + .as_ref() + .map_err(|err| Status::internal(err.clone())) +} + +#[derive(Clone)] +struct CachedIntermediateToken { + access_token: String, + token_type: String, + expires_at_ms: i64, +} + +struct IntermediateTokenCache { + tokens: Arc>>, +} + +impl IntermediateTokenCache { + fn new() -> Self { + Self { + tokens: Arc::new(RwLock::new(HashMap::new())), + } + } + + fn get(&self, key: &str) -> Option { + let now_ms = crate::persistence::current_time_ms(); + let tokens = self.tokens.read().ok()?; + let cached = tokens.get(key)?; + if cached.expires_at_ms <= now_ms { + return None; + } + Some(oauth::OAuthTokenResponse { + access_token: cached.access_token.clone(), + expires_in: cached.expires_at_ms.saturating_sub(now_ms) / 1000, + token_type: cached.token_type.clone(), + }) + } + + fn set(&self, key: String, token: &oauth::OAuthTokenResponse, expires_at_ms: i64) { + if let Ok(mut tokens) = self.tokens.write() { + let now_ms = crate::persistence::current_time_ms(); + tokens.retain(|_, cached| cached.expires_at_ms > now_ms); + if tokens.len() >= MAX_INTERMEDIATE_TOKEN_CACHE_ENTRIES + && let Some(evict_key) = tokens + .iter() + .min_by_key(|(_, cached)| cached.expires_at_ms) + .map(|(k, _)| k.clone()) + { + tokens.remove(&evict_key); + } + tokens.insert( + key, + CachedIntermediateToken { + access_token: token.access_token.clone(), + token_type: token.token_type.clone(), + expires_at_ms, + }, + ); + } + } +} async fn authorize_and_resolve_profile_workspace( state: &Arc, @@ -3421,6 +3569,469 @@ pub(super) async fn handle_update_provider( } } +pub(super) async fn handle_exchange_provider_subject_token( + state: &Arc, + request: Request, +) -> Result, Status> { + let req = request.get_ref().clone(); + let principal = crate::auth::guard::enforce_sandbox_scope(&request, &req.sandbox_id)?; + crate::auth::guard::ensure_sandbox_principal_scope(&principal, &req.sandbox_id)?; + drop(request); + + if req.provider.trim().is_empty() { + return Err(Status::invalid_argument("provider is required")); + } + if req.credential_key.trim().is_empty() { + return Err(Status::invalid_argument("credential_key is required")); + } + if req.supervisor_jwt_svid.trim().is_empty() { + return Err(Status::invalid_argument("supervisor_jwt_svid is required")); + } + + let sandbox = state + .store + .get_message::(&req.sandbox_id) + .await + .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + let workspace = sandbox.object_workspace().to_string(); + let spec = sandbox + .spec + .as_ref() + .ok_or_else(|| Status::internal("sandbox has no spec"))?; + if !spec + .providers + .iter() + .any(|provider| provider == &req.provider) + { + return Err(Status::permission_denied( + "provider is not attached to this sandbox", + )); + } + + let provider = state + .store + .get_message_by_name::(&workspace, &req.provider) + .await + .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? + .ok_or_else(|| Status::not_found("provider not found"))?; + let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), &workspace) + .await?; + let profile = + get_provider_type_profile_for_scope(&catalog, profile_id, &provider.profile_workspace) + .ok_or_else(|| Status::failed_precondition("provider profile not found"))?; + let profile_proto = profile.to_proto(); + let credential = profile_proto + .credentials + .iter() + .find(|credential| credential.name == req.credential_key) + .ok_or_else(|| { + Status::failed_precondition("credential not declared by provider profile") + })?; + let token_grant = credential + .token_grant + .as_ref() + .ok_or_else(|| Status::failed_precondition("credential does not declare token_grant"))?; + let grant_type = ProviderCredentialTokenGrantType::try_from(token_grant.grant_type) + .unwrap_or(ProviderCredentialTokenGrantType::ClientCredentials); + if grant_type != ProviderCredentialTokenGrantType::TokenExchange { + return Err(Status::failed_precondition( + "credential token_grant is not token_exchange", + )); + } + let subject_token = token_grant + .subject_token + .as_ref() + .ok_or_else(|| Status::failed_precondition("token_exchange subject_token is missing"))?; + if subject_token.source != "provider_credential" { + return Err(Status::failed_precondition( + "unsupported subject_token source", + )); + } + if !profile_proto + .credentials + .iter() + .any(|credential| credential.name == subject_token.credential) + { + return Err(Status::failed_precondition( + "subject token credential not declared by provider profile", + )); + } + let stored_subject_token = + resolve_subject_token_credential(&state.credentials, &provider, &subject_token.credential) + .await?; + + let jwt_svid_audience = + effective_jwt_svid_audience(&token_grant.token_endpoint, &token_grant.jwt_svid_audience); + let gateway_jwt_svid = fetch_gateway_jwt_svid(&jwt_svid_audience).await?; + let gateway_claims = parse_unverified_spiffe_claims(&gateway_jwt_svid)?; + validate_gateway_jwt_svid_claims(&gateway_claims, &jwt_svid_audience)?; + let supervisor_claims = validate_supervisor_jwt_svid( + &req.supervisor_jwt_svid, + &gateway_claims, + &jwt_svid_audience, + ) + .await?; + + let intermediate_cache_key = intermediate_token_cache_key(IntermediateTokenCacheKeyInput { + provider: &provider, + dynamic_credential: &req.credential_key, + subject_credential: &subject_token.credential, + token_endpoint: &token_grant.token_endpoint, + client_assertion_type: effective_client_assertion_type(&token_grant.client_assertion_type), + subject_token_type: effective_token_type(&subject_token.subject_token_type), + audience: &supervisor_claims.sub, + requested_token_type: effective_token_type(&token_grant.requested_token_type), + supervisor_subject: &supervisor_claims.sub, + gateway_subject: &gateway_claims.sub, + }); + if let Some(cached) = INTERMEDIATE_TOKEN_CACHE.get(&intermediate_cache_key) { + return Ok(Response::new(ExchangeProviderSubjectTokenResponse { + access_token: cached.access_token, + expires_in: cached.expires_in, + token_type: cached.token_type, + })); + } + + let token_response = perform_intermediate_token_exchange( + &token_grant.token_endpoint, + &gateway_jwt_svid, + &token_grant.client_assertion_type, + &stored_subject_token, + &subject_token.subject_token_type, + &supervisor_claims.sub, + &token_grant.requested_token_type, + ) + .await + .inspect_err(|status| { + warn!( + sandbox_id = %req.sandbox_id, + provider = %req.provider, + credential_key = %req.credential_key, + subject_credential = %subject_token.credential, + client_assertion_type = %effective_client_assertion_type(&token_grant.client_assertion_type), + gateway_svid_issuer = %gateway_claims.iss, + gateway_svid_subject = %gateway_claims.sub, + gateway_svid_audience = ?gateway_claims.aud, + supervisor_svid_issuer = %supervisor_claims.iss, + supervisor_svid_subject = %supervisor_claims.sub, + supervisor_svid_audience = ?supervisor_claims.aud, + status = ?status.code(), + error = %status.message(), + "intermediate provider token exchange failed" + ); + })?; + let cache_expires_at_ms = intermediate_token_cache_expires_at_ms( + &token_response, + token_grant.cache_ttl_seconds, + provider_credential_expires_at_ms(&provider, &subject_token.credential), + supervisor_claims.exp, + ); + if cache_expires_at_ms > crate::persistence::current_time_ms() { + INTERMEDIATE_TOKEN_CACHE.set(intermediate_cache_key, &token_response, cache_expires_at_ms); + } + + Ok(Response::new(ExchangeProviderSubjectTokenResponse { + access_token: token_response.access_token, + expires_in: token_response.expires_in, + token_type: token_response.token_type, + })) +} + +async fn resolve_subject_token_credential( + credentials: &crate::credentials::CredentialRuntime, + provider: &Provider, + credential_key: &str, +) -> Result { + if let Some(value) = provider + .credentials + .get(credential_key) + .filter(|value| !value.is_empty()) + { + ensure_subject_token_credential_not_expired(provider, credential_key)?; + return Ok(value.clone()); + } + + if provider.credential_handles.contains_key(credential_key) { + let resolved = credentials + .resolve_provider_handles(provider, crate::persistence::current_time_ms()) + .await?; + if let Some(value) = resolved + .values + .get(credential_key) + .filter(|value| !value.is_empty()) + { + return Ok(value.clone()); + } + } + + Err(Status::failed_precondition( + "subject token credential is not configured", + )) +} + +fn ensure_subject_token_credential_not_expired( + provider: &Provider, + credential_key: &str, +) -> Result<(), Status> { + let expires_at_ms = provider_credential_expires_at_ms(provider, credential_key); + if expires_at_ms > 0 && expires_at_ms <= crate::persistence::current_time_ms() { + return Err(Status::failed_precondition( + "subject token credential has expired", + )); + } + Ok(()) +} + +fn provider_credential_expires_at_ms(provider: &Provider, credential_key: &str) -> i64 { + provider + .credential_expires_at_ms + .get(credential_key) + .copied() + .unwrap_or_default() +} + +struct IntermediateTokenCacheKeyInput<'a> { + provider: &'a Provider, + dynamic_credential: &'a str, + subject_credential: &'a str, + token_endpoint: &'a str, + client_assertion_type: &'a str, + subject_token_type: &'a str, + audience: &'a str, + requested_token_type: &'a str, + supervisor_subject: &'a str, + gateway_subject: &'a str, +} + +fn intermediate_token_cache_key(input: IntermediateTokenCacheKeyInput<'_>) -> String { + let provider_id = input + .provider + .metadata + .as_ref() + .map(|metadata| metadata.id.as_str()) + .filter(|id| !id.is_empty()) + .unwrap_or_else(|| input.provider.object_name()); + let provider_resource_version = input + .provider + .metadata + .as_ref() + .map_or(0, |metadata| metadata.resource_version); + format!( + "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", + provider_id, + provider_resource_version, + input.dynamic_credential, + input.subject_credential, + input.token_endpoint, + input.client_assertion_type, + input.subject_token_type, + input.audience, + input.requested_token_type, + input.supervisor_subject, + input.gateway_subject + ) +} + +fn intermediate_token_cache_expires_at_ms( + token: &oauth::OAuthTokenResponse, + cache_ttl_seconds: i64, + subject_token_expires_at_ms: i64, + supervisor_svid_exp_seconds: i64, +) -> i64 { + let now_ms = crate::persistence::current_time_ms(); + let mut ttl_seconds = if token.expires_in > 0 { + token + .expires_in + .min(MAX_INTERMEDIATE_TOKEN_CACHE_TTL_SECONDS) + } else { + DEFAULT_INTERMEDIATE_TOKEN_CACHE_TTL_SECONDS + }; + if cache_ttl_seconds > 0 { + ttl_seconds = ttl_seconds.min(cache_ttl_seconds); + } + ttl_seconds = ttl_seconds + .saturating_sub(INTERMEDIATE_TOKEN_CACHE_EXPIRY_SKEW_SECONDS) + .max(1); + let mut expires_at_ms = now_ms.saturating_add(ttl_seconds.saturating_mul(1000)); + expires_at_ms = cap_cache_expiry_ms(expires_at_ms, jwt_exp_ms(&token.access_token)); + expires_at_ms = cap_cache_expiry_ms(expires_at_ms, Some(subject_token_expires_at_ms)); + expires_at_ms = cap_cache_expiry_ms( + expires_at_ms, + (supervisor_svid_exp_seconds > 0).then(|| supervisor_svid_exp_seconds.saturating_mul(1000)), + ); + expires_at_ms +} + +fn cap_cache_expiry_ms(current_expires_at_ms: i64, cap_expires_at_ms: Option) -> i64 { + let Some(cap_expires_at_ms) = cap_expires_at_ms.filter(|value| *value > 0) else { + return current_expires_at_ms; + }; + current_expires_at_ms.min( + cap_expires_at_ms + .saturating_sub(INTERMEDIATE_TOKEN_CACHE_EXPIRY_SKEW_SECONDS.saturating_mul(1000)), + ) +} + +fn jwt_exp_ms(token: &str) -> Option { + use base64::Engine as _; + let payload = token.split('.').nth(1)?; + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .ok()?; + let claims = serde_json::from_slice::(&decoded).ok()?; + claims + .get("exp")? + .as_i64() + .map(|exp| exp.saturating_mul(1000)) +} + +async fn fetch_gateway_jwt_svid(audience: &str) -> Result { + let socket_path = std::env::var(GATEWAY_SPIFFE_WORKLOAD_API_SOCKET) + .ok() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + Status::failed_precondition(format!( + "{GATEWAY_SPIFFE_WORKLOAD_API_SOCKET} is required for provider token exchange" + )) + })?; + let endpoint = workload_api_endpoint(std::path::Path::new(&socket_path)); + let client = spiffe::WorkloadApiClient::connect_to(&endpoint) + .await + .map_err(|e| { + Status::failed_precondition(format!("SPIFFE Workload API unavailable: {e}")) + })?; + client + .fetch_jwt_token([audience], None) + .await + .map_err(|e| Status::failed_precondition(format!("failed to fetch gateway JWT-SVID: {e}"))) +} + +fn validate_gateway_jwt_svid_claims( + claims: &SpiffeJwtClaims, + expected_audience: &str, +) -> Result<(), Status> { + if !claims.aud.contains(expected_audience) { + return Err(Status::failed_precondition( + "gateway SVID audience does not match token grant audience", + )); + } + if spiffe_trust_domain(&claims.sub).is_none() { + return Err(Status::failed_precondition( + "gateway SVID subject is not a SPIFFE ID", + )); + } + if claims.exp > 0 && claims.exp.saturating_mul(1000) <= crate::persistence::current_time_ms() { + return Err(Status::failed_precondition("gateway SVID has expired")); + } + Ok(()) +} + +async fn validate_supervisor_jwt_svid( + token: &str, + gateway_claims: &SpiffeJwtClaims, + expected_audience: &str, +) -> Result { + let unverified = parse_unverified_spiffe_claims(token)?; + if unverified.iss != gateway_claims.iss { + return Err(Status::permission_denied( + "supervisor SVID issuer does not match gateway SVID issuer", + )); + } + if !unverified.aud.contains(expected_audience) { + return Err(Status::permission_denied( + "supervisor SVID audience does not match token grant audience", + )); + } + let supervisor_trust_domain = spiffe_trust_domain(&unverified.sub) + .ok_or_else(|| Status::permission_denied("supervisor SVID subject is not a SPIFFE ID"))?; + let gateway_trust_domain = spiffe_trust_domain(&gateway_claims.sub) + .ok_or_else(|| Status::failed_precondition("gateway SVID subject is not a SPIFFE ID"))?; + if supervisor_trust_domain != gateway_trust_domain { + return Err(Status::permission_denied( + "supervisor SVID trust domain does not match gateway SVID trust domain", + )); + } + + let socket_path = std::env::var(GATEWAY_SPIFFE_WORKLOAD_API_SOCKET) + .ok() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + Status::failed_precondition(format!( + "{GATEWAY_SPIFFE_WORKLOAD_API_SOCKET} is required for supervisor JWT-SVID validation" + )) + })?; + let endpoint = workload_api_endpoint(std::path::Path::new(&socket_path)); + let client = spiffe::WorkloadApiClient::connect_to(&endpoint) + .await + .map_err(|e| { + Status::failed_precondition(format!("SPIFFE Workload API unavailable: {e}")) + })?; + let bundles = client + .fetch_jwt_bundles() + .await + .map_err(|e| Status::internal(format!("SPIFFE JWT bundle fetch failed: {e}")))?; + spiffe::JwtSvid::parse_and_validate(token, &bundles, &[expected_audience]) + .map_err(|e| Status::permission_denied(format!("invalid supervisor JWT-SVID: {e}")))?; + Ok(unverified) +} + +fn parse_unverified_spiffe_claims(token: &str) -> Result { + parse_unverified_jwt_svid_claims(token).map_err(jwt_svid_parse_error_status) +} + +fn jwt_svid_parse_error_status(error: JwtSvidParseError) -> Status { + Status::permission_denied(error.to_string()) +} + +async fn perform_intermediate_token_exchange( + token_endpoint: &str, + gateway_jwt_svid: &str, + client_assertion_type: &str, + subject_token: &str, + subject_token_type: &str, + audience: &str, + requested_token_type: &str, +) -> Result { + let client = token_exchange_http_client()?; + oauth::post_oauth_token_exchange( + client, + token_endpoint, + &TokenExchangeParams { + client_assertion: gateway_jwt_svid, + client_assertion_type, + subject_token, + subject_token_type, + audience, + scopes: &[], + requested_token_type, + }, + ) + .await + .map_err(|e| Status::failed_precondition(e.to_string())) +} + +fn effective_jwt_svid_audience(token_endpoint: &str, jwt_svid_audience: &str) -> String { + if !jwt_svid_audience.trim().is_empty() { + return jwt_svid_audience.to_string(); + } + derive_issuer_from_token_endpoint(token_endpoint) +} + +fn derive_issuer_from_token_endpoint(token_endpoint: &str) -> String { + if let Some(realms_idx) = token_endpoint.find("/realms/") { + let after_realms = &token_endpoint[realms_idx + "/realms/".len()..]; + if let Some(slash_idx) = after_realms.find('/') { + let realm_end = realms_idx + "/realms/".len() + slash_idx; + return token_endpoint[..realm_end].to_string(); + } + } + token_endpoint.to_string() +} + pub(super) async fn handle_get_provider_refresh_status( state: &Arc, request: Request, @@ -4228,13 +4839,16 @@ mod tests { refresh: None, path_template: String::new(), token_grant: Some(ProviderCredentialTokenGrant { + grant_type: ProviderCredentialTokenGrantType::ClientCredentials as i32, token_endpoint: "http://keycloak.default.svc.cluster.local/realms/openshell/protocol/openid-connect/token".to_string(), audience: "api://default".to_string(), jwt_svid_audience: "http://keycloak.default.svc.cluster.local/realms/openshell" .to_string(), client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer".to_string(), + subject_token: None, scopes: vec!["openid".to_string()], + requested_token_type: String::new(), cache_ttl_seconds: 300, audience_overrides: service_audiences .iter() @@ -5004,12 +5618,15 @@ mod tests { refresh: None, path_template: String::new(), token_grant: Some(ProviderCredentialTokenGrant { + grant_type: ProviderCredentialTokenGrantType::ClientCredentials as i32, token_endpoint: "https://auth.example.com/token".to_string(), audience: "api://default".to_string(), jwt_svid_audience: "https://auth.example.com".to_string(), client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" .to_string(), + subject_token: None, scopes: vec!["read".to_string()], + requested_token_type: String::new(), cache_ttl_seconds: 300, audience_overrides: Vec::new(), }), @@ -7236,6 +7853,33 @@ mod tests { ); } + #[tokio::test] + async fn token_exchange_subject_token_resolves_credential_handle() { + let config = openshell_core::Config::new(None).with_credential_drivers(["test-static"]); + let credentials = crate::credentials::CredentialRuntime::from_config(&config).unwrap(); + let mut provider = provider_with_values("exchange-local", "custom"); + provider.credentials.clear(); + provider.metadata.as_mut().expect("provider metadata").id = "provider-id".to_string(); + let handles = credentials + .store_provider_credentials( + provider.object_name(), + provider.object_workspace(), + provider.object_id(), + &HashMap::from([("subject_token".to_string(), "user-token".to_string())]), + &HashMap::new(), + ) + .await + .unwrap(); + provider.credential_handles = handles; + + let subject_token = + resolve_subject_token_credential(&credentials, &provider, "subject_token") + .await + .unwrap(); + + assert_eq!(subject_token, "user-token"); + } + #[tokio::test] async fn update_provider_record_overwrites_credentials_with_runtime() { let store = test_store().await; @@ -11410,7 +12054,11 @@ mod tests { .await .unwrap(); - let keys = active_provider_environment_keys(state.store.as_ref(), &provider, 0) + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(state.store.as_ref(), "default") + .await + .unwrap(); + let keys = active_provider_environment_keys(state.store.as_ref(), &catalog, &provider, 0) .await .unwrap(); assert!(keys.contains(&"AWS_ACCESS_KEY_ID".to_string())); diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index fe889d7445..2667611bcc 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -75,6 +75,10 @@ pub(crate) static TEST_ENV_LOCK: LazyLock> = LazyLock::new(|| Mutex::n #[cfg(test)] pub(crate) static TEST_TRACING_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); +pub(crate) fn install_jsonwebtoken_crypto_provider() { + let _ = jsonwebtoken::crypto::aws_lc::DEFAULT_PROVIDER.install_default(); +} + use compute::ComputeRuntime; use gateway_listener::{BoundGatewayListener, GatewayListenerScope, bind_gateway_listeners}; pub use grpc::OpenShellService; diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index cb376eadef..f562e1d18e 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -1174,6 +1174,8 @@ async fn mint_oauth2_client_credentials( async fn mint_google_service_account_jwt( state: &StoredProviderCredentialRefreshState, ) -> Result { + crate::install_jsonwebtoken_crypto_provider(); + let token_url = google_token_url(state); let client_email = required_material(&state.material, "client_email")?; let private_key = required_material(&state.material, "private_key")?; diff --git a/crates/openshell-server/tests/common/mod.rs b/crates/openshell-server/tests/common/mod.rs index 13c4294c0b..96b620a230 100644 --- a/crates/openshell-server/tests/common/mod.rs +++ b/crates/openshell-server/tests/common/mod.rs @@ -16,8 +16,9 @@ use hyper_util::{ use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, GatewayMessage, - GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest, + ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, + ExecSandboxInput, ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest, + GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse, IssueSandboxTokenRequest, IssueSandboxTokenResponse, ListProvidersRequest, @@ -219,6 +220,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(RevokeSshSessionResponse::default())) } + async fn exchange_provider_subject_token( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn create_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/supervisor_relay_integration.rs b/crates/openshell-server/tests/supervisor_relay_integration.rs index 987d43ba4a..32ef513adb 100644 --- a/crates/openshell-server/tests/supervisor_relay_integration.rs +++ b/crates/openshell-server/tests/supervisor_relay_integration.rs @@ -243,6 +243,12 @@ impl OpenShell for RelayGateway { ) -> Result, Status> { Err(Status::unimplemented("unused")) } + async fn exchange_provider_subject_token( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } async fn create_provider( &self, _: tonic::Request, diff --git a/crates/openshell-supervisor-network/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index 0f3191c5d8..34d9c32a47 100644 --- a/crates/openshell-supervisor-network/Cargo.toml +++ b/crates/openshell-supervisor-network/Cargo.toml @@ -11,7 +11,7 @@ repository.workspace = true rust-version.workspace = true [dependencies] -openshell-core = { path = "../openshell-core" } +openshell-core = { path = "../openshell-core", features = ["oauth"] } openshell-ocsf = { path = "../openshell-ocsf" } openshell-policy = { path = "../openshell-policy" } openshell-router = { path = "../openshell-router" } diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 19329d7de6..44f3f8023a 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -3335,6 +3335,37 @@ network_policies: (config, tunnel_engine, ctx, fixture) } + fn rest_token_exchange_relay_context( + resolver_response: std::result::Result<&str, &str>, + ) -> ( + L7EndpointConfig, + TunnelPolicyEngine, + L7EvalContext, + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture, + ) { + let (config, tunnel_engine, mut ctx, _) = + rest_token_grant_relay_context(Ok("unused-token")); + let provider_key = "api.example.test\t8080\t/v1/**\tprovider:access_token"; + let fixture = match resolver_response { + Ok(token) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::success_token_exchange( + provider_key, + token, + ) + } + Err(error) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::failure_token_exchange( + provider_key, + error, + ) + } + }; + ctx.dynamic_credentials = Some(fixture.dynamic_credentials()); + ctx.token_grant_resolver = Some(fixture.resolver()); + + (config, tunnel_engine, ctx, fixture) + } + fn middleware_relay_context( middleware_impl: &str, on_error: &str, @@ -3743,6 +3774,36 @@ network_policies: .await; } + fn passthrough_token_exchange_relay_context( + resolver_response: std::result::Result<&str, &str>, + ) -> ( + PolicyGenerationGuard, + L7EvalContext, + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture, + ) { + let (generation_guard, mut ctx, _) = + passthrough_token_grant_relay_context(Ok("unused-token")); + let provider_key = "api.example.test\t8080\t/v1/**\tprovider:access_token"; + let fixture = match resolver_response { + Ok(token) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::success_token_exchange( + provider_key, + token, + ) + } + Err(error) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::failure_token_exchange( + provider_key, + error, + ) + } + }; + ctx.dynamic_credentials = Some(fixture.dynamic_credentials()); + ctx.token_grant_resolver = Some(fixture.resolver()); + + (generation_guard, ctx, fixture) + } + fn jsonrpc_test_relay_context() -> (L7EndpointConfig, TunnelPolicyEngine, L7EvalContext) { jsonrpc_test_relay_context_with_path("/rpc") } @@ -4117,6 +4178,128 @@ network_policies: fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); } + #[tokio::test] + async fn l7_rest_relay_injects_token_exchange_authorization_header() { + let (config, tunnel_engine, ctx, fixture) = + rest_token_exchange_relay_context(Ok("grant-token")); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + app.write_all( + b"GET /v1/projects HTTP/1.1\r\nHost: api.example.test\r\nAuthorization: Bearer stale-token\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + + let mut upstream_request = [0u8; 1024]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut upstream_request), + ) + .await + .expect("request should reach upstream") + .unwrap(); + let upstream_request = String::from_utf8_lossy(&upstream_request[..n]); + + assert!( + upstream_request.starts_with("GET /v1/projects HTTP/1.1\r\n"), + "unexpected upstream request: {upstream_request:?}" + ); + assert!(upstream_request.contains("Authorization: Bearer grant-token\r\n")); + assert!(!upstream_request.contains("stale-token")); + assert_eq!(authorization_header_count(&upstream_request), 1); + + upstream + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + + let mut client_response = [0u8; 512]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read(&mut client_response), + ) + .await + .expect("response should reach client") + .unwrap(); + assert!(String::from_utf8_lossy(&client_response[..n]).contains("204 No Content")); + drop(app); + + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + + fixture.assert_one_token_exchange_request( + "api.example.test\t8080\t/v1/**\tprovider:access_token", + ); + } + + #[tokio::test] + async fn l7_rest_relay_token_exchange_failure_does_not_forward_request() { + let (config, tunnel_engine, ctx, fixture) = + rest_token_exchange_relay_context(Err("oauth unavailable")); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + app.write_all( + b"GET /v1/projects HTTP/1.1\r\nHost: api.example.test\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + + let mut client_response = [0u8; 512]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read(&mut client_response), + ) + .await + .expect("bad gateway response should reach client") + .unwrap(); + assert!(String::from_utf8_lossy(&client_response[..n]).contains("502 Bad Gateway")); + + let mut upstream_request = [0u8; 128]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut upstream_request), + ) + .await + .expect("upstream should close without forwarded data") + .unwrap(); + assert_eq!(n, 0, "unauthenticated request must not reach upstream"); + + fixture.assert_one_token_exchange_request( + "api.example.test\t8080\t/v1/**\tprovider:access_token", + ); + } + #[tokio::test] async fn l7_rest_middleware_redacts_body_before_upstream() { let (config, tunnel_engine, ctx) = @@ -6474,6 +6657,125 @@ network_policies: fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); } + #[tokio::test] + async fn passthrough_relay_injects_token_exchange_authorization_header() { + let (generation_guard, ctx, fixture) = + passthrough_token_exchange_relay_context(Ok("grant-token")); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_passthrough_with_credentials( + &mut relay_client, + &mut relay_upstream, + &ctx, + &generation_guard, + None, + ) + .await + }); + + app.write_all( + b"GET /v1/projects HTTP/1.1\r\nHost: api.example.test\r\nAuthorization: Bearer stale-token\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + + let mut upstream_request = [0u8; 1024]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut upstream_request), + ) + .await + .expect("request should reach upstream") + .unwrap(); + let upstream_request = String::from_utf8_lossy(&upstream_request[..n]); + + assert!(upstream_request.starts_with("GET /v1/projects HTTP/1.1\r\n")); + assert!(upstream_request.contains("Authorization: Bearer grant-token\r\n")); + assert!(!upstream_request.contains("stale-token")); + assert_eq!(authorization_header_count(&upstream_request), 1); + + upstream + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + + let mut client_response = [0u8; 512]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read(&mut client_response), + ) + .await + .expect("response should reach client") + .unwrap(); + assert!(String::from_utf8_lossy(&client_response[..n]).contains("204 No Content")); + drop(app); + + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + + fixture.assert_one_token_exchange_request( + "api.example.test\t8080\t/v1/**\tprovider:access_token", + ); + } + + #[tokio::test] + async fn passthrough_relay_token_exchange_failure_returns_bad_gateway_without_forwarding() { + let (generation_guard, ctx, fixture) = + passthrough_token_exchange_relay_context(Err("oauth unavailable")); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_passthrough_with_credentials( + &mut relay_client, + &mut relay_upstream, + &ctx, + &generation_guard, + None, + ) + .await + }); + + app.write_all( + b"GET /v1/projects HTTP/1.1\r\nHost: api.example.test\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + + let mut client_response = [0u8; 512]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read(&mut client_response), + ) + .await + .expect("bad gateway response should reach client") + .unwrap(); + assert!(String::from_utf8_lossy(&client_response[..n]).contains("502 Bad Gateway")); + + let mut upstream_request = [0u8; 128]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut upstream_request), + ) + .await + .expect("upstream should close without forwarded data") + .unwrap(); + assert_eq!(n, 0, "unauthenticated request must not reach upstream"); + + fixture.assert_one_token_exchange_request( + "api.example.test\t8080\t/v1/**\tprovider:access_token", + ); + } + #[test] fn websocket_text_policy_requires_explicit_message_rule() { let data = r#" diff --git a/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs b/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs index ce6c581bc8..fc440c6547 100644 --- a/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs +++ b/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs @@ -26,6 +26,8 @@ pub struct TokenGrantRequest<'a> { pub audience: &'a str, pub scopes: &'a [String], pub cache_ttl_seconds: i64, + pub grant_type: i32, + pub requested_token_type: &'a str, } pub trait TokenGrantResolver: Send + Sync { @@ -45,13 +47,17 @@ impl TokenGrantResolver for SpiffeTokenGrantResolver { ) -> Pin> + Send + 'a>> { Box::pin(async move { crate::token_grant::obtain_provider_token( - request.provider_key, - request.token_endpoint, - request.jwt_svid_audience, - request.client_assertion_type, - request.audience, - request.scopes, - request.cache_ttl_seconds, + crate::token_grant::ObtainProviderTokenRequest { + provider_name: request.provider_key, + token_endpoint: request.token_endpoint, + jwt_svid_audience: request.jwt_svid_audience, + client_assertion_type: request.client_assertion_type, + audience: request.audience, + scopes: request.scopes, + cache_ttl_override: request.cache_ttl_seconds, + grant_type: request.grant_type, + requested_token_type: request.requested_token_type, + }, ) .await }) @@ -127,7 +133,7 @@ pub async fn inject_if_needed(req: L7Request, ctx: &L7EvalContext) -> Result( audience: &token_grant.audience, scopes: &token_grant.scopes, cache_ttl_seconds: token_grant.cache_ttl_seconds, + grant_type: token_grant.grant_type, + requested_token_type: &token_grant.requested_token_type, } } @@ -348,7 +356,10 @@ fn inject_header(raw_header: &[u8], header_name: &str, header_value: &str) -> Re #[cfg(test)] pub mod test_support { use super::*; - use openshell_core::proto::{ProviderCredentialTokenGrant, ProviderProfileCredential}; + use openshell_core::proto::{ + ProviderCredentialTokenGrant, ProviderCredentialTokenGrantSubjectToken, + ProviderCredentialTokenGrantType, ProviderProfileCredential, + }; use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -366,6 +377,8 @@ pub mod test_support { audience: String, scopes: Vec, cache_ttl_seconds: i64, + grant_type: i32, + requested_token_type: String, } pub struct TokenGrantTestFixture { @@ -379,11 +392,27 @@ pub mod test_support { Self::new(key, Ok(token)) } + pub fn success_token_exchange(key: &str, token: &str) -> Self { + Self::new_with_grant(key, Ok(token), token_exchange_grant()) + } + pub fn failure(key: &str, error: &str) -> Self { Self::new(key, Err(error)) } + pub fn failure_token_exchange(key: &str, error: &str) -> Self { + Self::new_with_grant(key, Err(error), token_exchange_grant()) + } + fn new(key: &str, response: std::result::Result<&str, &str>) -> Self { + Self::new_with_grant(key, response, token_grant()) + } + + fn new_with_grant( + key: &str, + response: std::result::Result<&str, &str>, + token_grant: ProviderCredentialTokenGrant, + ) -> Self { let requests = Arc::new(Mutex::new(Vec::new())); let resolver = Arc::new(FakeTokenGrantResolver { requests: requests.clone(), @@ -397,7 +426,7 @@ pub mod test_support { name: "access_token".to_string(), auth_style: "bearer".to_string(), header_name: "Authorization".to_string(), - token_grant: Some(token_grant()), + token_grant: Some(token_grant), ..Default::default() }, ); @@ -445,6 +474,39 @@ pub mod test_support { assert_eq!(request.audience, "api://example"); assert_eq!(request.scopes, ["read"]); assert_eq!(request.cache_ttl_seconds, 300); + assert_eq!( + request.grant_type, + ProviderCredentialTokenGrantType::ClientCredentials as i32 + ); + assert!(request.requested_token_type.is_empty()); + } + + pub fn assert_one_token_exchange_request(&self, expected_provider_key: &str) { + let requests = self + .requests + .lock() + .expect("fake token grant requests lock poisoned"); + assert_eq!(requests.len(), 1); + + let request = &requests[0]; + assert_eq!(request.provider_key, expected_provider_key); + assert_eq!(request.token_endpoint, "https://auth.example.com/token"); + assert_eq!(request.jwt_svid_audience, "https://auth.example.com"); + assert_eq!( + request.client_assertion_type, + "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe" + ); + assert_eq!(request.audience, "api://example"); + assert_eq!(request.scopes, ["read"]); + assert_eq!(request.cache_ttl_seconds, 300); + assert_eq!( + request.grant_type, + ProviderCredentialTokenGrantType::TokenExchange as i32 + ); + assert_eq!( + request.requested_token_type, + "urn:ietf:params:oauth:token-type:access_token" + ); } } @@ -458,6 +520,24 @@ pub mod test_support { scopes: vec!["read".to_string()], cache_ttl_seconds: 300, audience_overrides: Vec::new(), + grant_type: ProviderCredentialTokenGrantType::ClientCredentials as i32, + subject_token: None, + requested_token_type: String::new(), + } + } + + fn token_exchange_grant() -> ProviderCredentialTokenGrant { + ProviderCredentialTokenGrant { + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe" + .to_string(), + grant_type: ProviderCredentialTokenGrantType::TokenExchange as i32, + subject_token: Some(ProviderCredentialTokenGrantSubjectToken { + source: "provider_credential".to_string(), + credential: "user_oidc_token".to_string(), + subject_token_type: "urn:ietf:params:oauth:token-type:id_token".to_string(), + }), + requested_token_type: "urn:ietf:params:oauth:token-type:access_token".to_string(), + ..token_grant() } } @@ -474,6 +554,8 @@ pub mod test_support { audience: request.audience.to_string(), scopes: request.scopes.to_vec(), cache_ttl_seconds: request.cache_ttl_seconds, + grant_type: request.grant_type, + requested_token_type: request.requested_token_type.to_string(), }; Box::pin(async move { self.requests @@ -510,6 +592,12 @@ mod tests { 443, "/repos/owner/repo" )); + assert!(dynamic_credential_key_matches( + "api.example.com\t443\t/repos/**\trev:42\tgithub:access_token", + "api.example.com", + 443, + "/repos/owner/repo" + )); assert!(!dynamic_credential_key_matches( key, "uploads.example.com", @@ -751,6 +839,46 @@ mod tests { fixture.assert_one_request("api.example.com\t443\t/v1/**\tprovider:access_token"); } + #[tokio::test] + async fn inject_if_needed_passes_token_exchange_grant_to_resolver() { + let fixture = TokenGrantTestFixture::success_token_exchange( + "api.example.com\t443\t/v1/**\tprovider:access_token", + "grant-token", + ); + + let ctx = L7EvalContext { + host: "api.example.com".into(), + port: 443, + policy_name: "api".into(), + binary_path: "/usr/bin/curl".into(), + ancestors: vec![], + cmdline_paths: vec![], + secret_resolver: None, + activity_tx: None, + dynamic_credentials: Some(fixture.dynamic_credentials()), + token_grant_resolver: Some(fixture.resolver()), + ..Default::default() + }; + let req = L7Request { + action: "GET".to_string(), + target: "/v1/projects".to_string(), + query_params: std::collections::HashMap::new(), + raw_header: b"GET /v1/projects HTTP/1.1\r\nHost: api.example.com\r\n\r\n".to_vec(), + body_length: BodyLength::None, + }; + + let rewritten = inject_if_needed(req, &ctx) + .await + .expect("fake token exchange grant should inject"); + let rewritten = + String::from_utf8(rewritten.raw_header).expect("rewritten request should be UTF-8"); + + assert!(rewritten.contains("Authorization: Bearer grant-token\r\n")); + fixture.assert_one_token_exchange_request( + "api.example.com\t443\t/v1/**\tprovider:access_token", + ); + } + #[tokio::test] async fn inject_if_needed_rejects_malformed_resolver_token() { let fixture = TokenGrantTestFixture::success( diff --git a/crates/openshell-supervisor-network/src/lib.rs b/crates/openshell-supervisor-network/src/lib.rs index ccb0a4d166..4fec48b300 100644 --- a/crates/openshell-supervisor-network/src/lib.rs +++ b/crates/openshell-supervisor-network/src/lib.rs @@ -18,7 +18,6 @@ pub mod procfs; pub mod proxy; pub mod run; pub mod sigv4; -mod spiffe_endpoint; mod token_grant; pub mod upstream_proxy; diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 35970d9fc7..ced9476ddf 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -22,7 +22,7 @@ use openshell_core::net::{ set_tcp_nodelay_best_effort, }; use openshell_core::policy::ProxyPolicy; -use openshell_core::provider_credentials::ProviderCredentialState; +use openshell_core::provider_credentials::{ProviderCredentialSnapshot, ProviderCredentialState}; use openshell_core::secrets::{self, SecretResolver, rewrite_header_line_checked}; use openshell_ocsf::{ ActionId, ActivityId, AiModel, ApiActivityBuilder, DispositionId, Endpoint, @@ -103,6 +103,27 @@ const HOST_GATEWAY_ALIASES: &[&str] = &[ "host.docker.internal", ]; +fn revision_scoped_dynamic_credentials( + snapshot: &ProviderCredentialSnapshot, +) -> std::collections::HashMap { + snapshot + .dynamic_credentials + .iter() + .map(|(key, credential)| { + let scoped_key = key.rsplit_once('\t').map_or_else( + || format!("rev:{}\t{key}", snapshot.revision), + |(endpoint_selector, provider_credential)| { + format!( + "{endpoint_selector}\trev:{}\t{provider_credential}", + snapshot.revision + ) + }, + ); + (scoped_key, credential.clone()) + }) + .collect() +} + /// Cloud instance metadata IPs that are NEVER exempted from SSRF blocking, /// even when they coincidentally match a host-gateway alias resolution. /// This list covers the well-known IMDS endpoints across major cloud providers. @@ -363,9 +384,9 @@ impl ProxyHandle { .as_ref() .and_then(ProviderCredentialState::resolver); let dynamic_credentials = provider_credentials.as_ref().map(|state| { - Arc::new(std::sync::RwLock::new( - state.snapshot().dynamic_credentials.clone(), - )) + Arc::new(std::sync::RwLock::new(revision_scoped_dynamic_credentials( + &state.snapshot(), + ))) }); let dtx = denial_tx.clone(); let atx = activity_tx.clone(); @@ -7405,6 +7426,29 @@ network_policies: ); } + #[test] + fn revision_scoped_dynamic_credentials_preserves_endpoint_selector_and_adds_revision() { + let mut dynamic_credentials = std::collections::HashMap::new(); + dynamic_credentials.insert( + "api.example.test\t443\t/v1/**\tprovider:access_token".to_string(), + openshell_core::proto::ProviderProfileCredential { + name: "access_token".to_string(), + ..Default::default() + }, + ); + let snapshot = ProviderCredentialSnapshot { + revision: 42, + child_env: std::collections::HashMap::new(), + dynamic_credentials, + }; + + let scoped = revision_scoped_dynamic_credentials(&snapshot); + + assert!( + scoped.contains_key("api.example.test\t443\t/v1/**\trev:42\tprovider:access_token") + ); + } + #[test] fn connect_activity_is_skipped_when_l7_will_count_the_request() { let (tx, mut rx) = mpsc::channel(4); @@ -7906,20 +7950,7 @@ network_policies: crate::l7::token_grant_injection::test_support::TokenGrantTestFixture, ) { let provider_key = "api.example.test\t8080\t/v1/**\tprovider:access_token"; - let fixture = match resolver_response { - Ok(token) => { - crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::success( - provider_key, - token, - ) - } - Err(error) => { - crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::failure( - provider_key, - error, - ) - } - }; + let fixture = forward_token_grant_fixture(provider_key, resolver_response, false); let ctx = crate::l7::relay::L7EvalContext { host: "api.example.test".into(), port: 8080, @@ -7937,6 +7968,54 @@ network_policies: (ctx, fixture) } + fn forward_token_exchange_context( + resolver_response: std::result::Result<&str, &str>, + ) -> ( + crate::l7::relay::L7EvalContext, + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture, + ) { + let (mut ctx, _) = forward_token_grant_context(Ok("unused-token")); + let provider_key = "api.example.test\t8080\t/v1/**\tprovider:access_token"; + let fixture = forward_token_grant_fixture(provider_key, resolver_response, true); + ctx.dynamic_credentials = Some(fixture.dynamic_credentials()); + ctx.token_grant_resolver = Some(fixture.resolver()); + + (ctx, fixture) + } + + fn forward_token_grant_fixture( + provider_key: &str, + resolver_response: std::result::Result<&str, &str>, + token_exchange: bool, + ) -> crate::l7::token_grant_injection::test_support::TokenGrantTestFixture { + match (resolver_response, token_exchange) { + (Ok(token), false) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::success( + provider_key, + token, + ) + } + (Ok(token), true) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::success_token_exchange( + provider_key, + token, + ) + } + (Err(error), false) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::failure( + provider_key, + error, + ) + } + (Err(error), true) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::failure_token_exchange( + provider_key, + error, + ) + } + } + } + fn authorization_header_count(headers: &str) -> usize { headers .lines() @@ -10774,6 +10853,34 @@ network_policies: fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); } + #[tokio::test] + async fn forward_proxy_injects_token_exchange_before_rewriting_request() { + let (ctx, fixture) = forward_token_exchange_context(Ok("grant-token")); + let raw = b"GET http://api.example.test:8080/v1/projects HTTP/1.1\r\nHost: api.example.test:8080\r\nAuthorization: Bearer stale-token\r\nConnection: close\r\n\r\n".to_vec(); + + let with_token = inject_token_grant_for_forward_request("GET", "/v1/projects", raw, &ctx) + .await + .expect("forward token exchange should inject"); + let rewritten = rewrite_forward_request( + &with_token, + with_token.len(), + "/v1/projects", + "api.example.test:8080", + None, + false, + ) + .expect("forward request should rewrite"); + let rewritten = String::from_utf8_lossy(&rewritten); + + assert!(rewritten.starts_with("GET /v1/projects HTTP/1.1\r\n")); + assert!(rewritten.contains("Authorization: Bearer grant-token\r\n")); + assert!(!rewritten.contains("stale-token")); + assert_eq!(authorization_header_count(&rewritten), 1); + fixture.assert_one_token_exchange_request( + "api.example.test\t8080\t/v1/**\tprovider:access_token", + ); + } + #[tokio::test] async fn forward_proxy_token_grant_failure_returns_error_before_rewrite() { let (ctx, fixture) = forward_token_grant_context(Err("oauth unavailable")); @@ -10788,6 +10895,22 @@ network_policies: fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); } + #[tokio::test] + async fn forward_proxy_token_exchange_failure_returns_error_before_rewrite() { + let (ctx, fixture) = forward_token_exchange_context(Err("oauth unavailable")); + let raw = b"GET http://api.example.test:8080/v1/projects HTTP/1.1\r\nHost: api.example.test:8080\r\nConnection: close\r\n\r\n".to_vec(); + + let err = inject_token_grant_for_forward_request("GET", "/v1/projects", raw, &ctx) + .await + .expect_err("forward token exchange failure should stop request rewriting"); + + assert!(err.to_string().contains("Token grant failed")); + assert!(err.to_string().contains("oauth unavailable")); + fixture.assert_one_token_exchange_request( + "api.example.test\t8080\t/v1/**\tprovider:access_token", + ); + } + #[test] fn test_rewrite_get_request() { let raw = diff --git a/crates/openshell-supervisor-network/src/spiffe_endpoint.rs b/crates/openshell-supervisor-network/src/spiffe_endpoint.rs deleted file mode 100644 index 4494626275..0000000000 --- a/crates/openshell-supervisor-network/src/spiffe_endpoint.rs +++ /dev/null @@ -1,17 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -use std::path::Path; - -/// Convert a path to a SPIFFE Workload API endpoint URL. -/// -/// If the path already has a scheme (`unix:` or `tcp:`), use it as-is. -/// Otherwise, assume it is a Unix socket path and prepend `unix:`. -pub fn workload_api_endpoint(path: &Path) -> String { - let path = path.to_string_lossy(); - if path.starts_with("unix:") || path.starts_with("tcp:") { - path.into_owned() - } else { - format!("unix:{path}") - } -} diff --git a/crates/openshell-supervisor-network/src/token_grant.rs b/crates/openshell-supervisor-network/src/token_grant.rs index 03e9bfb398..15aea6fccf 100644 --- a/crates/openshell-supervisor-network/src/token_grant.rs +++ b/crates/openshell-supervisor-network/src/token_grant.rs @@ -34,13 +34,16 @@ use std::collections::HashMap; use std::future::Future; -use std::net::IpAddr; use std::sync::{Arc, LazyLock, RwLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use miette::{IntoDiagnostic, Result, WrapErr}; +use openshell_core::oauth::{ + self, ACCESS_TOKEN_TYPE, OAuthTokenResponse, TokenExchangeParams, TokenGrantParams, + effective_client_assertion_type, effective_token_type, +}; +use openshell_core::proto::ProviderCredentialTokenGrantType; use openshell_core::sandbox_env; -use serde::Deserialize; use spiffe::WorkloadApiClient; /// Token cache shared across all provider token grants. @@ -54,32 +57,9 @@ static TOKEN_GRANT_HTTP_CLIENT: LazyLock = LazyLock::new(|| { .build() .expect("token grant HTTP client configuration should be valid") }); -const MAX_OAUTH_ERROR_FIELD_LEN: usize = 256; const DEFAULT_TOKEN_CACHE_TTL_SECONDS: i64 = 300; const TOKEN_CACHE_EXPIRY_SKEW_SECONDS: i64 = 30; const MAX_TOKEN_EXPIRES_IN_SECONDS: i64 = 3600; -const DEFAULT_CLIENT_ASSERTION_TYPE: &str = - "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; - -/// `OAuth2` token response from the authorization server. -#[derive(Debug, Clone, Deserialize)] -struct TokenResponse { - access_token: String, - #[serde(default)] - #[allow(dead_code)] - token_type: String, - #[serde(default)] - expires_in: i64, - #[serde(default)] - #[allow(dead_code)] - scope: String, -} - -#[derive(Debug, Deserialize)] -struct OAuthErrorResponse { - error: Option, - error_description: Option, -} /// Cached access token with expiration metadata. #[derive(Debug, Clone)] @@ -153,41 +133,98 @@ impl TokenCache { /// - JWT-SVID fetch fails /// - Token service request fails /// - Token response is invalid -pub async fn obtain_provider_token( - provider_name: &str, - token_endpoint: &str, - jwt_svid_audience: &str, - client_assertion_type: &str, - audience: &str, - scopes: &[String], - cache_ttl_override: i64, -) -> Result { +pub struct ObtainProviderTokenRequest<'a> { + pub provider_name: &'a str, + pub token_endpoint: &'a str, + pub jwt_svid_audience: &'a str, + pub client_assertion_type: &'a str, + pub audience: &'a str, + pub scopes: &'a [String], + pub cache_ttl_override: i64, + pub grant_type: i32, + pub requested_token_type: &'a str, +} + +pub async fn obtain_provider_token(request: ObtainProviderTokenRequest<'_>) -> Result { + let grant_type = + ProviderCredentialTokenGrantType::try_from(request.grant_type).map_err(|_| { + tracing::error!( + grant_type = request.grant_type, + provider = request.provider_name, + "unrecognised token grant_type" + ); + miette::miette!( + "unrecognised token grant_type {} for provider credential", + request.grant_type + ) + })?; obtain_provider_token_with_grant( ObtainProviderTokenInput { cache: &TOKEN_CACHE, - provider_name, - token_endpoint, - jwt_svid_audience, - client_assertion_type, - audience, - scopes, - cache_ttl_override, + provider_name: request.provider_name, + token_endpoint: request.token_endpoint, + jwt_svid_audience: request.jwt_svid_audience, + client_assertion_type: request.client_assertion_type, + audience: request.audience, + scopes: request.scopes, + cache_ttl_override: request.cache_ttl_override, + grant_type, + requested_token_type: request.requested_token_type, }, |jwt_audience| async move { // Fetch JWT-SVID with authorization server as audience // For RFC 7523, the JWT assertion's aud claim identifies the issuer/realm let jwt_svid = fetch_jwt_svid_for_token_grant(&jwt_audience).await?; - // Perform OAuth2 JWT client assertion grant - // The audience parameter in the token request specifies the resource server - perform_token_grant( - token_endpoint, - &jwt_svid, - client_assertion_type, - audience, - scopes, - ) - .await + match grant_type { + ProviderCredentialTokenGrantType::ClientCredentials + | ProviderCredentialTokenGrantType::Unspecified => { + // Perform OAuth2 JWT client assertion grant. The audience + // parameter in the token request specifies the resource server. + perform_token_grant( + request.token_endpoint, + &jwt_svid, + request.client_assertion_type, + request.audience, + request.scopes, + ) + .await + } + ProviderCredentialTokenGrantType::TokenExchange => { + let (provider, credential_key) = + parse_provider_credential_key(request.provider_name)?; + let endpoint = supervisor_gateway_endpoint_from_env()?; + let sandbox_id = supervisor_sandbox_id_from_env()?; + let intermediate = + openshell_core::grpc_client::exchange_provider_subject_token( + &endpoint, + &sandbox_id, + provider, + credential_key, + &jwt_svid, + ) + .await + .map_err(|err| { + miette::miette!( + "gateway intermediate provider token exchange failed: {err}" + ) + })?; + validate_access_token(&intermediate.access_token)?; + perform_token_exchange( + request.token_endpoint, + &TokenExchangeParams { + client_assertion: &jwt_svid, + client_assertion_type: request.client_assertion_type, + subject_token: &intermediate.access_token, + subject_token_type: ACCESS_TOKEN_TYPE, + audience: request.audience, + scopes: request.scopes, + requested_token_type: request.requested_token_type, + }, + ) + .await + } + } }, ) .await @@ -202,6 +239,8 @@ struct ObtainProviderTokenInput<'a> { audience: &'a str, scopes: &'a [String], cache_ttl_override: i64, + grant_type: ProviderCredentialTokenGrantType, + requested_token_type: &'a str, } async fn obtain_provider_token_with_grant( @@ -210,34 +249,30 @@ async fn obtain_provider_token_with_grant( ) -> Result where F: FnOnce(String) -> Fut, - Fut: Future>, + Fut: Future>, { - // Derive authorization server audience from token endpoint - // For Keycloak: https://auth.example.com/realms/openshell/protocol/openid-connect/token - // -> https://auth.example.com/realms/openshell let jwt_audience = effective_jwt_svid_audience(input.token_endpoint, input.jwt_svid_audience); - let cache_key = token_cache_key( - input.provider_name, - input.token_endpoint, - &jwt_audience, - effective_client_assertion_type(input.client_assertion_type), - input.audience, - input.scopes, - ); + let cache_key = token_cache_key(TokenCacheKeyInput { + provider_name: input.provider_name, + token_endpoint: input.token_endpoint, + jwt_svid_audience: &jwt_audience, + client_assertion_type: effective_client_assertion_type(input.client_assertion_type), + audience: input.audience, + scopes: input.scopes, + grant_type: input.grant_type, + requested_token_type: effective_token_type(input.requested_token_type), + }); - // Check cache first if let Some(cached) = input.cache.get(&cache_key) { return Ok(cached); } let token_response = grant(jwt_audience).await?; - validate_access_token(&token_response.access_token)?; let cache_ttl_seconds = token_cache_ttl_seconds(input.cache_ttl_override, token_response.expires_in); let expires_at_ms = current_time_ms().saturating_add(cache_ttl_seconds.saturating_mul(1000)); - // Cache the token input.cache.set( cache_key, token_response.access_token.clone(), @@ -256,7 +291,7 @@ async fn fetch_jwt_svid_for_token_grant(audience: &str) -> Result { let socket_path = provider_spiffe_workload_api_socket_from_env()?; let endpoint = - crate::spiffe_endpoint::workload_api_endpoint(std::path::Path::new(&socket_path)); + openshell_core::spiffe::workload_api_endpoint(std::path::Path::new(&socket_path)); // Connect to SPIRE agent let client = WorkloadApiClient::connect_to(&endpoint) @@ -287,153 +322,34 @@ fn provider_spiffe_workload_api_socket_from_env() -> Result { }) } -/// Perform `OAuth2` JWT client assertion grant. -/// -/// POSTs to the token endpoint with: -/// - `grant_type=client_credentials` -/// - `client_assertion_type=` -/// - `client_assertion=` (client identity is in the JWT's `sub` claim) -/// - `audience=` (if provided) -/// - `scope=` (if provided) -/// -/// Note: `client_id` is NOT included - the client is identified by the `sub` claim -/// in the JWT-SVID itself. async fn perform_token_grant( token_endpoint: &str, jwt_svid: &str, client_assertion_type: &str, audience: &str, scopes: &[String], -) -> Result { - let token_endpoint_url = parse_token_endpoint_url(token_endpoint)?; - let client_assertion_type = effective_client_assertion_type(client_assertion_type); - let mut form_params = vec![ - ("grant_type", "client_credentials"), - ("client_assertion_type", client_assertion_type), - ("client_assertion", jwt_svid), - ]; - - // Add audience if provided - let audience_param; - if !audience.is_empty() { - audience_param = audience.to_string(); - form_params.push(("audience", &audience_param)); - } - - // Add scopes if provided - let scope_param; - if !scopes.is_empty() { - scope_param = scopes.join(" "); - form_params.push(("scope", &scope_param)); - } - - // POST to token endpoint - let response = TOKEN_GRANT_HTTP_CLIENT - .post(token_endpoint_url) - .form(&form_params) - .send() - .await - .into_diagnostic() - .wrap_err_with(|| format!("failed to POST to token endpoint {token_endpoint}"))?; - - // Check response status - if !response.status().is_success() { - let status = response.status(); - let body = response - .text() - .await - .unwrap_or_else(|_| "".to_string()); - return Err(miette::miette!( - "{}", - token_grant_failure_message(status, &body) - )); - } - - // Parse token response - let token_response = response - .json::() - .await - .into_diagnostic() - .wrap_err("failed to parse token response as JSON")?; - validate_access_token(&token_response.access_token)?; - Ok(token_response) -} - -fn parse_token_endpoint_url(token_endpoint: &str) -> Result { - let url = reqwest::Url::parse(token_endpoint) - .into_diagnostic() - .wrap_err("token_endpoint must be an absolute URL")?; - if token_endpoint_transport_allowed(&url) { - return Ok(url); - } - - Err(miette::miette!( - "token_endpoint must use https, except http for loopback or in-cluster service hosts" - )) -} - -fn token_endpoint_transport_allowed(url: &reqwest::Url) -> bool { - match url.scheme() { - "https" => true, - "http" => url - .host_str() - .is_some_and(|host| is_loopback_host(host) || is_kubernetes_service_host(host)), - _ => false, - } -} - -fn is_loopback_host(host: &str) -> bool { - let host = host.trim_matches(['[', ']']); - if host.eq_ignore_ascii_case("localhost") { - return true; - } - - match host.parse::() { - Ok(IpAddr::V4(v4)) => v4.is_loopback(), - Ok(IpAddr::V6(v6)) => { - v6.is_loopback() || v6.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback()) - } - Err(_) => false, - } -} - -fn is_kubernetes_service_host(host: &str) -> bool { - let host = host.trim_end_matches('.').to_ascii_lowercase(); - let labels = host.split('.').collect::>(); - let is_service_name = labels.len() == 3 && labels[2] == "svc"; - let is_cluster_local_service = - labels.len() == 5 && labels[2] == "svc" && labels[3] == "cluster" && labels[4] == "local"; - (is_service_name || is_cluster_local_service) && labels.iter().all(|label| !label.is_empty()) -} - -pub fn validate_access_token(token: &str) -> Result<()> { - if token.is_empty() || !is_token68(token) { - return Err(miette::miette!( - "token grant returned a malformed access token" - )); - } - Ok(()) +) -> Result { + oauth::post_oauth_token_grant( + &TOKEN_GRANT_HTTP_CLIENT, + token_endpoint, + &TokenGrantParams { + client_assertion: jwt_svid, + client_assertion_type, + audience, + scopes, + }, + ) + .await } -fn is_token68(token: &str) -> bool { - let mut padding_started = false; - let mut saw_value = false; - for byte in token.bytes() { - if byte == b'=' { - padding_started = true; - continue; - } - if padding_started || !is_token68_value_byte(byte) { - return false; - } - saw_value = true; - } - saw_value +async fn perform_token_exchange( + token_endpoint: &str, + params: &TokenExchangeParams<'_>, +) -> Result { + oauth::post_oauth_token_exchange(&TOKEN_GRANT_HTTP_CLIENT, token_endpoint, params).await } -fn is_token68_value_byte(byte: u8) -> bool { - byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'+' | b'/') -} +pub use oauth::validate_access_token; fn token_cache_ttl_seconds(cache_ttl_override: i64, expires_in: i64) -> i64 { if cache_ttl_override > 0 { @@ -483,73 +399,52 @@ fn effective_jwt_svid_audience(token_endpoint: &str, jwt_svid_audience: &str) -> } } -fn effective_client_assertion_type(client_assertion_type: &str) -> &str { - if client_assertion_type.trim().is_empty() { - DEFAULT_CLIENT_ASSERTION_TYPE - } else { - client_assertion_type - } +fn supervisor_gateway_endpoint_from_env() -> Result { + std::env::var(sandbox_env::ENDPOINT) + .ok() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| miette::miette!("{} not set", sandbox_env::ENDPOINT)) } -fn token_cache_key( - provider_name: &str, - token_endpoint: &str, - jwt_svid_audience: &str, - client_assertion_type: &str, - audience: &str, - scopes: &[String], -) -> String { - format!( - "{}\t{}\t{}\t{}\t{}\t{}", - provider_name, - token_endpoint, - jwt_svid_audience, - client_assertion_type, - audience, - scopes.join(" ") - ) +fn supervisor_sandbox_id_from_env() -> Result { + std::env::var(sandbox_env::SANDBOX_ID) + .ok() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| miette::miette!("{} not set", sandbox_env::SANDBOX_ID)) } -fn token_grant_failure_message(status: reqwest::StatusCode, body: &str) -> String { - let Ok(error_response) = serde_json::from_str::(body) else { - return format!("token grant failed with status {status}"); - }; +fn parse_provider_credential_key(key: &str) -> Result<(&str, &str)> { + let provider_and_credential = key + .rsplit_once('\t') + .map_or(key, |(_, provider_and_credential)| provider_and_credential); + provider_and_credential.split_once(':').ok_or_else(|| { + miette::miette!("dynamic token grant key is missing provider credential identity") + }) +} - let error = error_response - .error - .as_deref() - .map(sanitize_oauth_error_field) - .filter(|value| !value.is_empty()); - let description = error_response - .error_description - .as_deref() - .map(sanitize_oauth_error_field) - .filter(|value| !value.is_empty()); - - match (error, description) { - (Some(error), Some(description)) => { - format!( - "token grant failed with status {status}: error={error}; error_description={description}" - ) - } - (Some(error), None) => { - format!("token grant failed with status {status}: error={error}") - } - (None, Some(description)) => { - format!("token grant failed with status {status}: error_description={description}") - } - (None, None) => format!("token grant failed with status {status}"), - } +struct TokenCacheKeyInput<'a> { + provider_name: &'a str, + token_endpoint: &'a str, + jwt_svid_audience: &'a str, + client_assertion_type: &'a str, + audience: &'a str, + scopes: &'a [String], + grant_type: ProviderCredentialTokenGrantType, + requested_token_type: &'a str, } -fn sanitize_oauth_error_field(value: &str) -> String { - value - .chars() - .map(|ch| if ch.is_control() { ' ' } else { ch }) - .take(MAX_OAUTH_ERROR_FIELD_LEN) - .collect::() - .trim() - .to_string() +fn token_cache_key(input: TokenCacheKeyInput<'_>) -> String { + format!( + "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", + input.provider_name, + input.token_endpoint, + input.jwt_svid_audience, + input.client_assertion_type, + input.audience, + input.scopes.join(" "), + input.grant_type as i32, + input.requested_token_type + ) } /// Get current Unix timestamp in milliseconds. @@ -564,6 +459,7 @@ fn current_time_ms() -> i64 { #[cfg(test)] mod tests { use super::*; + use openshell_core::oauth::{ACCESS_TOKEN_TYPE, DEFAULT_CLIENT_ASSERTION_TYPE}; use std::collections::HashMap; use std::sync::{ Arc, @@ -778,16 +674,17 @@ mod tests { audience: input.audience, scopes: input.scopes, cache_ttl_override: input.cache_ttl_override, + grant_type: ProviderCredentialTokenGrantType::ClientCredentials, + requested_token_type: "", }, move |_| { let grant_calls = input.grant_calls.clone(); async move { let call = grant_calls.fetch_add(1, Ordering::SeqCst) + 1; - Ok(TokenResponse { + Ok(OAuthTokenResponse { access_token: format!("token-{call}"), token_type: "Bearer".to_string(), expires_in: input.expires_in, - scope: input.scopes.join(" "), }) } }, @@ -814,6 +711,8 @@ mod tests { audience, scopes, cache_ttl_override, + grant_type: ProviderCredentialTokenGrantType::ClientCredentials, + requested_token_type: "", }, |_| async { Err(miette::miette!("grant should not be called on cache hit")) }, ) @@ -855,103 +754,98 @@ mod tests { } #[test] - fn validate_access_token_accepts_token68_values() { - for token in [ - "abcXYZ123-._~+/", - "eyJhbGciOiJSUzI1NiJ9.payload.sig", - "token==", - ] { - validate_access_token(token).expect("token68 bearer token should be accepted"); - } - } + fn token_cache_key_varies_by_resource_audience_and_scopes() { + let provider_name = "alpha.default.svc.cluster.local\t80\t\tprovider:access_token"; + let token_endpoint = + "https://auth.example.com/realms/openshell/protocol/openid-connect/token"; + let jwt_svid_audience = "https://auth.example.com/realms/openshell"; + let alpha_scopes = ["alpha".to_string()]; + let delta_scopes = ["delta".to_string()]; + let base = token_cache_key(TokenCacheKeyInput { + provider_name, + token_endpoint, + jwt_svid_audience, + client_assertion_type: DEFAULT_CLIENT_ASSERTION_TYPE, + audience: "alpha", + scopes: &alpha_scopes, + grant_type: ProviderCredentialTokenGrantType::ClientCredentials, + requested_token_type: ACCESS_TOKEN_TYPE, + }); + let different_audience = token_cache_key(TokenCacheKeyInput { + provider_name, + token_endpoint, + jwt_svid_audience, + client_assertion_type: DEFAULT_CLIENT_ASSERTION_TYPE, + audience: "delta", + scopes: &alpha_scopes, + grant_type: ProviderCredentialTokenGrantType::ClientCredentials, + requested_token_type: ACCESS_TOKEN_TYPE, + }); + let different_scopes = token_cache_key(TokenCacheKeyInput { + provider_name, + token_endpoint, + jwt_svid_audience, + client_assertion_type: DEFAULT_CLIENT_ASSERTION_TYPE, + audience: "alpha", + scopes: &delta_scopes, + grant_type: ProviderCredentialTokenGrantType::ClientCredentials, + requested_token_type: ACCESS_TOKEN_TYPE, + }); + let different_assertion_type = token_cache_key(TokenCacheKeyInput { + provider_name, + token_endpoint, + jwt_svid_audience, + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe", + audience: "alpha", + scopes: &alpha_scopes, + grant_type: ProviderCredentialTokenGrantType::ClientCredentials, + requested_token_type: ACCESS_TOKEN_TYPE, + }); - #[test] - fn validate_access_token_rejects_header_injection_and_non_token68_values() { - for token in [ - "", - "token with spaces", - "token\r\nX-Injected: yes", - "token\u{7f}", - "tokené", - "token=continued", - "==", - ] { - let err = validate_access_token(token) - .expect_err("malformed bearer token should be rejected"); - assert_eq!( - err.to_string(), - "token grant returned a malformed access token" - ); - } + assert_ne!(base, different_audience); + assert_ne!(base, different_scopes); + assert_ne!(base, different_assertion_type); } #[test] - fn token_endpoint_url_allows_https_loopback_and_in_cluster_http() { - for endpoint in [ - "https://auth.example.com/token", - "http://127.0.0.1:8080/token", - "http://[::1]:8080/token", - "http://token-issuer.default.svc.cluster.local/token", - "http://token-issuer.default.svc/token", - ] { - parse_token_endpoint_url(endpoint).expect("token endpoint should be allowed"); - } - } + fn token_cache_key_varies_by_provider_env_revision_prefix() { + let token_endpoint = + "https://auth.example.com/realms/openshell/protocol/openid-connect/token"; + let jwt_svid_audience = "https://auth.example.com/realms/openshell"; + let scopes = ["alpha".to_string()]; + let revision_one = token_cache_key(TokenCacheKeyInput { + provider_name: "api.example.test\t443\t/v1/**\trev:1\tprovider:access_token", + token_endpoint, + jwt_svid_audience, + client_assertion_type: DEFAULT_CLIENT_ASSERTION_TYPE, + audience: "alpha", + scopes: &scopes, + grant_type: ProviderCredentialTokenGrantType::TokenExchange, + requested_token_type: ACCESS_TOKEN_TYPE, + }); + let revision_two = token_cache_key(TokenCacheKeyInput { + provider_name: "api.example.test\t443\t/v1/**\trev:2\tprovider:access_token", + token_endpoint, + jwt_svid_audience, + client_assertion_type: DEFAULT_CLIENT_ASSERTION_TYPE, + audience: "alpha", + scopes: &scopes, + grant_type: ProviderCredentialTokenGrantType::TokenExchange, + requested_token_type: ACCESS_TOKEN_TYPE, + }); - #[test] - fn token_endpoint_url_rejects_plain_http_non_cluster_hosts() { - for endpoint in [ - "http://auth.example.com/token", - "http://keycloak/realms/openshell/protocol/openid-connect/token", - "http://token-issuer.default.svc.evil.com/token", - "ftp://auth.example.com/token", - "/relative/token", - ] { - assert!( - parse_token_endpoint_url(endpoint).is_err(), - "token endpoint should be rejected: {endpoint}" - ); - } + assert_ne!(revision_one, revision_two); } #[test] - fn token_cache_key_varies_by_resource_audience_and_scopes() { - let base = token_cache_key( - "alpha.default.svc.cluster.local\t80\t\tprovider:access_token", - "https://auth.example.com/realms/openshell/protocol/openid-connect/token", - "https://auth.example.com/realms/openshell", - DEFAULT_CLIENT_ASSERTION_TYPE, - "alpha", - &["alpha".to_string()], - ); - let different_audience = token_cache_key( - "alpha.default.svc.cluster.local\t80\t\tprovider:access_token", - "https://auth.example.com/realms/openshell/protocol/openid-connect/token", - "https://auth.example.com/realms/openshell", - DEFAULT_CLIENT_ASSERTION_TYPE, - "delta", - &["alpha".to_string()], - ); - let different_scopes = token_cache_key( - "alpha.default.svc.cluster.local\t80\t\tprovider:access_token", - "https://auth.example.com/realms/openshell/protocol/openid-connect/token", - "https://auth.example.com/realms/openshell", - DEFAULT_CLIENT_ASSERTION_TYPE, - "alpha", - &["delta".to_string()], - ); - let different_assertion_type = token_cache_key( - "alpha.default.svc.cluster.local\t80\t\tprovider:access_token", - "https://auth.example.com/realms/openshell/protocol/openid-connect/token", - "https://auth.example.com/realms/openshell", - "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe", - "alpha", - &["alpha".to_string()], + fn provider_credential_key_parser_ignores_revision_segment() { + assert_eq!( + parse_provider_credential_key( + "api.example.test\t443\t/v1/**\trev:42\tprovider:access_token" + ) + .expect("parse provider credential key"), + ("provider", "access_token") ); - - assert_ne!(base, different_audience); - assert_ne!(base, different_scopes); - assert_ne!(base, different_assertion_type); } #[test] @@ -1077,14 +971,16 @@ mod tests { let jwt_svid_audience = "https://auth.example.com"; let audience = "api://resource"; - let cache_key = token_cache_key( + let cache_key = token_cache_key(TokenCacheKeyInput { provider_name, token_endpoint, jwt_svid_audience, - DEFAULT_CLIENT_ASSERTION_TYPE, + client_assertion_type: DEFAULT_CLIENT_ASSERTION_TYPE, audience, - &scopes, - ); + scopes: &scopes, + grant_type: ProviderCredentialTokenGrantType::ClientCredentials, + requested_token_type: ACCESS_TOKEN_TYPE, + }); cache.set( cache_key, "expired-token".to_string(), @@ -1109,57 +1005,6 @@ mod tests { assert_eq!(grant_calls.load(Ordering::SeqCst), 1); } - #[tokio::test] - async fn obtain_provider_token_rejects_malformed_token_before_cache() { - let cache = TokenCache::new(); - let scopes = vec!["read".to_string()]; - let provider_name = "api.example.test\t443\t/v1/**\tprovider:access_token"; - let token_endpoint = "https://auth.example.com/token"; - let jwt_svid_audience = "https://auth.example.com"; - let audience = "api://resource"; - - let err = obtain_provider_token_with_grant( - ObtainProviderTokenInput { - cache: &cache, - provider_name, - token_endpoint, - jwt_svid_audience, - client_assertion_type: DEFAULT_CLIENT_ASSERTION_TYPE, - audience, - scopes: &scopes, - cache_ttl_override: 0, - }, - |_| async { - Ok(TokenResponse { - access_token: "access-123\r\nX-Injected: yes".to_string(), - token_type: "Bearer".to_string(), - expires_in: 60, - scope: "read".to_string(), - }) - }, - ) - .await - .expect_err("malformed access token should fail before caching"); - - let cache_key = token_cache_key( - provider_name, - token_endpoint, - jwt_svid_audience, - DEFAULT_CLIENT_ASSERTION_TYPE, - audience, - &scopes, - ); - - assert_eq!( - err.to_string(), - "token grant returned a malformed access token" - ); - assert!( - cache.get(&cache_key).is_none(), - "malformed access token must not be cached" - ); - } - #[tokio::test] async fn obtain_provider_token_cache_ttl_override_extends_zero_expires_in() { let cache = TokenCache::new(); @@ -1382,43 +1227,4 @@ mod tests { .contains("failed to parse token response as JSON") ); } - - #[test] - fn token_grant_failure_message_reports_oauth_error_fields() { - let message = token_grant_failure_message( - reqwest::StatusCode::UNAUTHORIZED, - r#"{"error":"invalid_client","error_description":"Invalid client credentials"}"#, - ); - - assert_eq!( - message, - "token grant failed with status 401 Unauthorized: error=invalid_client; error_description=Invalid client credentials" - ); - } - - #[test] - fn token_grant_failure_message_omits_unstructured_response_body() { - let message = token_grant_failure_message( - reqwest::StatusCode::INTERNAL_SERVER_ERROR, - "internal error containing implementation details", - ); - - assert_eq!( - message, - "token grant failed with status 500 Internal Server Error" - ); - } - - #[test] - fn token_grant_failure_message_sanitizes_oauth_error_fields() { - let long_description = "a".repeat(MAX_OAUTH_ERROR_FIELD_LEN + 20); - let body = - format!(r#"{{"error":"invalid_client\n","error_description":"{long_description}"}}"#); - let message = token_grant_failure_message(reqwest::StatusCode::UNAUTHORIZED, &body); - - assert!(!message.contains('\n')); - assert!(message.contains("error=invalid_client")); - assert!(message.contains(&"a".repeat(MAX_OAUTH_ERROR_FIELD_LEN))); - assert!(!message.contains(&"a".repeat(MAX_OAUTH_ERROR_FIELD_LEN + 1))); - } } diff --git a/crates/openshell-supervisor-process/src/process.rs b/crates/openshell-supervisor-process/src/process.rs index b61a51c5b2..52557493cd 100644 --- a/crates/openshell-supervisor-process/src/process.rs +++ b/crates/openshell-supervisor-process/src/process.rs @@ -9,6 +9,8 @@ use crate::managed_children; #[cfg(target_os = "linux")] use crate::netns::NetworkNamespace; use crate::sandbox; +#[cfg(target_os = "linux")] +use miette::WrapErr; use miette::{IntoDiagnostic, Result}; use nix::sys::signal::{self, Signal}; use nix::unistd::{Gid, Group, Pid, Uid, User}; @@ -18,7 +20,7 @@ use std::ffi::CString; #[cfg(unix)] use std::os::fd::AsRawFd; #[cfg(target_os = "linux")] -use std::os::fd::{OwnedFd, RawFd}; +use std::os::fd::RawFd; #[cfg(target_os = "linux")] use std::os::unix::ffi::OsStrExt; #[cfg(unix)] @@ -29,6 +31,8 @@ use std::path::PathBuf; use std::process::Stdio; #[cfg(target_os = "linux")] use std::sync::OnceLock; +#[cfg(target_os = "linux")] +use std::sync::mpsc; use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command}; use tracing::{debug, info}; @@ -397,11 +401,13 @@ static SUPERVISOR_IDENTITY_MOUNT_NS: OnceLock, } #[cfg(target_os = "linux")] type SupervisorIdentityNsRef = &'static SupervisorIdentityMountNamespace; +#[cfg(target_os = "linux")] +type SupervisorIdentitySpawnJob = Box; #[cfg(target_os = "linux")] impl SupervisorIdentityMountNamespace { @@ -410,13 +416,9 @@ impl SupervisorIdentityMountNamespace { return Ok(None); }; Ok(Some(Self { - fd: create_supervisor_identity_mount_namespace(&target)?, + spawn_tx: start_supervisor_identity_spawn_worker(target)?, })) } - - pub fn enter_for_child(&self) -> std::io::Result<()> { - set_mount_namespace(self.fd.as_raw_fd()) - } } #[cfg(target_os = "linux")] @@ -447,6 +449,100 @@ pub fn supervisor_identity_mount_from_env() -> Result std::io::Result { + let namespace = supervisor_identity_mount_from_env() + .map_err(|err| std::io::Error::other(err.to_string()))?; + let Some(namespace) = namespace else { + return cmd.spawn(); + }; + namespace.spawn_tokio_command(cmd) +} + +#[cfg(target_os = "linux")] +pub fn spawn_std_command_with_supervisor_identity_namespace( + mut cmd: std::process::Command, +) -> std::io::Result { + let namespace = supervisor_identity_mount_from_env() + .map_err(|err| std::io::Error::other(err.to_string()))?; + let Some(namespace) = namespace else { + return cmd.spawn(); + }; + namespace.spawn_std_command(cmd) +} + +#[cfg(target_os = "linux")] +impl SupervisorIdentityMountNamespace { + fn spawn_tokio_command(&self, mut cmd: Command) -> std::io::Result { + let (result_tx, result_rx) = mpsc::channel(); + let handle = tokio::runtime::Handle::current(); + self.spawn_tx + .send(Box::new(move || { + let _guard = handle.enter(); + let _ = result_tx.send(cmd.spawn()); + })) + .map_err(|_| std::io::Error::other("supervisor identity spawn worker stopped"))?; + result_rx + .recv() + .map_err(|_| std::io::Error::other("supervisor identity spawn worker dropped result"))? + } + + fn spawn_std_command( + &self, + mut cmd: std::process::Command, + ) -> std::io::Result { + let (result_tx, result_rx) = mpsc::channel(); + self.spawn_tx + .send(Box::new(move || { + let _ = result_tx.send(cmd.spawn()); + })) + .map_err(|_| std::io::Error::other("supervisor identity spawn worker stopped"))?; + result_rx + .recv() + .map_err(|_| std::io::Error::other("supervisor identity spawn worker dropped result"))? + } +} + +#[cfg(target_os = "linux")] +fn start_supervisor_identity_spawn_worker( + target: PathBuf, +) -> Result> { + let (spawn_tx, spawn_rx) = mpsc::channel::(); + let (ready_tx, ready_rx) = mpsc::channel::>(); + std::thread::Builder::new() + .name("openshell-identity-spawn".into()) + .spawn(move || { + let setup = (|| -> std::io::Result<()> { + private_mount_namespace()?; + let target = + cstring_path(&target).map_err(|err| std::io::Error::other(err.to_string()))?; + mount_empty_tmpfs(&target) + })(); + let ready = match &setup { + Ok(()) => Ok(()), + Err(err) => Err(std::io::Error::new( + err.kind(), + format!("supervisor identity setup failed: {err}"), + )), + }; + let _ = ready_tx.send(ready); + if setup.is_err() { + return; + } + while let Ok(job) = spawn_rx.recv() { + job(); + } + }) + .map_err(|err| miette::miette!("failed to spawn supervisor identity worker: {err}"))?; + ready_rx + .recv() + .map_err(|err| miette::miette!("supervisor identity worker did not start: {err}"))? + .map_err(|err| miette::miette!("{err}"))?; + Ok(spawn_tx) +} + #[cfg(target_os = "linux")] fn supervisor_identity_socket_path_from_env() -> Option<(&'static str, String)> { std::env::var(openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET) @@ -467,10 +563,7 @@ fn supervisor_identity_mount_target(socket_path: &str) -> Result return Ok(None); } if trimmed.starts_with("tcp:") { - return Err(miette::miette!( - "{} must be a UNIX socket path so sandbox child processes can hide it", - openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET - )); + return Ok(None); } let path = trimmed.strip_prefix("unix:").unwrap_or(trimmed); let path = Path::new(path); @@ -513,52 +606,12 @@ fn cstring_path(path: &Path) -> Result { .map_err(|_| miette::miette!("path contains an interior NUL byte: {}", path.display())) } -#[cfg(target_os = "linux")] -fn create_supervisor_identity_mount_namespace(target: &Path) -> Result { - let original_ns = open_current_mount_namespace() - .map_err(|err| miette::miette!("failed to open original mount namespace: {err}"))?; - - private_mount_namespace() - .map_err(|err| miette::miette!("failed to create supervisor identity namespace: {err}"))?; - - let target = cstring_path(target)?; - let result = (|| -> Result { - mount_empty_tmpfs(&target).map_err(|err| { - miette::miette!("failed to hide supervisor identity mount from child namespace: {err}") - })?; - open_current_mount_namespace() - .map_err(|err| miette::miette!("failed to open sanitized mount namespace: {err}")) - })(); - - set_mount_namespace(original_ns.as_raw_fd()).map_err(|restore_err| { - let result_msg = result.as_ref().err().map_or_else( - || "sanitized namespace was created".to_string(), - ToString::to_string, - ); - miette::miette!( - "failed to restore original mount namespace after supervisor identity isolation setup: \ - {restore_err}; setup result: {result_msg}" - ) - })?; - - result -} - -#[cfg(target_os = "linux")] -fn open_current_mount_namespace() -> std::io::Result { - let file = std::fs::File::open("/proc/thread-self/ns/mnt")?; - Ok(file.into()) -} - #[cfg(target_os = "linux")] fn private_mount_namespace() -> std::io::Result<()> { #[allow(unsafe_code)] let rc = unsafe { libc::unshare(libc::CLONE_NEWNS) }; if rc != 0 { - return Err(std::io::Error::other(format!( - "failed to create private mount namespace: {}", - std::io::Error::last_os_error() - ))); + return Err(std::io::Error::last_os_error()); } #[allow(unsafe_code)] @@ -573,23 +626,7 @@ fn private_mount_namespace() -> std::io::Result<()> { ) }; if rc != 0 { - return Err(std::io::Error::other(format!( - "failed to mark mount namespace private: {}", - std::io::Error::last_os_error() - ))); - } - Ok(()) -} - -#[cfg(target_os = "linux")] -fn set_mount_namespace(fd: RawFd) -> std::io::Result<()> { - #[allow(unsafe_code)] - let rc = unsafe { libc::setns(fd, libc::CLONE_NEWNS) }; - if rc != 0 { - return Err(std::io::Error::other(format!( - "failed to enter mount namespace: {}", - std::io::Error::last_os_error() - ))); + return Err(std::io::Error::last_os_error()); } Ok(()) } @@ -609,10 +646,7 @@ fn mount_empty_tmpfs(target: &CString) -> std::io::Result<()> { ) }; if rc != 0 { - return Err(std::io::Error::other(format!( - "failed to hide supervisor identity mount from child process: {}", - std::io::Error::last_os_error() - ))); + return Err(std::io::Error::last_os_error()); } Ok(()) } @@ -808,15 +842,6 @@ impl ProcessHandle { #[cfg(target_os = "linux")] let prepared_sandbox = prepare_child_sandbox(policy, workspace.root(), enforcement_mode) .map_err(|err| miette::miette!("Failed to prepare sandbox: {err}"))?; - #[cfg(target_os = "linux")] - let supervisor_identity_mount = if enforcement_mode.uses_privileged_process_setup() { - supervisor_identity_mount_from_env().map_err(|err| { - miette::miette!("Failed to prepare supervisor identity isolation: {err}") - })? - } else { - None - }; - // Set up process group for signal handling (non-interactive mode only). // In interactive mode, we inherit the parent's process group to maintain // proper terminal control for shells and interactive programs. @@ -840,19 +865,17 @@ impl ProcessHandle { return Err(std::io::Error::last_os_error()); } - // Enter network namespace before applying other restrictions + // Enter network namespace before applying other restrictions. if let Some(fd) = netns_fd { let result = libc::setns(fd, libc::CLONE_NEWNET); if result != 0 { - return Err(std::io::Error::last_os_error()); + return Err(std::io::Error::other(format!( + "failed to enter network namespace: {}", + std::io::Error::last_os_error() + ))); } } - #[cfg(target_os = "linux")] - if let Some(mount) = supervisor_identity_mount { - mount.enter_for_child()?; - } - // Drop privileges. initgroups/setgid/setuid need access to // /etc/group and /etc/passwd which would be blocked if // Landlock were already enforced. @@ -877,7 +900,15 @@ impl ProcessHandle { } } - let mut child = cmd.spawn().into_diagnostic()?; + #[cfg(target_os = "linux")] + let mut child = spawn_command_with_supervisor_identity_namespace(cmd) + .into_diagnostic() + .wrap_err("failed to spawn sandbox entrypoint process")?; + #[cfg(not(target_os = "linux"))] + let mut child = cmd + .spawn() + .into_diagnostic() + .wrap_err("failed to spawn sandbox entrypoint process")?; let pid = child.id().unwrap_or(0); managed_children::register(pid); @@ -3873,7 +3904,11 @@ mod tests { #[test] fn supervisor_identity_mount_target_rejects_unhideable_endpoints() { - assert!(supervisor_identity_mount_target("tcp:127.0.0.1:8081").is_err()); + assert_eq!( + supervisor_identity_mount_target("tcp:127.0.0.1:8081") + .expect("tcp endpoint should not require mount hiding"), + None + ); assert!(supervisor_identity_mount_target("spiffe-workload-api/spire-agent.sock").is_err()); assert!(supervisor_identity_mount_target("/spire-agent.sock").is_err()); } diff --git a/crates/openshell-supervisor-process/src/run.rs b/crates/openshell-supervisor-process/src/run.rs index 34b0001110..8a2080f217 100644 --- a/crates/openshell-supervisor-process/src/run.rs +++ b/crates/openshell-supervisor-process/src/run.rs @@ -117,10 +117,18 @@ pub async fn run_process( // the flag stays at its default (false) and no skill is installed. install_initial_agent_skill(sandbox_id, openshell_endpoint, &agent_proposals).await; + // Provider token grants may mount supervisor-only identity sockets such as + // the SPIFFE Workload API. Prepare the child mount namespace that hides + // those mounts before supervisor seccomp hardening removes the needed + // namespace syscalls. + #[cfg(target_os = "linux")] + crate::process::prepare_supervisor_identity_mount_namespace_from_env()?; + // Install the supervisor seccomp prelude before spawning any workload-side // tasks. By this point the orchestrator has finished privileged startup - // helpers (network namespace setup, nftables probes via run_networking), - // and the SSH listener and entrypoint child have not been exposed yet. + // helpers (network namespace setup, identity mount namespace setup, + // nftables probes via run_networking), and the SSH listener and entrypoint + // child have not been exposed yet. crate::sandbox::apply_supervisor_startup_hardening()?; // Spawn the bypass detection monitor. It tails dmesg for nftables LOG diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index 41546f6473..a56ab8f2d7 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -1131,9 +1131,12 @@ fn spawn_pty_shell( enforcement_mode, #[cfg(target_os = "linux")] prepared_sandbox, - )?; + ); } + #[cfg(target_os = "linux")] + let mut child = crate::process::spawn_std_command_with_supervisor_identity_namespace(cmd)?; + #[cfg(not(target_os = "linux"))] let mut child = cmd.spawn()?; #[cfg(target_os = "linux")] let child_pid = child.id(); @@ -1286,9 +1289,12 @@ fn spawn_pipe_exec( enforcement_mode, #[cfg(target_os = "linux")] prepared_sandbox, - )?; + ); } + #[cfg(target_os = "linux")] + let mut child = crate::process::spawn_std_command_with_supervisor_identity_namespace(cmd)?; + #[cfg(not(target_os = "linux"))] let mut child = cmd.spawn()?; #[cfg(target_os = "linux")] let child_pid = child.id(); @@ -1429,19 +1435,11 @@ mod unsafe_pty { resolved_identity: ResolvedProcessIdentity, enforcement_mode: ProcessEnforcementMode, #[cfg(target_os = "linux")] prepared: Option, - ) -> anyhow::Result<()> { + ) { // Wrap in Option so we can .take() it out of the FnMut closure. // pre_exec is only called once (after fork, before exec). #[cfg(target_os = "linux")] let mut prepared = prepared; - #[cfg(target_os = "linux")] - let supervisor_identity_mount = if enforcement_mode.uses_privileged_process_setup() { - crate::process::supervisor_identity_mount_from_env().map_err(|err| { - anyhow::anyhow!("failed to prepare supervisor identity isolation: {err}") - })? - } else { - None - }; unsafe { cmd.pre_exec(move || { setsid().map_err(|err| std::io::Error::other(err.to_string()))?; @@ -1453,13 +1451,10 @@ mod unsafe_pty { resolved_identity, enforcement_mode, #[cfg(target_os = "linux")] - supervisor_identity_mount, - #[cfg(target_os = "linux")] prepared.take(), ) }); } - Ok(()) } /// Pre-exec hook for pipe-based (non-PTY) exec. @@ -1481,17 +1476,9 @@ mod unsafe_pty { resolved_identity: ResolvedProcessIdentity, enforcement_mode: ProcessEnforcementMode, #[cfg(target_os = "linux")] prepared: Option, - ) -> anyhow::Result<()> { + ) { #[cfg(target_os = "linux")] let mut prepared = prepared; - #[cfg(target_os = "linux")] - let supervisor_identity_mount = if enforcement_mode.uses_privileged_process_setup() { - crate::process::supervisor_identity_mount_from_env().map_err(|err| { - anyhow::anyhow!("failed to prepare supervisor identity isolation: {err}") - })? - } else { - None - }; unsafe { cmd.pre_exec(move || { enter_netns_and_sandbox( @@ -1500,13 +1487,10 @@ mod unsafe_pty { resolved_identity, enforcement_mode, #[cfg(target_os = "linux")] - supervisor_identity_mount, - #[cfg(target_os = "linux")] prepared.take(), ) }); } - Ok(()) } fn enter_netns_and_sandbox( @@ -1514,9 +1498,6 @@ mod unsafe_pty { policy: &SandboxPolicy, resolved_identity: ResolvedProcessIdentity, enforcement_mode: ProcessEnforcementMode, - #[cfg(target_os = "linux")] supervisor_identity_mount: Option< - &crate::process::SupervisorIdentityMountNamespace, - >, #[cfg(target_os = "linux")] prepared: Option, ) -> std::io::Result<()> { // Enter network namespace before dropping privileges. @@ -1535,11 +1516,6 @@ mod unsafe_pty { #[cfg(not(target_os = "linux"))] let _ = netns_fd; - #[cfg(target_os = "linux")] - if let Some(mount) = supervisor_identity_mount { - mount.enter_for_child()?; - } - // Drop privileges. initgroups/setgid/setuid need /etc/group and // /etc/passwd which would be blocked if Landlock were already enforced. if enforcement_mode.uses_privileged_process_setup() { @@ -2190,8 +2166,7 @@ mod tests { ) .expect("prepare should succeed in test environment"), ), - ) - .expect("install pre_exec should succeed"); + ); let output = cmd .spawn() @@ -2246,8 +2221,7 @@ mod tests { ProcessEnforcementMode::Full, #[cfg(target_os = "linux")] None, - ) - .expect("install pre_exec should succeed"); + ); let output = cmd .spawn() diff --git a/deploy/helm/openshell/README.md b/deploy/helm/openshell/README.md index 817900e2d2..5879d617ff 100644 --- a/deploy/helm/openshell/README.md +++ b/deploy/helm/openshell/README.md @@ -146,15 +146,20 @@ JWT signing Secret. ## SPIFFE/SPIRE provider token grants -Set `server.providerTokenGrants.spiffe.enabled=true` to let sandbox supervisors -use SPIFFE JWT-SVIDs for dynamic provider token grants. The chart keeps -supervisor-to-gateway authentication on gateway-minted sandbox JWTs and passes -the SPIFFE Workload API socket path to the Kubernetes driver so sandbox pods can -mount the SPIFFE CSI socket. +Set `server.providerTokenGrants.spiffe.enabled=true` to let the gateway and +sandbox supervisors use SPIFFE JWT-SVIDs for dynamic provider token grants. The +chart keeps supervisor-to-gateway authentication on gateway-minted sandbox JWTs, +mounts the SPIFFE CSI socket into the gateway pod, exports +`OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET`, and passes the socket path to +the Kubernetes driver so sandbox pods can mount the same socket. For local development, uncomment the SPIRE Helm releases in `skaffold.yaml` and add `ci/values-spire.yaml` to the OpenShell release values files. +The gateway verifies supervisor JWT-SVIDs with JWT bundles fetched from the +SPIFFE Workload API, so this path does not require access to the SPIRE OIDC +discovery endpoint or its TLS CA. + ## Values | Key | Type | Default | Description | @@ -257,8 +262,8 @@ add `ci/values-spire.yaml` to the OpenShell release values files. | server.oidc.scopesClaim | string | `""` | Dot-separated path to the scopes array in the JWT claims. | | server.oidc.userRole | string | `""` | Role name for standard user access. | | server.policyValidationFailureMode | string | `"fail_closed"` | Posture when a candidate sandbox policy fails validation. `fail_closed` deactivates the previous policy; `retain_last_valid` keeps it active. | -| server.providerTokenGrants.spiffe.enabled | bool | `false` | Mount the SPIFFE Workload API socket into sandbox pods for dynamic provider token grants. | -| server.providerTokenGrants.spiffe.workloadApiSocketPath | string | `"/spiffe-workload-api/spire-agent.sock"` | Path to the SPIFFE Workload API socket mounted into sandbox pods. | +| server.providerTokenGrants.spiffe.enabled | bool | `false` | Mount the SPIFFE Workload API socket into gateway and sandbox pods for dynamic provider token grants. | +| server.providerTokenGrants.spiffe.workloadApiSocketPath | string | `"/spiffe-workload-api/spire-agent.sock"` | Path to the SPIFFE Workload API socket mounted into gateway and sandbox pods. | | server.sandboxImage | string | `"ghcr.io/nvidia/openshell-community/sandboxes/base:latest"` | Default sandbox image used when requests do not specify one. | | server.sandboxImagePullPolicy | string | `""` | Kubernetes imagePullPolicy for sandbox pods. Empty = Kubernetes default (Always for :latest, IfNotPresent otherwise). Set to "Always" for dev clusters so new images are picked up without manual eviction. | | server.sandboxImagePullSecrets | list | `[]` | Image pull secrets attached to sandbox pods. Referenced Secrets must exist in the sandbox namespace. | diff --git a/deploy/helm/openshell/README.md.gotmpl b/deploy/helm/openshell/README.md.gotmpl index b3dd63e083..73ebb39c88 100644 --- a/deploy/helm/openshell/README.md.gotmpl +++ b/deploy/helm/openshell/README.md.gotmpl @@ -146,14 +146,19 @@ JWT signing Secret. ## SPIFFE/SPIRE provider token grants -Set `server.providerTokenGrants.spiffe.enabled=true` to let sandbox supervisors -use SPIFFE JWT-SVIDs for dynamic provider token grants. The chart keeps -supervisor-to-gateway authentication on gateway-minted sandbox JWTs and passes -the SPIFFE Workload API socket path to the Kubernetes driver so sandbox pods can -mount the SPIFFE CSI socket. +Set `server.providerTokenGrants.spiffe.enabled=true` to let the gateway and +sandbox supervisors use SPIFFE JWT-SVIDs for dynamic provider token grants. The +chart keeps supervisor-to-gateway authentication on gateway-minted sandbox JWTs, +mounts the SPIFFE CSI socket into the gateway pod, exports +`OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET`, and passes the socket path to +the Kubernetes driver so sandbox pods can mount the same socket. For local development, uncomment the SPIRE Helm releases in `skaffold.yaml` and add `ci/values-spire.yaml` to the OpenShell release values files. +The gateway verifies supervisor JWT-SVIDs with JWT bundles fetched from the +SPIFFE Workload API, so this path does not require access to the SPIRE OIDC +discovery endpoint or its TLS CA. + {{ template "chart.valuesSection" . }} {{ template "helm-docs.versionFooter" . }} diff --git a/deploy/helm/openshell/templates/_gateway-workload.tpl b/deploy/helm/openshell/templates/_gateway-workload.tpl index 5ff608ae59..a73acc9810 100644 --- a/deploy/helm/openshell/templates/_gateway-workload.tpl +++ b/deploy/helm/openshell/templates/_gateway-workload.tpl @@ -66,7 +66,8 @@ spec: {{- end }} # Most gateway settings live in the ConfigMap-backed TOML file # mounted at /etc/openshell/gateway.toml. Secret-bearing settings use - # env vars that the TOML references by name. + # env vars that the TOML references by name. Some process-level + # settings consumed by libraries outside gateway code also remain here. {{- if and .Values.server.oidc.issuer .Values.server.oidc.caConfigMapName }} # OIDC issuer custom-CA: rustls/reqwest read SSL_CERT_FILE for # outbound TLS verification. This is a process-level env var @@ -77,6 +78,10 @@ spec: {{- end }} - name: OPENSHELL_TELEMETRY_ENABLED value: {{ .Values.server.telemetryEnabled | quote }} + {{- if .Values.server.providerTokenGrants.spiffe.enabled }} + - name: OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET + value: {{ .Values.server.providerTokenGrants.spiffe.workloadApiSocketPath | quote }} + {{- end }} volumeMounts: {{- if eq (include "openshell.workloadKind" .) "statefulset" }} - name: openshell-data @@ -108,6 +113,11 @@ spec: mountPath: /etc/openshell-tls/oidc-ca readOnly: true {{- end }} + {{- if .Values.server.providerTokenGrants.spiffe.enabled }} + - name: spiffe-workload-api + mountPath: {{ dir .Values.server.providerTokenGrants.spiffe.workloadApiSocketPath | quote }} + readOnly: true + {{- end }} ports: - name: grpc containerPort: {{ .Values.service.port }} @@ -180,6 +190,12 @@ spec: configMap: name: {{ .Values.server.oidc.caConfigMapName }} {{- end }} + {{- if .Values.server.providerTokenGrants.spiffe.enabled }} + - name: spiffe-workload-api + csi: + driver: csi.spiffe.io + readOnly: true + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 4 }} diff --git a/deploy/helm/openshell/tests/gateway_config_test.yaml b/deploy/helm/openshell/tests/gateway_config_test.yaml index afacd01eb4..eaa2140862 100644 --- a/deploy/helm/openshell/tests/gateway_config_test.yaml +++ b/deploy/helm/openshell/tests/gateway_config_test.yaml @@ -549,14 +549,42 @@ tests: path: data["gateway.toml"] pattern: '\[openshell\.gateway\.spiffe\]' - - it: keeps the gateway sandbox JWT secret mounted when provider SPIFFE grants are enabled + - it: mounts the gateway SPIFFE socket while keeping sandbox JWT auth set: server.providerTokenGrants.spiffe.enabled: true template: templates/statefulset.yaml asserts: - - matchRegex: - path: spec.template.spec.volumes[1].name - pattern: '^sandbox-jwt$' + - contains: + path: spec.template.spec.containers[0].env + content: + name: OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET + value: /spiffe-workload-api/spire-agent.sock + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: sandbox-jwt + mountPath: /etc/openshell-jwt + readOnly: true + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: spiffe-workload-api + mountPath: /spiffe-workload-api + readOnly: true + - contains: + path: spec.template.spec.volumes + content: + name: sandbox-jwt + secret: + defaultMode: 256 + secretName: openshell-jwt-keys + - contains: + path: spec.template.spec.volumes + content: + name: spiffe-workload-api + csi: + driver: csi.spiffe.io + readOnly: true - it: fails when serverIssuerRef is set but certManager is disabled template: templates/statefulset.yaml diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index b4b14ef9d3..f2c3c28dd8 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -376,15 +376,15 @@ server: # (owner-read only). Override to 0440 or 0444 if the container UID # does not match the volume file owner. secretDefaultMode: "" - # Dynamic provider token grants. When SPIFFE is enabled here, sandbox - # supervisors mount the SPIFFE Workload API socket so provider profiles can - # exchange JWT-SVIDs for upstream access tokens. Supervisor-to-gateway - # authentication still uses gateway-minted sandbox JWTs. + # Dynamic provider token grants. When SPIFFE is enabled here, both the + # gateway and sandbox supervisors mount the SPIFFE Workload API socket so + # token-exchange profiles can use gateway- and sandbox-scoped JWT-SVIDs. + # Supervisor-to-gateway authentication still uses gateway-minted sandbox JWTs. providerTokenGrants: spiffe: - # -- Mount the SPIFFE Workload API socket into sandbox pods for dynamic provider token grants. + # -- Mount the SPIFFE Workload API socket into gateway and sandbox pods for dynamic provider token grants. enabled: false - # -- Path to the SPIFFE Workload API socket mounted into sandbox pods. + # -- Path to the SPIFFE Workload API socket mounted into gateway and sandbox pods. workloadApiSocketPath: /spiffe-workload-api/spire-agent.sock # OIDC (OpenID Connect) configuration for JWT-based authentication. # When issuer is set, the server validates Bearer tokens on gRPC requests. diff --git a/docs/kubernetes/access-control.mdx b/docs/kubernetes/access-control.mdx index 5409a4b11d..e3addad032 100644 --- a/docs/kubernetes/access-control.mdx +++ b/docs/kubernetes/access-control.mdx @@ -23,9 +23,11 @@ For how the CLI resolves gateways and stores credentials, refer to [Gateway Auth Kubernetes sandbox supervisors authenticate back to the gateway as sandbox workloads. By default, the gateway mints its own sandbox JWTs and Kubernetes sandboxes bootstrap them with a projected ServiceAccount token. -Dynamic provider token grants can use SPIFFE without changing supervisor-to-gateway authentication. Set `server.providerTokenGrants.spiffe.enabled=true` to mount the SPIFFE CSI Workload API socket into sandbox pods while keeping the projected ServiceAccount token bootstrap and gateway-minted sandbox JWT path. +Dynamic provider token grants can use SPIFFE without changing supervisor-to-gateway authentication. Set `server.providerTokenGrants.spiffe.enabled=true` to mount the SPIFFE CSI Workload API socket into gateway and sandbox pods while keeping the projected ServiceAccount token bootstrap and gateway-minted sandbox JWT path. -Provider token grants require a SPIFFE implementation such as SPIRE and a `ClusterSPIFFEID` that assigns per-sandbox IDs from the pod's `openshell.io/sandbox-id` annotation. Provider profiles with `token_grant` metadata cause the sandbox supervisor to request JWT-SVIDs and exchange them for upstream OAuth2 access tokens. +Provider token grants require a SPIFFE implementation such as SPIRE and identities for the gateway and sandbox pods. The repository's local SPIRE overlay assigns sandbox IDs from the pod's `openshell.io/sandbox-id` annotation, but the gateway validation path only requires the supervisor SVID to be valid and in the same SPIFFE trust domain as the gateway SVID. Provider profiles with `token_grant` metadata cause the sandbox supervisor to request JWT-SVIDs and exchange them for upstream OAuth2 access tokens. Token-exchange profiles also require a gateway SPIFFE identity because the gateway brokers the intermediate token exchange with its own JWT-SVID. + +The gateway verifies supervisor JWT-SVIDs with JWT bundles fetched from the SPIFFE Workload API, so intermediate token exchange does not require gateway access to the SPIRE OIDC discovery endpoint or its TLS CA. ## OIDC User Authentication @@ -74,6 +76,8 @@ helm upgrade openshell \ Both `adminRole` and `userRole` must be set, or both must be empty. Setting only one is not supported. +OIDC RBAC is method-level authorization. It controls which API operations a caller can perform, but provider and sandbox records are not owned by individual OIDC subjects. In shared clusters, treat provider credentials as gateway-wide resources and use separate gateways or external tenancy controls when users must not see or attach each other's providers and sandboxes. + ### Provider-specific rolesClaim paths | Provider | rolesClaim value | diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index cefae0b5cb..59fc35c485 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -553,6 +553,14 @@ In managed workspace mode, the Kubernetes driver copies each explicitly named namespace on sandbox creation. Shared and operator modes require the Secret to already exist in the sandbox namespace. +For token-exchange provider profiles, the gateway also needs access to its own +SPIFFE Workload API socket. In Helm deployments, set +`server.providerTokenGrants.spiffe.enabled=true`; the chart mounts the socket +into the gateway pod and sets `OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET`. +The gateway verifies supervisor JWT-SVIDs with JWT bundles fetched from the +SPIFFE Workload API, so this validation path does not require gateway access to +the SPIRE OIDC discovery endpoint or its TLS CA. + ### Docker Sandboxes run as containers on a local bridge network. The supervisor binary is bind-mounted from the host (no in-cluster image pull required); guest mTLS material is supplied as host paths. diff --git a/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index 8cf1e3715c..66fc418041 100644 --- a/docs/sandboxes/manage-providers.mdx +++ b/docs/sandboxes/manage-providers.mdx @@ -60,6 +60,35 @@ openshell provider create --name my-nvidia --type nvidia --credential NVIDIA_API This looks up the current value of `$NVIDIA_API_KEY` in your shell and stores it. +### From the Current OIDC Login + +Profile-backed token-exchange providers can store the current gateway OIDC +access token as their subject credential. The subject credential stays +gateway-only and is not emitted into sandbox environment material or static +credential bindings: + +```shell +openshell provider create \ + --name custom-api \ + --type custom-api \ + --from-oidc-token +``` + +OpenShell infers the destination credential from the provider profile when the +profile has exactly one `token_grant.subject_token.credential`. If the profile +has more than one, pass `--credential `. Refresh the stored subject token +later with: + +```shell +openshell provider update custom-api --from-oidc-token +``` + +This copies the current OIDC access token and expiry from the active gateway +login. This requires an active named gateway that was registered for OIDC. If +the stored gateway access token is expired and a refresh token is available, the +CLI refreshes it before storing the provider credential. It does not store the +OIDC refresh token in the provider. + Provider profile metadata is available for known provider types. Provider profile network policy is gateway opt-in: diff --git a/docs/sandboxes/providers-v2.mdx b/docs/sandboxes/providers-v2.mdx index 16c96515af..96ce68ab32 100644 --- a/docs/sandboxes/providers-v2.mdx +++ b/docs/sandboxes/providers-v2.mdx @@ -323,6 +323,10 @@ category: data inference_capable: false credentials: + - name: user_oidc_token + description: User OIDC token stored for gateway-side token exchange only + required: true + - name: api_token description: API access token env_vars: [CUSTOM_API_TOKEN] @@ -355,17 +359,23 @@ credentials: required: true secret: true - # Optional dynamic credential. The sandbox supervisor requests a - # SPIFFE JWT-SVID, exchanges it at token_endpoint, caches the returned - # access token, and injects it according to auth_style/header_name for - # matching endpoint traffic. + # Optional dynamic credential. The sandbox supervisor resolves this on + # demand for matching endpoint traffic, caches the returned access token, + # and injects it according to auth_style/header_name. token_grant: + # Accepted values: client_credentials, token_exchange. + grant_type: token_exchange token_endpoint: https://login.example.com/realms/custom/protocol/openid-connect/token audience: api://custom-api jwt_svid_audience: https://login.example.com/realms/custom - client_assertion_type: urn:ietf:params:oauth:client-assertion-type:jwt-bearer + client_assertion_type: urn:ietf:params:oauth:client-assertion-type:jwt-spiffe scopes: [api.read, api.write] cache_ttl_seconds: 300 + requested_token_type: urn:ietf:params:oauth:token-type:access_token + subject_token: + source: provider_credential + credential: user_oidc_token + subject_token_type: urn:ietf:params:oauth:token-type:access_token audience_overrides: - host: api.example.com port: 443 @@ -498,7 +508,14 @@ The refresh attaches to the primary credential (`access_key_id`). Each reference ### Dynamic Token Grants -`token_grant` belongs to one credential declaration. When a sandbox with the provider attached sends HTTP traffic to a matching profile endpoint, the supervisor requests a SPIFFE JWT-SVID from the local Workload API, exchanges it at `token_endpoint`, caches the returned access token, and injects it before forwarding the request upstream. Use `auth_style: bearer` to inject `Authorization: Bearer `, or `auth_style: header` with `header_name` to inject the raw access token into a custom header. Token grants do not support `query` or `path` placement. +`token_grant` belongs to one credential declaration. When a sandbox with the provider attached sends HTTP traffic to a matching profile endpoint, the supervisor resolves the dynamic credential, caches the returned access token, and injects it before forwarding the request upstream. Use `auth_style: bearer` to inject `Authorization: Bearer `, or `auth_style: header` with `header_name` to inject the raw access token into a custom header. Token grants do not support `query` or `path` placement. + +OpenShell supports two dynamic grant types: + +| Grant type | Behavior | +|---|---| +| `client_credentials` | The supervisor requests a SPIFFE JWT-SVID from the local Workload API and sends it directly to `token_endpoint` as the OAuth2 client assertion. This is the default when `grant_type` is omitted. | +| `token_exchange` | The supervisor first asks the gateway for an intermediate token. The request includes the supervisor JWT-SVID; the gateway verifies it, uses the SVID subject as the intermediate token audience, and exchanges the stored subject credential at the same `token_endpoint` using the gateway's own JWT-SVID as the client assertion. The supervisor then exchanges that intermediate token for the final upstream token using its own JWT-SVID as the client assertion. | Create provider instances for token-grant-only profiles with `--runtime-credentials`. This records an empty provider instance and makes the runtime-resolved credential source explicit: @@ -509,19 +526,39 @@ openshell provider create \ --runtime-credentials ``` +For `token_exchange` profiles, the provider also stores the user subject token referenced by `token_grant.subject_token.credential`. That credential is gateway-only: the sandbox does not receive it as environment material, static credential binding metadata, or workload placeholder ownership. Create or update that provider credential from the current gateway OIDC login with `--from-oidc-token`. This requires an active named gateway that was registered for OIDC. The CLI copies the current OIDC access token and its expiry into the provider. If the stored gateway access token is expired and a refresh token is available, the CLI refreshes it first. OpenShell does not store the OIDC refresh token in the provider. When the stored subject-token credential expires, the gateway rejects intermediate token exchange until the provider is updated with a fresh token. + +```shell +openshell provider create \ + --name custom-api \ + --type custom-api \ + --from-oidc-token + +openshell provider update custom-api \ + --from-oidc-token +``` + +OpenShell infers the destination credential when the provider profile has exactly one `token_grant.subject_token.credential`. If a profile declares more than one token-exchange subject credential, pass `--credential ` to choose one. + Token grant fields: | Field | Required | Behavior | |---|---|---| +| `grant_type` | No | `client_credentials` or `token_exchange`. Defaults to `client_credentials` for backward compatibility. | | `token_endpoint` | Yes | OAuth2 token endpoint that accepts a SPIFFE JWT-SVID client assertion. Use `https://` unless the endpoint is loopback or a Kubernetes service DNS name such as `token-issuer.default.svc.cluster.local`. | -| `audience` | No | Resource audience requested from the token service. | +| `audience` | No | Resource audience requested from the token service. For `token_exchange`, this is the final exchange audience; the gateway intermediate exchange always uses the verified supervisor SVID subject as its audience. | | `jwt_svid_audience` | No | Audience used when requesting the JWT-SVID. When omitted, OpenShell derives an issuer-style audience from Keycloak token endpoint paths or falls back to the full token endpoint URL. | -| `client_assertion_type` | No | OAuth2 `client_assertion_type` form value. Defaults to RFC 7523 `urn:ietf:params:oauth:client-assertion-type:jwt-bearer`. Set a provider-specific value, such as `urn:ietf:params:oauth:client-assertion-type:jwt-spiffe`, only when the token issuer explicitly requires it. | +| `client_assertion_type` | No | OAuth2 `client_assertion_type` form value. Defaults to RFC 7523 `urn:ietf:params:oauth:client-assertion-type:jwt-bearer`. Set `urn:ietf:params:oauth:client-assertion-type:jwt-spiffe` when the token issuer expects the SPIFFE assertion type. | | `scopes` | No | OAuth2 scopes sent as a space-separated `scope` parameter. | | `cache_ttl_seconds` | No | Token cache TTL override. When omitted or `0`, OpenShell uses the token response `expires_in` with a 30-second safety margin and one-hour cap, or five minutes minus the margin if the response does not include an expiry. | -| `audience_overrides` | No | Endpoint-specific `audience` and `scopes` overrides selected by host, port, and path. | +| `requested_token_type` | No | RFC 8693 `requested_token_type` sent during token exchange. Defaults to `urn:ietf:params:oauth:token-type:access_token`. | +| `subject_token` | Required for `token_exchange` | Subject-token source used for the gateway-brokered intermediate exchange. Phase one supports `source: provider_credential`, where `credential` names another credential declared in the same profile. That referenced credential is broker-only and cannot declare workload injection metadata such as env vars, static placement, refresh, or token grant metadata. | +| `subject_token.subject_token_type` | No | RFC 8693 `subject_token_type` for the stored subject token. Defaults to `urn:ietf:params:oauth:token-type:access_token`. | +| `audience_overrides` | No | Endpoint-specific final-exchange `audience` and `scopes` overrides selected by host, port, and path. These overrides do not affect the gateway intermediate exchange. | + +Token grants require the sandbox supervisor to have access to a SPIFFE Workload API socket. `token_exchange` also requires the gateway to have its own Workload API socket so it can present a gateway JWT-SVID during the intermediate exchange. They apply to HTTP traffic that the proxy can inspect. Endpoints with `tls: skip` bypass TLS termination and cannot receive dynamic token grant injection for HTTPS traffic. The token service must return a token value that is safe for HTTP header placement; malformed values are rejected before caching or header injection. -Token grants require the sandbox supervisor to have access to a SPIFFE Workload API socket. They apply to HTTP traffic that the proxy can inspect. Endpoints with `tls: skip` bypass TLS termination and cannot receive dynamic token grant injection for HTTPS traffic. The token service must return a token value that is safe for HTTP header placement; malformed values are rejected before caching or header injection. +The gateway only brokers an intermediate token for a sandbox principal, and only when the requested provider is attached to that sandbox. It verifies the supervisor JWT-SVID issuer, audience, signature, and SPIFFE trust domain against the gateway's own JWT-SVID, then uses the verified supervisor SVID subject as the intermediate-token audience. ## Provider Instances diff --git a/e2e/rust/Cargo.lock b/e2e/rust/Cargo.lock index 679e2c326d..bbccb7496a 100644 --- a/e2e/rust/Cargo.lock +++ b/e2e/rust/Cargo.lock @@ -8,12 +8,72 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "base64" version = "0.22.1" @@ -78,12 +138,28 @@ dependencies = [ "serde_repr", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -115,6 +191,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "digest" version = "0.10.7" @@ -127,20 +209,20 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "either" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "equivalent" @@ -155,7 +237,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -164,6 +246,18 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -245,6 +339,19 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -268,6 +375,25 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -282,9 +408,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -335,6 +461,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -360,6 +487,19 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -523,6 +663,32 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "libc" version = "0.2.189" @@ -566,12 +732,24 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "mio" version = "1.2.2" @@ -580,7 +758,7 @@ checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -595,6 +773,40 @@ dependencies = [ "libc", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -613,6 +825,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "jsonwebtoken", "nix", "prost", "rand", @@ -624,6 +837,10 @@ dependencies = [ "sha2", "tempfile", "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tower", "url", ] @@ -650,12 +867,42 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -671,6 +918,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -771,6 +1024,20 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -781,9 +1048,15 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "ryu" version = "1.0.23" @@ -924,6 +1197,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -934,6 +1213,18 @@ dependencies = [ "libc", ] +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "slab" version = "0.4.12" @@ -953,7 +1244,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -984,6 +1275,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.13.2" @@ -1005,7 +1302,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1028,6 +1325,36 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1052,18 +1379,29 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", ] [[package]] @@ -1080,6 +1418,71 @@ dependencies = [ "tokio", ] +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + [[package]] name = "tower-service" version = "0.3.3" @@ -1093,9 +1496,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1123,6 +1538,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -1171,6 +1592,51 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1199,6 +1665,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1208,6 +1683,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 0b8b56f3e5..42f989ce42 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -96,6 +96,11 @@ name = "vm_gateway_start" path = "tests/vm_gateway_start.rs" required-features = ["e2e-vm"] +[[test]] +name = "provider_token_exchange" +path = "tests/provider_token_exchange.rs" +required-features = ["e2e-podman"] + [[test]] name = "readyz_health" path = "tests/readyz_health.rs" @@ -179,8 +184,10 @@ futures-util = "0.3" http-body-util = "0.1" hyper = { version = "1", features = ["client", "http1"] } hyper-util = { version = "0.1", features = ["tokio"] } +jsonwebtoken = "9" prost = "0.14" tokio = { version = "1.43", features = ["full"] } +tokio-stream = { version = "0.1", features = ["net"] } tempfile = "3" sha1 = "0.10" sha2 = "0.10" @@ -189,6 +196,9 @@ rand = "0.9" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yml = "0.0.12" +tonic = { version = "0.14", features = ["transport"] } +tonic-prost = "0.14" +tower = "0.5" url = "2" nix = { version = "0.29", features = ["user"] } diff --git a/e2e/rust/e2e-podman.sh b/e2e/rust/e2e-podman.sh index 16796c0562..f5f1effd18 100755 --- a/e2e/rust/e2e-podman.sh +++ b/e2e/rust/e2e-podman.sh @@ -17,6 +17,13 @@ if [ "${E2E_TEST}" = "gpu" ] && [ -z "${OPENSHELL_E2E_WORKLOAD_MANIFEST:-}" ] && echo "note: running Podman GPU e2e without a workload manifest; workload validation will log an explicit skip. Build one with 'CONTAINER_ENGINE=podman mise run e2e:workloads:build' or set OPENSHELL_E2E_WORKLOAD_MANIFEST." fi +# An empty selector runs the full Podman suite, including provider_token_exchange. +if [ -z "${E2E_TEST}" ] || [ "${E2E_TEST}" = "provider_token_exchange" ]; then + export OPENSHELL_E2E_SPIFFE_FIXTURE="${OPENSHELL_E2E_SPIFFE_FIXTURE:-1}" +fi + +cargo build -p openshell-cli + TEST_ARGS=( cargo test --manifest-path "${ROOT}/e2e/rust/Cargo.toml" --features "${E2E_FEATURES}" diff --git a/e2e/rust/tests/provider_token_exchange.rs b/e2e/rust/tests/provider_token_exchange.rs new file mode 100644 index 0000000000..8747a4b279 --- /dev/null +++ b/e2e/rust/tests/provider_token_exchange.rs @@ -0,0 +1,907 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "e2e-podman")] + +use std::collections::HashMap; +use std::convert::Infallible; +use std::fs; +use std::io::Write as _; +use std::net::{Ipv4Addr, SocketAddr}; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; +use std::task::{Context, Poll}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use base64::Engine as _; +use futures_util::future::BoxFuture; +use jsonwebtoken::{Algorithm, EncodingKey, Header}; +use openshell_e2e::harness::binary::openshell_cmd; +use openshell_e2e::harness::port::find_free_port; +use openshell_e2e::harness::sandbox::SandboxGuard; +use serde_json::json; +use tempfile::NamedTempFile; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::{TcpListener, UnixListener}; +use tokio::process::Command; +use tokio_stream::wrappers::{ReceiverStream, TcpListenerStream, UnixListenerStream}; +use tonic::body::Body as TonicBody; +use tonic::codegen::{Body, http}; +use tonic::{Request, Response, Status}; + +const TRUST_DOMAIN: &str = "openshell-e2e.test"; +const ISSUER: &str = "https://spiffe.openshell-e2e.test"; +const KEY_ID: &str = "openshell-e2e-test-key"; +const USER_SUBJECT_TOKEN: &str = "stored-user-token"; +const INTERMEDIATE_TOKEN: &str = "intermediate-token"; +const FINAL_ACCESS_TOKEN: &str = "final-access-token"; +const TOKEN_TYPE_ACCESS_TOKEN: &str = "urn:ietf:params:oauth:token-type:access_token"; +const CLIENT_ASSERTION_TYPE: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe"; + +const TEST_RSA_PRIVATE_KEY: &str = r"-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCvCoZ0mVHpCHsF +zeeqw2caNIe/eb4BQUccFPhZfRnF7sCfyB84zTBmuwG2umRBdjFnVsfIIZRp2HcD +OESrRYYiE1RGfjBXImGVg2Wtza0HYhL1sLyX1eaEefylxoilmApAgWDh9p36h8J2 +s5YHwyXPTttx4DpdWDnxju1iNmwoIB8uVE/5amWgbNvlETMBOcB1RxDHtnVy+xJz +jjjrzK4Qz9WsUTHAvngdi4Yyxvci+yKpjYTg5+UWxmAN6iW522TpLe32MDb5Ug1d +trBvvepWmdQ6CBwPhBHCt/sMoSJAYSO4RKeBnBjeLQBXFTxaOv5iTGIsRTX3K471 +epHp3cT5AgMBAAECggEASQlRv/4nZN5SgsH/K8v7zb3kdHsmUly8AJYpaCGgauvr +uN/mUyueyga2uNl+MqhQBef6VWHZjO6y/gdw86v/Q2GgVQebQQhKAnpAp2w+Ceoc +siKMFqi8VkOWLU+xPbM6d97kH3TpRxt1g1T8wYFmWeF0BEiE4eUJzGaQW14M9BJ+ +G0QxmP/zjX9cNpVeApKTjBWKiH4CXG3DuI3pJ93VOMpUlOsrdLXvKGTze0e01itr +MX/MHHTE+VXB4FB+/zKSA4c36egi676OSXrGC/GDmM8ntJ4CUGeD5uZsMSADiAUn +iccv5iGRWVMIKxUS5Q4k0jy8uWuK+QVP4Y6cQWYArwKBgQDhuSNORBNpIGRfsKGN +iJo/h+qinz6pEIpa3D3oVl7rpkyvgIyaTwfXvC1vfdS9V5VIel2gV2Cx0OrI8yrr +nQu1JuNV/rLmtvqX321fgBLRdoiqF3pAy1gbmdUz1elerAIYL578gXQ6jg1bbdic +kJpn0MsoDUJGwvJnXcgLqG7q3wKBgQDGhRIa4oJsj1vqICc8zt8YsCAcot3vjWLH +588X7JdBGOWJdWxfdmGXQRn5Zw9UhMQnYa3uyTBPeVcXopThlPotYeuFhLSU856T +IJzfpzCJzC4zIQayoyvJFrKe7N70iUQ986dewYy9oxQhHvFKd/qe4ylbzZJXpthX +eWEuuBSjJwKBgGkqXt6qLPj/1IQYwUw15tfOtW0LEKCoSi3HCzjidNsJ4hSqqdeD +Fr5WuDyHvcRxt+XKzTBVRYHTOnBhiw+3XasK8UQxpJyFh/+WY1jpTNs2hLnqslTZ +6LUDWSgLc+1d6qPmHAa9Ma/OWz7L0O4xGR9hUiXY95YMYe/y668yzGq1AoGBAJyU +Gsqfu7U6gYmxoKEine6QBFPx1dD7GF2KJdq93jMXGvyHZFoLOkAdtgnz0rCcI0bY +kWKUxwj4MMxQjNM8OPMQl75xBCmz2XA8Od9htDQLmqjzNKAzePabc3lMZTJFDlE6 +29kuGf79IIRbLn/JECDAFT/2baW60Ep2T0OVJ5njAoGAfaCaQ4aVgjI027q7Y5qP +KfNSI8uuA8PLqmUY30I9KFWzN6VDLu00eKa90F4w3CeWRRQWXW1+007tTz3V1mNw +20A24Fi3HGQmXc7NyuLDODTJsWBICuOemCnRkvcxIlxb+ec7jp+XRmzDwKkzSnVN +pM2zFU8SeVkvHKlEuoHaP0s= +-----END PRIVATE KEY-----"; //notsecret + +const TEST_RSA_MODULUS_HEX: &str = concat!( + "af0a86749951e9087b05cde7aac3671a3487bf79be0141471c14f8597d19c5eec09fc81f38cd3066bb01b6ba", + "644176316756c7c8219469d877033844ab4586221354467e30572261958365adcdad076212f5b0bc97d5e684", + "79fca5c688a5980a408160e1f69dfa87c276b39607c325cf4edb71e03a5d5839f18eed62366c28201f2e54", + "4ff96a65a06cdbe511330139c0754710c7b67572fb12738e38ebccae10cfd5ac5131c0be781d8b8632c6", + "f722fb22a98d84e0e7e516c6600dea25b9db64e92dedf63036f9520d5db6b06fbdea5699d43a081c0f84", + "11c2b7fb0ca122406123b844a7819c18de2d0057153c5a3afe624c622c4535f72b8ef57a91e9ddc4f9" +); + +#[derive(Clone, PartialEq, prost::Message)] +struct JwtsvidRequest { + #[prost(string, repeated, tag = "1")] + audience: Vec, + #[prost(string, tag = "2")] + spiffe_id: String, +} + +#[derive(Clone, PartialEq, prost::Message)] +struct JwtsvidResponse { + #[prost(message, repeated, tag = "1")] + svids: Vec, +} + +#[derive(Clone, PartialEq, prost::Message)] +struct Jwtsvid { + #[prost(string, tag = "1")] + spiffe_id: String, + #[prost(string, tag = "2")] + svid: String, + #[prost(string, tag = "3")] + hint: String, +} + +#[derive(Clone, PartialEq, prost::Message)] +struct JwtBundlesRequest {} + +#[derive(Clone, PartialEq, prost::Message)] +struct JwtBundlesResponse { + #[prost(map = "string, bytes", tag = "1")] + bundles: HashMap>, +} + +#[derive(Clone)] +struct SpiffeWorkloadApi { + subject: Arc, + jwks: Arc>, + encoding_key: Arc, +} + +impl SpiffeWorkloadApi { + fn jwt_svid(&self, audience: Vec) -> Result { + let now = unix_timestamp(); + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(KEY_ID.to_string()); + let claims = json!({ + "iss": ISSUER, + "sub": self.subject.as_ref(), + "aud": audience, + "iat": now, + "exp": now + 3600, + }); + jsonwebtoken::encode(&header, &claims, &self.encoding_key) + .map_err(|err| Status::internal(format!("sign JWT-SVID: {err}"))) + } +} + +#[derive(Clone)] +struct SpiffeWorkloadApiServer { + inner: Arc, +} + +impl SpiffeWorkloadApiServer { + fn new(inner: SpiffeWorkloadApi) -> Self { + Self { + inner: Arc::new(inner), + } + } +} + +impl tower::Service> for SpiffeWorkloadApiServer +where + B: Body + Send + 'static, + B::Error: Into> + Send + 'static, +{ + type Response = http::Response; + type Error = Infallible; + type Future = BoxFuture<'static, Result>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, req: http::Request) -> Self::Future { + match req.uri().path() { + "/SpiffeWorkloadAPI/FetchJWTSVID" => { + #[derive(Clone)] + struct FetchJwtsvidSvc(Arc); + impl tonic::server::UnaryService for FetchJwtsvidSvc { + type Response = JwtsvidResponse; + type Future = BoxFuture<'static, Result, Status>>; + + fn call(&mut self, request: Request) -> Self::Future { + let inner = Arc::clone(&self.0); + Box::pin(async move { + let request = request.into_inner(); + let svid = inner.jwt_svid(request.audience)?; + Ok(Response::new(JwtsvidResponse { + svids: vec![Jwtsvid { + spiffe_id: inner.subject.to_string(), + svid, + hint: String::new(), + }], + })) + }) + } + } + + let inner = Arc::clone(&self.inner); + Box::pin(async move { + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec); + Ok(grpc.unary(FetchJwtsvidSvc(inner), req).await) + }) + } + "/SpiffeWorkloadAPI/FetchJWTBundles" => { + #[derive(Clone)] + struct FetchJwtBundlesSvc(Arc); + impl tonic::server::ServerStreamingService for FetchJwtBundlesSvc { + type Response = JwtBundlesResponse; + type ResponseStream = ReceiverStream>; + type Future = + BoxFuture<'static, Result, Status>>; + + fn call(&mut self, _request: Request) -> Self::Future { + let inner = Arc::clone(&self.0); + Box::pin(async move { + let mut bundles = HashMap::new(); + bundles.insert(TRUST_DOMAIN.to_string(), inner.jwks.as_ref().clone()); + let (tx, rx) = tokio::sync::mpsc::channel(1); + tx.send(Ok(JwtBundlesResponse { bundles })) + .await + .map_err(|err| Status::internal(format!("send bundle: {err}")))?; + Ok(Response::new(ReceiverStream::new(rx))) + }) + } + } + + let inner = Arc::clone(&self.inner); + Box::pin(async move { + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec); + Ok(grpc.server_streaming(FetchJwtBundlesSvc(inner), req).await) + }) + } + _ => Box::pin(async move { + let mut response = http::Response::new(TonicBody::empty()); + response.headers_mut().insert( + tonic::Status::GRPC_STATUS, + (tonic::Code::Unimplemented as i32).into(), + ); + response.headers_mut().insert( + http::header::CONTENT_TYPE, + tonic::metadata::GRPC_CONTENT_TYPE, + ); + Ok(response) + }), + } + } +} + +impl tonic::server::NamedService for SpiffeWorkloadApiServer { + const NAME: &'static str = "SpiffeWorkloadAPI"; +} + +struct FixtureHandle { + task: tokio::task::JoinHandle<()>, +} + +impl Drop for FixtureHandle { + fn drop(&mut self) { + self.task.abort(); + } +} + +fn unix_timestamp() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_secs() + .try_into() + .expect("timestamp should fit i64") +} + +fn jwks() -> Vec { + let modulus = hex::decode(TEST_RSA_MODULUS_HEX).expect("valid test RSA modulus hex"); + let n = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(modulus); + let e = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0x01, 0x00, 0x01]); + serde_json::to_vec(&json!({ + "keys": [{ + "kty": "RSA", + "kid": KEY_ID, + "use": "sig", + "alg": "RS256", + "n": n, + "e": e, + }] + })) + .expect("JWKS should serialize") +} + +async fn start_spiffe_workload_api(path: &Path, subject: &str) -> FixtureHandle { + let api = SpiffeWorkloadApi { + subject: Arc::::from(subject), + jwks: Arc::new(jwks()), + encoding_key: Arc::new( + EncodingKey::from_rsa_pem(TEST_RSA_PRIVATE_KEY.as_bytes()) + .expect("test RSA key should parse"), + ), + }; + let endpoint = path.to_string_lossy(); + if endpoint.starts_with("tcp:") { + let listen = std::env::var("OPENSHELL_E2E_PROVIDER_SPIFFE_LISTEN") + .expect("OPENSHELL_E2E_PROVIDER_SPIFFE_LISTEN must be set for TCP SPIFFE fixture"); + let listener = TcpListener::bind(&listen) + .await + .expect("bind TCP SPIFFE Workload API fixture"); + let incoming = TcpListenerStream::new(listener); + let task = tokio::spawn(async move { + let result = tonic::transport::Server::builder() + .add_service(SpiffeWorkloadApiServer::new(api)) + .serve_with_incoming(incoming) + .await; + if let Err(err) = result { + eprintln!("SPIFFE Workload API fixture failed: {err}"); + } + }); + return FixtureHandle { task }; + } + let _ = fs::remove_file(path); + let listener = UnixListener::bind(path).expect("bind SPIFFE Workload API socket"); + let mut permissions = fs::metadata(path) + .expect("stat SPIFFE Workload API socket") + .permissions(); + permissions.set_mode(0o777); + fs::set_permissions(path, permissions).expect("chmod SPIFFE Workload API socket"); + let incoming = UnixListenerStream::new(listener); + let task = tokio::spawn(async move { + let result = tonic::transport::Server::builder() + .add_service(SpiffeWorkloadApiServer::new(api)) + .serve_with_incoming(incoming) + .await; + if let Err(err) = result { + eprintln!("SPIFFE Workload API fixture failed: {err}"); + } + }); + FixtureHandle { task } +} + +async fn start_gateway_token_endpoint(port: u16) -> FixtureHandle { + let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port))) + .await + .expect("bind gateway token endpoint"); + let task = tokio::spawn(async move { + loop { + let Ok((mut stream, _peer)) = listener.accept().await else { + break; + }; + tokio::spawn(async move { + let mut buf = vec![0_u8; 8192]; + let n = stream.read(&mut buf).await.unwrap_or(0); + let request = String::from_utf8_lossy(&buf[..n]); + let access_token = if request.starts_with("POST /token ") + && request.contains("subject_token=stored-user-token") + && request.contains("client_assertion=") + { + Some(INTERMEDIATE_TOKEN) + } else if request.starts_with("POST /token ") + && request.contains("subject_token=intermediate-token") + && request.contains("client_assertion=") + { + Some(FINAL_ACCESS_TOKEN) + } else { + None + }; + let (status, body) = if let Some(access_token) = access_token { + ( + "HTTP/1.1 200 OK", + json!({ + "access_token": access_token, + "token_type": "Bearer", + "expires_in": 300 + }) + .to_string(), + ) + } else { + ( + "HTTP/1.1 400 Bad Request", + json!({"error": "unexpected_token_exchange"}).to_string(), + ) + }; + let response = format!( + "{status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + }); + } + }); + FixtureHandle { task } +} + +async fn start_protected_target(port: u16) -> FixtureHandle { + let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::UNSPECIFIED, port))) + .await + .expect("bind protected target"); + let task = tokio::spawn(async move { + loop { + let Ok((mut stream, _peer)) = listener.accept().await else { + break; + }; + tokio::spawn(async move { + let mut buf = vec![0_u8; 8192]; + let n = stream.read(&mut buf).await.unwrap_or(0); + let request = String::from_utf8_lossy(&buf[..n]); + let ok = request.lines().any(|line| { + line.eq_ignore_ascii_case(&format!( + "authorization: Bearer {FINAL_ACCESS_TOKEN}" + )) + }); + let (status, body) = if ok { + ("HTTP/1.1 200 OK", "token-exchange-ok") + } else { + ("HTTP/1.1 401 Unauthorized", "missing-final-token") + }; + let response = format!( + "{status}\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + }); + } + }); + FixtureHandle { task } +} + +async fn run_cli(args: &[&str]) -> Result { + let output = openshell_cmd() + .args(args) + .output() + .await + .map_err(|err| format!("spawn openshell: {err}"))?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format!("{stdout}{stderr}"); + if output.status.success() { + Ok(combined) + } else { + Err(format!( + "openshell {:?} failed with {:?}:\n{combined}", + args, + output.status.code() + )) + } +} + +async fn run_cli_ignore_error(args: &[&str]) { + let _ = openshell_cmd().args(args).output().await; +} + +async fn sandbox_logs(sandbox_name: &str) -> String { + run_cli(&["logs", sandbox_name]) + .await + .unwrap_or_else(|err| format!("failed to collect sandbox logs: {err}")) +} + +async fn podman_exec_capture(container_name: &str, args: &[&str]) -> String { + let Ok(socket) = std::env::var("OPENSHELL_PODMAN_SOCKET") else { + return "OPENSHELL_PODMAN_SOCKET is not set".to_string(); + }; + let mut cmd = Command::new("podman"); + cmd.arg("--url") + .arg(format!("unix://{socket}")) + .arg("exec") + .arg(container_name) + .args(args); + apply_podman_config_env(&mut cmd); + match cmd.output().await { + Ok(output) => format!( + "exit={:?}\nstdout:\n{}\nstderr:\n{}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ), + Err(err) => format!("failed to run podman exec {:?}: {err}", args), + } +} + +async fn podman_logs_capture(container_name: &str) -> String { + let Ok(socket) = std::env::var("OPENSHELL_PODMAN_SOCKET") else { + return "OPENSHELL_PODMAN_SOCKET is not set".to_string(); + }; + let mut cmd = Command::new("podman"); + cmd.arg("--url").arg(format!("unix://{socket}")).args([ + "logs", + "--tail", + "200", + container_name, + ]); + apply_podman_config_env(&mut cmd); + match cmd.output().await { + Ok(output) => format!( + "exit={:?}\nstdout:\n{}\nstderr:\n{}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ), + Err(err) => format!("failed to run podman logs: {err}"), + } +} + +async fn provider_token_debug(sandbox_name: &str, token_port: u16, target_port: u16) -> String { + let sandbox_logs = sandbox_logs(sandbox_name).await; + let Ok(socket) = std::env::var("OPENSHELL_PODMAN_SOCKET") else { + return format!("Sandbox logs:\n{sandbox_logs}\nOPENSHELL_PODMAN_SOCKET is not set"); + }; + let container_name = match podman_container_name_for_sandbox(&socket, sandbox_name).await { + Ok(name) => name, + Err(err) => return format!("Sandbox logs:\n{sandbox_logs}\n{err}"), + }; + let env = podman_exec_capture(&container_name, &["env"]).await; + let hosts = podman_exec_capture(&container_name, &["cat", "/etc/hosts"]).await; + let processes = podman_exec_capture(&container_name, &["ps", "-ef"]).await; + let resolve_host = podman_exec_capture( + &container_name, + &[ + "python3", + "-c", + "import socket; print(socket.getaddrinfo('host.openshell.internal', 0, type=socket.SOCK_STREAM))", + ], + ) + .await; + let token_probe = podman_exec_capture( + &container_name, + &[ + "python3", + "-c", + &format!( + "import socket; s=socket.create_connection(('127.0.0.1', {token_port}), 2); s.sendall(b'POST /token HTTP/1.1\\r\\nHost: 127.0.0.1\\r\\nContent-Length: 0\\r\\nConnection: close\\r\\n\\r\\n'); print(s.recv(4096).decode(errors='replace')); s.close()" + ), + ], + ) + .await; + let target_probe = podman_exec_capture( + &container_name, + &[ + "python3", + "-c", + &format!( + "import socket; s=socket.create_connection(('host.openshell.internal', {target_port}), 2); print('connected', s.getpeername()); s.close()" + ), + ], + ) + .await; + let container_logs = podman_logs_capture(&container_name).await; + + format!( + "Sandbox logs:\n{sandbox_logs}\n\ + Container: {container_name}\n\ + --- podman env ---\n{env}\n\ + --- /etc/hosts ---\n{hosts}\n\ + --- ps -ef ---\n{processes}\n\ + --- resolve host.openshell.internal ---\n{resolve_host}\n\ + --- token endpoint probe ---\n{token_probe}\n\ + --- protected target TCP probe ---\n{target_probe}\n\ + --- podman logs ---\n{container_logs}" + ) +} + +fn write_profile(profile_type: &str, token_port: u16, target_port: u16) -> NamedTempFile { + let token_endpoint = format!("http://127.0.0.1:{token_port}/token"); + let mut file = tempfile::Builder::new() + .suffix(".yaml") + .tempfile() + .expect("create provider profile temp file"); + let profile = format!( + r#"id: {profile_type} +display_name: Podman token exchange e2e +description: Podman e2e provider profile for two-stage token exchange +category: other +credentials: + - name: subject_token + description: Stored user subject token + required: true + - name: access_token + description: Access token obtained through token exchange + required: false + auth_style: bearer + header_name: Authorization + token_grant: + grant_type: token_exchange + token_endpoint: {token_endpoint} + audience: final-audience + jwt_svid_audience: {token_endpoint} + client_assertion_type: {CLIENT_ASSERTION_TYPE} + requested_token_type: {TOKEN_TYPE_ACCESS_TOKEN} + cache_ttl_seconds: 30 + subject_token: + source: provider_credential + credential: subject_token + subject_token_type: {TOKEN_TYPE_ACCESS_TOKEN} +endpoints: + - host: host.openshell.internal + port: {target_port} + protocol: rest + tls: none + access: read-write + enforcement: enforce + allowed_ips: + - 10.0.0.0/8 + - 169.254.0.0/16 + - 172.0.0.0/8 + - 192.168.0.0/16 +binaries: + - /usr/bin/curl + - /usr/local/bin/curl +"# + ); + file.write_all(profile.as_bytes()) + .expect("write provider profile"); + file.flush().expect("flush provider profile"); + file +} + +fn sandbox_script(token_port: u16) -> String { + let _ = token_port; + r#"set -eu +echo token-server-ready +while true; do sleep 60; done +"# + .to_string() +} + +fn container_token_endpoint_script() -> String { + format!( + r#" +import json +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import parse_qs + +PORT = int(sys.argv[1]) + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): + if self.path != "/token": + self.send_response(404) + self.end_headers() + return + length = int(self.headers.get("content-length", "0")) + params = parse_qs(self.rfile.read(length).decode()) + subject_token = params.get("subject_token", [""])[0] + client_assertion = params.get("client_assertion", [""])[0] + if subject_token == "{USER_SUBJECT_TOKEN}" and client_assertion: + access_token = "{INTERMEDIATE_TOKEN}" + elif subject_token == "{INTERMEDIATE_TOKEN}" and client_assertion: + access_token = "{FINAL_ACCESS_TOKEN}" + else: + self.send_response(400) + body = json.dumps({{"error": "unexpected_token_exchange"}}).encode() + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + body = json.dumps({{ + "access_token": access_token, + "token_type": "Bearer", + "expires_in": 300, + }}).encode() + self.send_response(200) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, fmt, *args): + return + +HTTPServer(("127.0.0.1", PORT), Handler).serve_forever() +"# + ) +} + +async fn start_container_token_endpoint(sandbox_name: &str, token_port: u16) -> Result<(), String> { + let socket = std::env::var("OPENSHELL_PODMAN_SOCKET") + .map_err(|_| "OPENSHELL_PODMAN_SOCKET must be set by e2e-podman.sh".to_string())?; + let container_name = podman_container_name_for_sandbox(&socket, sandbox_name).await?; + let mut cmd = Command::new("podman"); + cmd.arg("--url") + .arg(format!("unix://{socket}")) + .arg("exec") + .arg("-d") + .arg(&container_name) + .arg("python3") + .arg("-c") + .arg(container_token_endpoint_script()) + .arg(token_port.to_string()); + apply_podman_config_env(&mut cmd); + let output = cmd + .output() + .await + .map_err(|err| format!("spawn podman exec token endpoint: {err}"))?; + if !output.status.success() { + return Err(format!( + "podman exec token endpoint failed: {}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )); + } + + for _ in 0..20 { + if container_loopback_port_ready(&container_name, token_port).await { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + Err(format!( + "container token endpoint did not become ready on 127.0.0.1:{token_port}" + )) +} + +async fn podman_container_name_for_sandbox( + socket: &str, + sandbox_name: &str, +) -> Result { + let mut cmd = Command::new("podman"); + cmd.arg("--url") + .arg(format!("unix://{socket}")) + .arg("ps") + .arg("--filter") + .arg(format!("label=openshell.ai/sandbox-name={sandbox_name}")) + .arg("--format") + .arg("{{.Names}}"); + apply_podman_config_env(&mut cmd); + let output = cmd + .output() + .await + .map_err(|err| format!("spawn podman ps for sandbox container: {err}"))?; + if !output.status.success() { + return Err(format!( + "podman ps for sandbox container failed: {}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )); + } + let names = String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect::>(); + match names.as_slice() { + [name] => Ok(name.clone()), + [] => Err(format!( + "no running Podman container found for sandbox '{sandbox_name}'" + )), + _ => Err(format!( + "multiple running Podman containers found for sandbox '{sandbox_name}': {}", + names.join(", ") + )), + } +} + +fn apply_podman_config_env(cmd: &mut Command) { + if std::env::var_os("OPENSHELL_E2E_CONTAINER_ENGINE_UNSET_XDG_CONFIG_HOME").is_some() { + cmd.env_remove("XDG_CONFIG_HOME"); + } else if let Some(value) = std::env::var_os("OPENSHELL_E2E_CONTAINER_ENGINE_XDG_CONFIG_HOME") { + cmd.env("XDG_CONFIG_HOME", value); + } +} + +async fn container_loopback_port_ready(container_name: &str, token_port: u16) -> bool { + let Ok(socket) = std::env::var("OPENSHELL_PODMAN_SOCKET") else { + return false; + }; + let probe = format!( + "import socket; s=socket.create_connection(('127.0.0.1', {token_port}), 1); s.close()" + ); + let mut cmd = Command::new("podman"); + cmd.arg("--url") + .arg(format!("unix://{socket}")) + .arg("exec") + .arg(container_name) + .arg("python3") + .arg("-c") + .arg(probe); + apply_podman_config_env(&mut cmd); + cmd.stdout(Stdio::null()).stderr(Stdio::null()); + cmd.status() + .await + .map(|status| status.success()) + .unwrap_or(false) +} + +async fn sandbox_exec_curl(sandbox_name: &str, target_port: u16) -> Result { + let url = format!("http://host.openshell.internal:{target_port}/resource"); + let mut last_output = String::new(); + for _ in 0..20 { + let output = openshell_cmd() + .args([ + "sandbox", + "exec", + "--name", + sandbox_name, + "--no-tty", + "--", + "curl", + "-fsS", + "--max-time", + "5", + &url, + ]) + .output() + .await + .map_err(|err| format!("spawn openshell sandbox exec: {err}"))?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format!("{stdout}{stderr}"); + if output.status.success() { + return Ok(combined); + } + last_output = format!( + "exit={:?}\nstdout:\n{stdout}\nstderr:\n{stderr}", + output.status.code() + ); + tokio::time::sleep(Duration::from_millis(500)).await; + } + Err(format!( + "curl to protected target did not succeed at {url}; last attempt:\n{last_output}" + )) +} + +#[tokio::test] +async fn podman_provider_token_exchange_injects_bearer_header() { + let gateway_socket = PathBuf::from( + std::env::var("OPENSHELL_E2E_GATEWAY_SPIFFE_SOCKET") + .expect("OPENSHELL_E2E_GATEWAY_SPIFFE_SOCKET must be set by e2e-podman.sh"), + ); + let provider_socket = PathBuf::from( + std::env::var("OPENSHELL_E2E_PROVIDER_SPIFFE_SOCKET") + .expect("OPENSHELL_E2E_PROVIDER_SPIFFE_SOCKET must be set by e2e-podman.sh"), + ); + + let profile_type = format!("podman-token-exchange-e2e-{}", std::process::id()); + let provider_name = format!("podman-token-exchange-e2e-{}", std::process::id()); + let token_port = find_free_port(); + let target_port = find_free_port(); + let token_endpoint = format!("http://127.0.0.1:{token_port}/token"); + let gateway_subject = format!("spiffe://{TRUST_DOMAIN}/openshell/gateway"); + let supervisor_subject = format!("spiffe://{TRUST_DOMAIN}/openshell/sandbox/e2e"); + + let _gateway_spiffe = start_spiffe_workload_api(&gateway_socket, &gateway_subject).await; + let _provider_spiffe = start_spiffe_workload_api(&provider_socket, &supervisor_subject).await; + let _gateway_token = start_gateway_token_endpoint(token_port).await; + let _target = start_protected_target(target_port).await; + + run_cli(&[ + "settings", + "set", + "--global", + "--key", + "providers_v2_enabled", + "--value", + "true", + "--yes", + ]) + .await + .expect("enable providers v2"); + + run_cli_ignore_error(&["provider", "delete", &provider_name, "--yes"]).await; + run_cli_ignore_error(&["provider", "profile", "delete", &profile_type, "--yes"]).await; + + let profile = write_profile(&profile_type, token_port, target_port); + let profile_path = profile + .path() + .to_str() + .expect("profile path should be UTF-8"); + run_cli(&["provider", "profile", "import", "-f", profile_path]) + .await + .expect("import provider profile"); + run_cli(&[ + "provider", + "create", + "--name", + &provider_name, + "--type", + &profile_type, + "--credential", + &format!("subject_token={USER_SUBJECT_TOKEN}"), + ]) + .await + .expect("create provider"); + + let script = sandbox_script(token_port); + let mut sandbox = SandboxGuard::create_keep_with_args( + &["--provider", &provider_name], + &["sh", "-lc", &script], + "token-server-ready", + ) + .await + .unwrap_or_else(|err| { + panic!( + "sandbox should complete token exchange against {token_endpoint} and protected target port {target_port}:\n{err}" + ) + }); + start_container_token_endpoint(&sandbox.name, token_port) + .await + .expect("start container token endpoint"); + let curl_output = match sandbox_exec_curl(&sandbox.name, target_port).await { + Ok(output) => output, + Err(err) => { + let debug = provider_token_debug(&sandbox.name, token_port, target_port).await; + panic!("curl protected target from kept sandbox: {err}\n{debug}"); + } + }; + + run_cli_ignore_error(&["provider", "delete", &provider_name, "--yes"]).await; + run_cli_ignore_error(&["provider", "profile", "delete", &profile_type, "--yes"]).await; + sandbox.cleanup().await; + + assert!( + curl_output.contains("token-exchange-ok"), + "protected target should receive the final exchanged bearer token:\n{}", + curl_output + ); +} diff --git a/e2e/with-podman-gateway.sh b/e2e/with-podman-gateway.sh index bb22ee7376..dcb2d43fe0 100755 --- a/e2e/with-podman-gateway.sh +++ b/e2e/with-podman-gateway.sh @@ -86,6 +86,16 @@ podman_cmd() { WORKDIR_PARENT="${TMPDIR:-/tmp}" WORKDIR_PARENT="${WORKDIR_PARENT%/}" WORKDIR="$(mktemp -d "${WORKDIR_PARENT}/openshell-e2e-podman.XXXXXX")" +if [ "${OPENSHELL_E2E_SPIFFE_FIXTURE:-0}" = "1" ]; then + mkdir -p "${WORKDIR}/spiffe" + export OPENSHELL_E2E_GATEWAY_SPIFFE_SOCKET="${OPENSHELL_E2E_GATEWAY_SPIFFE_SOCKET:-${WORKDIR}/spiffe/gateway.sock}" + export OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET="${OPENSHELL_E2E_GATEWAY_SPIFFE_SOCKET}" + if [ -z "${OPENSHELL_E2E_PROVIDER_SPIFFE_SOCKET:-}" ]; then + OPENSHELL_E2E_PROVIDER_SPIFFE_PORT="$(e2e_pick_port)" + export OPENSHELL_E2E_PROVIDER_SPIFFE_LISTEN="0.0.0.0:${OPENSHELL_E2E_PROVIDER_SPIFFE_PORT}" + export OPENSHELL_E2E_PROVIDER_SPIFFE_SOCKET="tcp:169.254.1.2:${OPENSHELL_E2E_PROVIDER_SPIFFE_PORT}" + fi +fi GATEWAY_BIN="" CLI_BIN="" GATEWAY_PID="" @@ -473,6 +483,9 @@ cp "${ROOT}/deploy/rpm/gateway.toml.default" "${GATEWAY_CONFIG}" printf 'guest_tls_cert = %s\n' "$(toml_string "${PKI_DIR}/client/tls.crt")" printf 'guest_tls_key = %s\n' "$(toml_string "${PKI_DIR}/client/tls.key")" printf 'enable_bind_mounts = true\n' + if [ -n "${OPENSHELL_E2E_PROVIDER_SPIFFE_SOCKET:-}" ]; then + printf 'provider_spiffe_workload_api_socket = %s\n' "$(toml_string "${OPENSHELL_E2E_PROVIDER_SPIFFE_SOCKET}")" + fi # The in-process Podman driver reads `socket_path` from TOML only — the # OPENSHELL_PODMAN_SOCKET env var is honoured by the standalone driver # binary, not the in-process driver used here. Pin the socket to the one diff --git a/examples/spiffe-token-exchange-demo/README.md b/examples/spiffe-token-exchange-demo/README.md new file mode 100644 index 0000000000..bd0ca44a89 --- /dev/null +++ b/examples/spiffe-token-exchange-demo/README.md @@ -0,0 +1,232 @@ +# SPIFFE Token Exchange Demo + +This example validates provider dynamic token exchange using SPIFFE JWT-SVIDs. +It runs alongside `examples/spiffe-token-grant-demo` but exercises the +`token_exchange` grant type instead of `client_credentials`. + +The demo deploys three in-cluster workloads: + +| Workload | Purpose | +|---|---| +| `token-exchange-issuer` | Issues a demo user subject token, performs the gateway intermediate token exchange, and performs the supervisor final token exchange | +| `alpha-exchange` | Requires a final bearer token with audience and scope `alpha` | +| `beta-exchange` | Requires a final bearer token with audience and scope `beta` | + +The OpenShell provider profile in `provider-profile.yaml` declares a stored +`subject_token` credential and a runtime `access_token` credential with +`token_grant.grant_type: token_exchange`. The subject token is stored for +gateway-side exchange only and is not injected into the sandbox environment. + +The profile declares exact Kubernetes service hostnames for `alpha-exchange` +and `beta-exchange`. It intentionally does not set `allowed_ips`, because +cluster service CIDRs vary across Kubernetes installations. + +When a sandbox curls `alpha-exchange` or `beta-exchange`: + +1. The supervisor fetches its SPIFFE JWT-SVID. +2. The supervisor asks the gateway for an intermediate token. +3. The gateway verifies the supervisor SVID, fetches its own gateway JWT-SVID, + and exchanges the stored provider `subject_token` at `token-exchange-issuer`. + The requested intermediate audience is the supervisor SPIFFE ID. +4. The supervisor exchanges the intermediate token at the same token endpoint + for the final alpha/beta access token. +5. The supervisor injects that final token into the outbound HTTP request. + +## Prerequisites + +- A Kubernetes OpenShell dev cluster. +- SPIRE enabled for provider token grants and gateway token exchange. +- Gateway and supervisor access to SPIRE OIDC/JWKS discovery. +- OpenShell configured with the Kubernetes ServiceAccount supervisor bootstrap + path. +- `providers_v2_enabled=true` on the target gateway. +- Local `curl`, `python3`, `openssl`, `nc`, `kubectl`, and `openshell`. +- A registered and logged-in CLI gateway. The script uses `GATEWAY_NAME`, then + `OPENSHELL_GATEWAY`, then the active OpenShell gateway selection. + +For the Helm dev environment, deploy with the SPIRE releases and +`ci/values-spire.yaml` enabled in `deploy/helm/openshell/skaffold.yaml`. + +The demo assumes these SPIFFE ID prefixes: + +| Identity | Prefix | +|---|---| +| Gateway | `spiffe://openshell.local/ns/openshell/sa/` | +| Supervisor | `spiffe://openshell.local/openshell/sandbox/` | + +Override `GATEWAY_TRUST_DOMAIN_PREFIX` or `SUPERVISOR_TRUST_DOMAIN_PREFIX` in +`k8s/workloads.yaml` if your development cluster uses different SPIFFE IDs. + +The demo issuer fetches SPIRE JWKS from the in-cluster OIDC discovery service +to verify JWT-SVID signatures. The issuer pod runs a `spiffe-helper` sidecar +that writes the SPIFFE bundle into a shared volume. The Node issuer uses that +bundle as `SPIRE_JWKS_CA_FILE` when fetching JWKS over HTTPS. + +## Kubeconfig And Mise + +The repository `mise.toml` sets `KUBECONFIG` to the repo-local `kubeconfig` +when your shell activates the OpenShell directory. If you are testing against a +different cluster, run these commands from outside the repository and pass the +target kubeconfig explicitly. + +```bash +export OPENSHELL_REPO=/path/to/OpenShell +export DEMO_KUBECONFIG=/path/to/your/kubeconfig +export OPENSHELL_GATEWAY=local +``` + +## Deploy Workloads + +From a directory outside the repository: + +```bash +ACCESS_TOKEN_SECRET="$(openssl rand -hex 32)" +KUBECONFIG="$DEMO_KUBECONFIG" kubectl -n default create secret generic openshell-spiffe-token-exchange-demo \ + --from-literal=access-token-secret="$ACCESS_TOKEN_SECRET" \ + --dry-run=client \ + -o yaml | KUBECONFIG="$DEMO_KUBECONFIG" kubectl apply -f - +KUBECONFIG="$DEMO_KUBECONFIG" kubectl apply -k "$OPENSHELL_REPO/examples/spiffe-token-exchange-demo/k8s" +KUBECONFIG="$DEMO_KUBECONFIG" kubectl -n default rollout restart deployment/token-exchange-issuer deployment/alpha-exchange deployment/beta-exchange +KUBECONFIG="$DEMO_KUBECONFIG" kubectl -n default rollout status deployment/token-exchange-issuer --timeout=180s +KUBECONFIG="$DEMO_KUBECONFIG" kubectl -n default rollout status deployment/alpha-exchange --timeout=180s +KUBECONFIG="$DEMO_KUBECONFIG" kubectl -n default rollout status deployment/beta-exchange --timeout=180s +``` + +## Register Provider And Test + +Port-forward the local gateway in one terminal: + +```bash +KUBECONFIG="$DEMO_KUBECONFIG" kubectl port-forward -n openshell svc/openshell 8097:8080 +``` + +Copy the Helm-generated TLS client bundle into the CLI config used for this +demo. This uses the same gateway name as `OPENSHELL_GATEWAY`. + +```bash +mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/openshell/gateways/${OPENSHELL_GATEWAY}/mtls" +KUBECONFIG="$DEMO_KUBECONFIG" kubectl -n openshell get secret openshell-client-tls \ + -o jsonpath='{.data.ca\.crt}' | base64 -d > "${XDG_CONFIG_HOME:-$HOME/.config}/openshell/gateways/${OPENSHELL_GATEWAY}/mtls/ca.crt" +KUBECONFIG="$DEMO_KUBECONFIG" kubectl -n openshell get secret openshell-client-tls \ + -o jsonpath='{.data.tls\.crt}' | base64 -d > "${XDG_CONFIG_HOME:-$HOME/.config}/openshell/gateways/${OPENSHELL_GATEWAY}/mtls/tls.crt" +KUBECONFIG="$DEMO_KUBECONFIG" kubectl -n openshell get secret openshell-client-tls \ + -o jsonpath='{.data.tls\.key}' | base64 -d > "${XDG_CONFIG_HOME:-$HOME/.config}/openshell/gateways/${OPENSHELL_GATEWAY}/mtls/tls.key" +``` + +Port-forward the token exchange issuer in another terminal and fetch a demo +subject token: + +```bash +KUBECONFIG="$DEMO_KUBECONFIG" kubectl port-forward -n default svc/token-exchange-issuer 18080:80 +SUBJECT_TOKEN="$( + curl -fsS http://127.0.0.1:18080/demo-subject-token | + python3 -c 'import json, sys; print(json.load(sys.stdin)["access_token"])' +)" +``` + +Then run: + +```bash +export GATEWAY=https://127.0.0.1:8097 + +openshell --gateway "$OPENSHELL_GATEWAY" --gateway-endpoint "$GATEWAY" settings set \ + --global --key providers_v2_enabled --value true --yes + +openshell --gateway "$OPENSHELL_GATEWAY" --gateway-endpoint "$GATEWAY" provider profile import \ + -f "$OPENSHELL_REPO/examples/spiffe-token-exchange-demo/provider-profile.yaml" + +openshell --gateway "$OPENSHELL_GATEWAY" --gateway-endpoint "$GATEWAY" provider create \ + --name spiffe-token-exchange-demo \ + --type spiffe-token-exchange-demo \ + --credential "subject_token=${SUBJECT_TOKEN}" + +openshell --gateway "$OPENSHELL_GATEWAY" --gateway-endpoint "$GATEWAY" sandbox create \ + --name spiffe-token-exchange-demo \ + --provider spiffe-token-exchange-demo \ + --keep \ + --no-tty \ + -- echo "sandbox ready" + +openshell --gateway "$OPENSHELL_GATEWAY" --gateway-endpoint "$GATEWAY" sandbox exec \ + --name spiffe-token-exchange-demo \ + --no-tty \ + -- curl -sS http://alpha-exchange.default.svc.cluster.local/ + +openshell --gateway "$OPENSHELL_GATEWAY" --gateway-endpoint "$GATEWAY" sandbox exec \ + --name spiffe-token-exchange-demo \ + --no-tty \ + -- curl -sS http://beta-exchange.default.svc.cluster.local/ +``` + +Expected output includes the demo user as the token subject and the sandbox +SPIFFE ID as the authorized party/client: + +```text +alpha called with path /: + sub: demo-user + aud: alpha, account + scope: alpha profile email + azp: spiffe://openshell.local/openshell/sandbox/ + client_id: spiffe://openshell.local/openshell/sandbox/ + +beta called with path /: + sub: demo-user + aud: beta, account + scope: beta profile email + azp: spiffe://openshell.local/openshell/sandbox/ + client_id: spiffe://openshell.local/openshell/sandbox/ +``` + +The token issuer logs both token exchange phases: + +```bash +KUBECONFIG="$DEMO_KUBECONFIG" kubectl -n default logs deployment/token-exchange-issuer --tail=40 +``` + +Example log lines: + +```text +issued intermediate token for user=demo-user audience=spiffe://openshell.local/openshell/sandbox/ +issued final token for user=demo-user audience=alpha client=spiffe://openshell.local/openshell/sandbox/ +issued final token for user=demo-user audience=beta client=spiffe://openshell.local/openshell/sandbox/ +``` + +## Automated Demo + +`demo.sh` applies the workloads, fetches a demo subject token, registers the +provider profile, creates a sandbox, curls alpha/beta, and deletes the sandbox +with `openshell` on exit. It leaves the Kubernetes demo workloads in place and +prints diagnostics only when the run fails. + +```bash +cd /tmp +KUBECONFIG="$DEMO_KUBECONFIG" bash "$OPENSHELL_REPO/examples/spiffe-token-exchange-demo/demo.sh" +``` + +The script reuses your normal OpenShell CLI config so it can load the stored +OIDC token for `OPENSHELL_GATEWAY`. If you set `ISOLATED_CONFIG=1`, register +and log in to the gateway in that isolated config before running the demo. + +## Podman Demo + +A local Podman variant lives in `podman/`. It reuses the same dummy token +issuer and protected service code, starts local SPIRE and demo service +containers, manually registers one sandbox SPIFFE entry, and runs the same +alpha/beta token exchange checks. + +See `podman/README.md` for the required gateway configuration and script +usage. + +## Cleanup + +Delete the sandbox through OpenShell: + +```bash +openshell --gateway "$OPENSHELL_GATEWAY" --gateway-endpoint "$GATEWAY" sandbox delete spiffe-token-exchange-demo +``` + +Delete the demo workloads with Kubernetes: + +```bash +KUBECONFIG="$DEMO_KUBECONFIG" kubectl delete -k "$OPENSHELL_REPO/examples/spiffe-token-exchange-demo/k8s" +``` diff --git a/examples/spiffe-token-exchange-demo/demo.sh b/examples/spiffe-token-exchange-demo/demo.sh new file mode 100755 index 0000000000..e50f989454 --- /dev/null +++ b/examples/spiffe-token-exchange-demo/demo.sh @@ -0,0 +1,229 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROFILE_FILE="${SCRIPT_DIR}/provider-profile.yaml" +K8S_DIR="${SCRIPT_DIR}/k8s" + +SANDBOX_NAME="${SANDBOX_NAME:-spiffe-token-demo}" +PROVIDER_NAME="${PROVIDER_NAME:-spiffe-token-exchange-demo}" +PROFILE_ID="${PROFILE_ID:-spiffe-token-exchange-demo}" +PORT_FORWARD_PORT="${PORT_FORWARD_PORT:-8097}" +TOKEN_ISSUER_PORT="${TOKEN_ISSUER_PORT:-18080}" +GATEWAY_ENDPOINT="${GATEWAY_ENDPOINT:-https://127.0.0.1:${PORT_FORWARD_PORT}}" +KEEP_SANDBOX="${KEEP_SANDBOX:-0}" +ISOLATED_CONFIG="${ISOLATED_CONFIG:-0}" +ACCESS_TOKEN_SECRET="${ACCESS_TOKEN_SECRET:-$(openssl rand -hex 32)}" + +TEMP_CONFIG_HOME="" +if [[ "$ISOLATED_CONFIG" == "1" ]]; then + TEMP_CONFIG_HOME="$(mktemp -d)" + export XDG_CONFIG_HOME="$TEMP_CONFIG_HOME" +fi + +default_gateway_name() { + if [[ -n "${GATEWAY_NAME:-}" ]]; then + printf "%s\n" "$GATEWAY_NAME" + return + fi + if [[ -n "${OPENSHELL_GATEWAY:-}" ]]; then + printf "%s\n" "$OPENSHELL_GATEWAY" + return + fi + + local config_home="${XDG_CONFIG_HOME:-$HOME/.config}" + if [[ -s "${config_home}/openshell/active_gateway" ]]; then + head -n1 "${config_home}/openshell/active_gateway" + return + fi + if [[ -s /etc/openshell/active_gateway ]]; then + head -n1 /etc/openshell/active_gateway + return + fi + + printf "k8s\n" +} + +GATEWAY_NAME="$(default_gateway_name)" + +PF_PID="" +TOKEN_PF_PID="" + +dump_diagnostics() { + set +e + + printf "\n=== diagnostics: openshell sandbox logs ===\n" >&2 + "${OS[@]}" logs "$SANDBOX_NAME" -n 120 --source sandbox >&2 + + printf "\n=== diagnostics: gateway logs ===\n" >&2 + kubectl -n openshell logs -l app.kubernetes.io/name=openshell,app.kubernetes.io/instance=openshell \ + --tail=120 --prefix=true >&2 + + printf "\n=== diagnostics: token exchange issuer logs ===\n" >&2 + kubectl -n default logs -l app=token-exchange-issuer --tail=120 --prefix=true >&2 + + printf "\n=== diagnostics: alpha logs ===\n" >&2 + kubectl -n default logs -l app=alpha-exchange --tail=60 --prefix=true >&2 + + printf "\n=== diagnostics: beta logs ===\n" >&2 + kubectl -n default logs -l app=beta-exchange --tail=60 --prefix=true >&2 + + printf "\n=== diagnostics: gateway port-forward log ===\n" >&2 + sed 's/^/gateway-port-forward> /' /tmp/openshell-spiffe-token-exchange-demo-gateway-port-forward.log >&2 + + printf "\n=== diagnostics: token issuer port-forward log ===\n" >&2 + sed 's/^/issuer-port-forward> /' /tmp/openshell-spiffe-token-exchange-demo-issuer-port-forward.log >&2 +} + +cleanup() { + if [[ "$KEEP_SANDBOX" != "1" ]]; then + openshell --gateway "$GATEWAY_NAME" --gateway-endpoint "$GATEWAY_ENDPOINT" sandbox delete "$SANDBOX_NAME" >/dev/null 2>&1 || true + fi + if [[ -n "$PF_PID" ]]; then + kill "$PF_PID" >/dev/null 2>&1 || true + fi + if [[ -n "$TOKEN_PF_PID" ]]; then + kill "$TOKEN_PF_PID" >/dev/null 2>&1 || true + fi + if [[ -n "$TEMP_CONFIG_HOME" ]]; then + rm -rf "$TEMP_CONFIG_HOME" + fi +} + +on_exit() { + local status="$1" + if [[ "$status" -ne 0 ]]; then + dump_diagnostics || true + fi + cleanup + exit "$status" +} +trap 'on_exit $?' EXIT + +run() { + printf "\n$ %s\n" "$*" + "$@" +} + +wait_for_port() { + local port="$1" + local label="$2" + for _ in $(seq 1 60); do + if nc -z 127.0.0.1 "$port" >/dev/null 2>&1; then + return 0 + fi + sleep 0.25 + done + printf "%s port-forward did not become ready\n" "$label" >&2 + exit 1 +} + +assert_contains() { + local haystack="$1" + local needle="$2" + if [[ "$haystack" != *"$needle"* ]]; then + printf "expected output to contain: %s\n" "$needle" >&2 + printf "actual output:\n%s\n" "$haystack" >&2 + exit 1 + fi +} + +install_gateway_tls_bundle() { + local config_home="${XDG_CONFIG_HOME:-$HOME/.config}" + local tls_dir="${config_home}/openshell/gateways/${GATEWAY_NAME}/mtls" + mkdir -p "$tls_dir" + kubectl -n openshell get secret openshell-client-tls \ + -o jsonpath='{.data.ca\.crt}' | base64 -d >"${tls_dir}/ca.crt" + kubectl -n openshell get secret openshell-client-tls \ + -o jsonpath='{.data.tls\.crt}' | base64 -d >"${tls_dir}/tls.crt" + kubectl -n openshell get secret openshell-client-tls \ + -o jsonpath='{.data.tls\.key}' | base64 -d >"${tls_dir}/tls.key" +} + +subject_token_from_json() { + python3 -c 'import json, sys; print(json.load(sys.stdin)["access_token"])' +} + +sandbox_curl_until() { + local label="$1" + local url="$2" + local expected="$3" + local output="" + + for attempt in $(seq 1 12); do + printf "\n$ openshell sandbox exec %s curl (attempt %s)\n" "$label" "$attempt" + if output=$("${OS[@]}" sandbox exec --name "$SANDBOX_NAME" --no-tty -- curl -sS --max-time 10 "$url" 2>&1); then + printf "%s\n" "$output" + if [[ "$output" == *"$expected"* ]]; then + SANDBOX_CURL_OUTPUT="$output" + return 0 + fi + else + printf "%s\n" "$output" + fi + sleep 2 + done + + printf "timed out waiting for %s to return expected output\n" "$label" >&2 + printf "last output:\n%s\n" "$output" >&2 + exit 1 +} + +OS=(openshell --gateway "$GATEWAY_NAME" --gateway-endpoint "$GATEWAY_ENDPOINT") + +printf "Using OpenShell gateway '%s' at %s\n" "$GATEWAY_NAME" "$GATEWAY_ENDPOINT" + +printf "\n$ kubectl -n default create secret generic openshell-spiffe-token-exchange-demo --from-literal=access-token-secret=*** --dry-run=client -o yaml | kubectl apply -f -\n" +kubectl -n default create secret generic openshell-spiffe-token-exchange-demo \ + --from-literal=access-token-secret="$ACCESS_TOKEN_SECRET" \ + --dry-run=client \ + -o yaml | kubectl apply -f - + +run kubectl apply -k "$K8S_DIR" +run kubectl -n default rollout restart deployment/token-exchange-issuer deployment/alpha-exchange deployment/beta-exchange +run kubectl -n default rollout status deployment/token-exchange-issuer --timeout=180s +run kubectl -n default rollout status deployment/alpha-exchange --timeout=180s +run kubectl -n default rollout status deployment/beta-exchange --timeout=180s + +kubectl -n openshell port-forward svc/openshell "${PORT_FORWARD_PORT}:8080" >/tmp/openshell-spiffe-token-exchange-demo-gateway-port-forward.log 2>&1 & +PF_PID=$! +wait_for_port "$PORT_FORWARD_PORT" "gateway" +install_gateway_tls_bundle + +kubectl -n default port-forward svc/token-exchange-issuer "${TOKEN_ISSUER_PORT}:80" >/tmp/openshell-spiffe-token-exchange-demo-issuer-port-forward.log 2>&1 & +TOKEN_PF_PID=$! +wait_for_port "$TOKEN_ISSUER_PORT" "token issuer" + +SUBJECT_TOKEN="$(curl -fsS "http://127.0.0.1:${TOKEN_ISSUER_PORT}/demo-subject-token" | subject_token_from_json)" + +"${OS[@]}" sandbox delete "$SANDBOX_NAME" >/dev/null 2>&1 || true +"${OS[@]}" provider delete "$PROVIDER_NAME" >/dev/null 2>&1 || true +"${OS[@]}" provider profile delete "$PROFILE_ID" >/dev/null 2>&1 || true + +run "${OS[@]}" settings set --global --key providers_v2_enabled --value true --yes +run "${OS[@]}" provider profile lint -f "$PROFILE_FILE" +run "${OS[@]}" provider profile import -f "$PROFILE_FILE" +run "${OS[@]}" provider create --name "$PROVIDER_NAME" --type "$PROFILE_ID" --credential "subject_token=${SUBJECT_TOKEN}" +run "${OS[@]}" sandbox create --name "$SANDBOX_NAME" --provider "$PROVIDER_NAME" --keep --no-tty -- echo "sandbox ready" + +sandbox_curl_until "alpha" "http://alpha-exchange.default.svc.cluster.local/" "alpha called with path /:" +ALPHA_OUTPUT="$SANDBOX_CURL_OUTPUT" +assert_contains "$ALPHA_OUTPUT" "alpha called with path /:" +assert_contains "$ALPHA_OUTPUT" "sub: demo-user" +assert_contains "$ALPHA_OUTPUT" "aud: alpha, account" +assert_contains "$ALPHA_OUTPUT" "scope: alpha profile email" +assert_contains "$ALPHA_OUTPUT" "azp: spiffe://openshell.local/openshell/sandbox/" + +sandbox_curl_until "beta" "http://beta-exchange.default.svc.cluster.local/" "beta called with path /:" +BETA_OUTPUT="$SANDBOX_CURL_OUTPUT" +assert_contains "$BETA_OUTPUT" "beta called with path /:" +assert_contains "$BETA_OUTPUT" "sub: demo-user" +assert_contains "$BETA_OUTPUT" "aud: beta, account" +assert_contains "$BETA_OUTPUT" "scope: beta profile email" +assert_contains "$BETA_OUTPUT" "azp: spiffe://openshell.local/openshell/sandbox/" + +printf "\nSPIFFE token exchange demo succeeded.\n" diff --git a/examples/spiffe-token-exchange-demo/k8s/kustomization.yaml b/examples/spiffe-token-exchange-demo/k8s/kustomization.yaml new file mode 100644 index 0000000000..d4cdcb80a0 --- /dev/null +++ b/examples/spiffe-token-exchange-demo/k8s/kustomization.yaml @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +generatorOptions: + disableNameSuffixHash: true + +configMapGenerator: + - name: openshell-spiffe-token-exchange-demo-scripts + files: + - token-issuer.js + - protected-service.js + - name: openshell-spiffe-token-exchange-demo-helper + files: + - spiffe-helper.conf + +resources: + - workloads.yaml diff --git a/examples/spiffe-token-exchange-demo/k8s/protected-service.js b/examples/spiffe-token-exchange-demo/k8s/protected-service.js new file mode 100644 index 0000000000..263601c705 --- /dev/null +++ b/examples/spiffe-token-exchange-demo/k8s/protected-service.js @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const http = require("http"); +const crypto = require("crypto"); + +const PORT = Number(process.env.PORT || 8080); +const SERVICE_NAME = process.env.SERVICE_NAME || "alpha"; +const EXPECTED_AUDIENCE = process.env.EXPECTED_AUDIENCE || SERVICE_NAME; +const EXPECTED_SCOPE = process.env.EXPECTED_SCOPE || SERVICE_NAME; +const ACCESS_TOKEN_ISSUER = + process.env.ACCESS_TOKEN_ISSUER || "http://token-exchange-issuer.default.svc.cluster.local"; +const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET; + +if (!ACCESS_TOKEN_SECRET) { + throw new Error("ACCESS_TOKEN_SECRET is required"); +} + +function b64urlDecode(value) { + const padded = `${value}${"=".repeat((4 - (value.length % 4)) % 4)}`; + return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64"); +} + +function b64urlEncode(value) { + return Buffer.from(value) + .toString("base64") + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); +} + +function parseJwt(jwt) { + const parts = jwt.split("."); + if (parts.length !== 3) { + throw new Error("JWT must contain three segments"); + } + return { + payload: JSON.parse(b64urlDecode(parts[1]).toString("utf8")), + signingInput: `${parts[0]}.${parts[1]}`, + signature: parts[2], + }; +} + +function verifyAccessToken(jwt) { + const parsed = parseJwt(jwt); + const expected = b64urlEncode( + crypto.createHmac("sha256", ACCESS_TOKEN_SECRET).update(parsed.signingInput).digest(), + ); + if ( + parsed.signature.length !== expected.length || + !crypto.timingSafeEqual(Buffer.from(parsed.signature), Buffer.from(expected)) + ) { + throw new Error("access token signature validation failed"); + } + + const now = Math.floor(Date.now() / 1000); + if (parsed.payload.exp && parsed.payload.exp <= now) { + throw new Error("access token expired"); + } + if (parsed.payload.iss !== ACCESS_TOKEN_ISSUER) { + throw new Error(`unexpected access token issuer ${parsed.payload.iss}`); + } + if (parsed.payload.demo_token_use !== "final") { + throw new Error("expected final token"); + } + const aud = Array.isArray(parsed.payload.aud) ? parsed.payload.aud : [parsed.payload.aud]; + if (!aud.includes(EXPECTED_AUDIENCE)) { + throw new Error(`access token audience did not include ${EXPECTED_AUDIENCE}`); + } + const scopes = String(parsed.payload.scope || "").split(/\s+/).filter(Boolean); + if (!scopes.includes(EXPECTED_SCOPE)) { + throw new Error(`access token scope did not include ${EXPECTED_SCOPE}`); + } + return parsed.payload; +} + +function text(res, status, body) { + res.writeHead(status, { + "content-type": "text/plain", + "content-length": Buffer.byteLength(body), + }); + res.end(body); +} + +http + .createServer((req, res) => { + try { + if (req.url === "/healthz") { + return text(res, 200, "ok\n"); + } + const auth = req.headers.authorization || ""; + const token = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length) : ""; + if (!token) { + console.warn(`${SERVICE_NAME} rejected request path=${req.url} reason=missing_bearer_token`); + return text(res, 401, `${SERVICE_NAME} missing bearer token\n`); + } + const claims = verifyAccessToken(token); + const aud = Array.isArray(claims.aud) ? claims.aud.join(", ") : claims.aud; + console.log( + `${SERVICE_NAME} accepted request path=${req.url} sub=${claims.sub} aud="${aud}" scope="${claims.scope}" client_id=${claims.client_id}`, + ); + return text( + res, + 200, + `${SERVICE_NAME} called with path ${req.url}:\n` + + ` sub: ${claims.sub}\n` + + ` aud: ${aud}\n` + + ` iss: ${claims.iss}\n` + + ` scope: ${claims.scope}\n` + + ` azp: ${claims.azp}\n` + + ` client_id: ${claims.client_id}\n`, + ); + } catch (error) { + console.warn(`${SERVICE_NAME} rejected request path=${req.url} reason="${error.message}"`); + return text(res, 403, `${SERVICE_NAME} rejected token: ${error.message}\n`); + } + }) + .listen(PORT, "0.0.0.0", () => { + console.log(`${SERVICE_NAME} listening on ${PORT}`); + }); diff --git a/examples/spiffe-token-exchange-demo/k8s/spiffe-helper.conf b/examples/spiffe-token-exchange-demo/k8s/spiffe-helper.conf new file mode 100644 index 0000000000..93987d00c9 --- /dev/null +++ b/examples/spiffe-token-exchange-demo/k8s/spiffe-helper.conf @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +agent_address = "/run/spire/sockets/spire-agent.sock" +cert_dir = "/target" +svid_file_name = "svid.pem" +svid_key_file_name = "key.pem" +svid_bundle_file_name = "bundle.pem" diff --git a/examples/spiffe-token-exchange-demo/k8s/token-issuer.js b/examples/spiffe-token-exchange-demo/k8s/token-issuer.js new file mode 100644 index 0000000000..1a5d0e7527 --- /dev/null +++ b/examples/spiffe-token-exchange-demo/k8s/token-issuer.js @@ -0,0 +1,342 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const http = require("http"); +const https = require("https"); +const crypto = require("crypto"); +const fs = require("fs"); + +const TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"; +const JWT_SPIFFE_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe"; + +const PORT = Number(process.env.PORT || 8080); +const JWKS_URI = + process.env.SPIRE_JWKS_URI || + "https://spire-spiffe-oidc-discovery-provider.spire.svc.cluster.local/keys"; +const SPIRE_ISSUER = + process.env.SPIRE_ISSUER || + "https://spire-spiffe-oidc-discovery-provider.spire.svc.cluster.local"; +const JWT_SVID_AUDIENCE = + process.env.JWT_SVID_AUDIENCE || "http://token-exchange-issuer.default.svc.cluster.local"; +const SUPERVISOR_TRUST_DOMAIN_PREFIX = + process.env.SUPERVISOR_TRUST_DOMAIN_PREFIX || "spiffe://openshell.local/openshell/sandbox/"; +const GATEWAY_TRUST_DOMAIN_PREFIX = + process.env.GATEWAY_TRUST_DOMAIN_PREFIX || "spiffe://openshell.local/ns/openshell/sa/"; +const ACCESS_TOKEN_ISSUER = + process.env.ACCESS_TOKEN_ISSUER || "http://token-exchange-issuer.default.svc.cluster.local"; +const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET; +const DEMO_USER_SUBJECT = process.env.DEMO_USER_SUBJECT || "demo-user"; +const SPIRE_JWKS_CA_FILE = process.env.SPIRE_JWKS_CA_FILE || ""; +const JWT_SVID_VERIFY_ALGORITHMS = { + ES256: { algorithm: "sha256", dsaEncoding: "ieee-p1363" }, + RS256: { algorithm: "RSA-SHA256" }, +}; + +if (!ACCESS_TOKEN_SECRET) { + throw new Error("ACCESS_TOKEN_SECRET is required"); +} + +let cachedJwks; +let cachedJwksAt = 0; + +function b64urlDecode(value) { + const padded = `${value}${"=".repeat((4 - (value.length % 4)) % 4)}`; + return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64"); +} + +function b64urlEncode(value) { + return Buffer.from(value) + .toString("base64") + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); +} + +function parseJwt(jwt) { + const parts = jwt.split("."); + if (parts.length !== 3) { + throw new Error("JWT must contain three segments"); + } + return { + header: JSON.parse(b64urlDecode(parts[0]).toString("utf8")), + payload: JSON.parse(b64urlDecode(parts[1]).toString("utf8")), + signingInput: `${parts[0]}.${parts[1]}`, + signature: b64urlDecode(parts[2]), + signatureB64: parts[2], + }; +} + +async function jwks() { + const now = Date.now(); + if (cachedJwks && now - cachedJwksAt < 60000) { + return cachedJwks; + } + cachedJwks = await fetchJson(JWKS_URI); + cachedJwksAt = now; + return cachedJwks; +} + +function fetchJson(url) { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const isHttps = parsed.protocol === "https:"; + const client = isHttps ? https : http; + const options = {}; + if (isHttps && SPIRE_JWKS_CA_FILE) { + options.ca = fs.readFileSync(SPIRE_JWKS_CA_FILE); + } + + const req = client.get(parsed, options, (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => { + const body = Buffer.concat(chunks).toString("utf8"); + if (res.statusCode < 200 || res.statusCode >= 300) { + reject(new Error(`JWKS fetch failed with HTTP ${res.statusCode}: ${body}`)); + return; + } + try { + resolve(JSON.parse(body)); + } catch (error) { + reject(error); + } + }); + }); + req.on("error", reject); + req.setTimeout(10000, () => req.destroy(new Error("JWKS fetch timed out"))); + }); +} + +function hasAudience(payload, expected) { + const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; + return aud.includes(expected); +} + +async function verifyJwtSvid(jwt, subjectPrefix) { + const parsed = parseJwt(jwt); + const verifyAlgorithm = JWT_SVID_VERIFY_ALGORITHMS[parsed.header.alg]; + if (!verifyAlgorithm) { + throw new Error(`unsupported JWT-SVID alg ${parsed.header.alg}`); + } + + const keys = await jwks(); + const jwk = keys.keys.find((key) => key.kid === parsed.header.kid); + if (!jwk) { + throw new Error(`no JWKS key for kid ${parsed.header.kid}`); + } + + const verifier = crypto.createVerify(verifyAlgorithm.algorithm); + verifier.update(parsed.signingInput); + verifier.end(); + const publicKey = crypto.createPublicKey({ key: jwk, format: "jwk" }); + const verifyOptions = { key: publicKey }; + if (verifyAlgorithm.dsaEncoding) { + verifyOptions.dsaEncoding = verifyAlgorithm.dsaEncoding; + } + if (!verifier.verify(verifyOptions, parsed.signature)) { + throw new Error("JWT-SVID signature validation failed"); + } + + const now = Math.floor(Date.now() / 1000); + if (parsed.payload.exp && parsed.payload.exp <= now) { + throw new Error("JWT-SVID expired"); + } + if (parsed.payload.nbf && parsed.payload.nbf > now + 30) { + throw new Error("JWT-SVID not active yet"); + } + if (parsed.payload.iss !== SPIRE_ISSUER) { + throw new Error(`unexpected JWT-SVID issuer ${parsed.payload.iss}`); + } + if (!hasAudience(parsed.payload, JWT_SVID_AUDIENCE)) { + throw new Error(`JWT-SVID audience did not include ${JWT_SVID_AUDIENCE}`); + } + if (!String(parsed.payload.sub || "").startsWith(subjectPrefix)) { + throw new Error(`JWT-SVID subject did not start with ${subjectPrefix}`); + } + return parsed.payload; +} + +function signAccessToken(payload) { + const header = b64urlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" })); + const body = b64urlEncode(JSON.stringify(payload)); + const signingInput = `${header}.${body}`; + const signature = crypto + .createHmac("sha256", ACCESS_TOKEN_SECRET) + .update(signingInput) + .digest(); + return `${signingInput}.${b64urlEncode(signature)}`; +} + +function verifyAccessToken(jwt, tokenUse) { + const parsed = parseJwt(jwt); + const expected = b64urlEncode( + crypto.createHmac("sha256", ACCESS_TOKEN_SECRET).update(parsed.signingInput).digest(), + ); + if ( + parsed.signatureB64.length !== expected.length || + !crypto.timingSafeEqual(Buffer.from(parsed.signatureB64), Buffer.from(expected)) + ) { + throw new Error("token signature validation failed"); + } + + const now = Math.floor(Date.now() / 1000); + if (parsed.payload.exp && parsed.payload.exp <= now) { + throw new Error("token expired"); + } + if (parsed.payload.iss !== ACCESS_TOKEN_ISSUER) { + throw new Error(`unexpected token issuer ${parsed.payload.iss}`); + } + if (parsed.payload.demo_token_use !== tokenUse) { + throw new Error(`expected ${tokenUse} token`); + } + return parsed.payload; +} + +function issueDemoSubjectToken() { + const now = Math.floor(Date.now() / 1000); + return signAccessToken({ + iss: ACCESS_TOKEN_ISSUER, + sub: DEMO_USER_SUBJECT, + aud: ["openshell-gateway", "account"], + scope: "openid profile email", + demo_token_use: "user_subject", + iat: now, + exp: now + 1800, + }); +} + +function json(res, status, body) { + const payload = JSON.stringify(body); + res.writeHead(status, { + "content-type": "application/json", + "content-length": Buffer.byteLength(payload), + }); + res.end(payload); +} + +async function bodyText(req) { + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + if (Buffer.concat(chunks).length > 1024 * 1024) { + throw new Error("request body too large"); + } + } + return Buffer.concat(chunks).toString("utf8"); +} + +async function handleTokenExchange(req, res) { + const params = new URLSearchParams(await bodyText(req)); + if (params.get("grant_type") !== TOKEN_EXCHANGE_GRANT_TYPE) { + return json(res, 400, { error: "unsupported_grant_type" }); + } + if (params.get("client_assertion_type") !== JWT_SPIFFE_ASSERTION_TYPE) { + return json(res, 400, { error: "unsupported_client_assertion_type" }); + } + + const jwtSvid = params.get("client_assertion"); + if (!jwtSvid) { + return json(res, 400, { error: "missing_client_assertion" }); + } + const subjectToken = params.get("subject_token"); + if (!subjectToken) { + return json(res, 400, { error: "missing_subject_token" }); + } + + const audience = params.get("audience") || ""; + const requestedScopes = (params.get("scope") || "").split(/\s+/).filter(Boolean); + const now = Math.floor(Date.now() / 1000); + + const userToken = (() => { + try { + return verifyAccessToken(subjectToken, "user_subject"); + } catch (_error) { + return null; + } + })(); + + if (userToken) { + const gatewaySvid = await verifyJwtSvid(jwtSvid, GATEWAY_TRUST_DOMAIN_PREFIX); + if (!audience.startsWith(SUPERVISOR_TRUST_DOMAIN_PREFIX)) { + return json(res, 400, { error: "unsupported_intermediate_audience", audience }); + } + const intermediateToken = signAccessToken({ + iss: ACCESS_TOKEN_ISSUER, + sub: userToken.sub, + aud: [audience], + scope: userToken.scope || "openid profile email", + azp: gatewaySvid.sub, + client_id: gatewaySvid.sub, + demo_token_use: "intermediate", + iat: now, + exp: now + 300, + }); + console.log(`issued intermediate token for user=${userToken.sub} audience=${audience}`); + return json(res, 200, { + access_token: intermediateToken, + token_type: "Bearer", + expires_in: 300, + }); + } + + const supervisorSvid = await verifyJwtSvid(jwtSvid, SUPERVISOR_TRUST_DOMAIN_PREFIX); + const intermediateToken = verifyAccessToken(subjectToken, "intermediate"); + if (!hasAudience(intermediateToken, supervisorSvid.sub)) { + return json(res, 403, { error: "intermediate_token_audience_mismatch" }); + } + if (!["alpha", "beta"].includes(audience)) { + return json(res, 400, { error: "unsupported_audience", audience }); + } + if (!requestedScopes.includes(audience)) { + return json(res, 403, { error: "missing_matching_scope" }); + } + + const accessToken = signAccessToken({ + iss: ACCESS_TOKEN_ISSUER, + sub: intermediateToken.sub, + aud: [audience, "account"], + scope: `${requestedScopes.join(" ")} profile email`, + azp: supervisorSvid.sub, + client_id: supervisorSvid.sub, + demo_token_use: "final", + iat: now, + exp: now + 300, + }); + + console.log( + `issued final token for user=${intermediateToken.sub} audience=${audience} client=${supervisorSvid.sub}`, + ); + return json(res, 200, { + access_token: accessToken, + token_type: "Bearer", + expires_in: 300, + scope: `${requestedScopes.join(" ")} profile email`, + }); +} + +http + .createServer(async (req, res) => { + try { + if (req.url === "/healthz") { + res.writeHead(200, { "content-type": "text/plain" }); + return res.end("ok\n"); + } + if (req.method === "GET" && req.url === "/demo-subject-token") { + return json(res, 200, { + access_token: issueDemoSubjectToken(), + token_type: "Bearer", + expires_in: 1800, + }); + } + if (req.method === "POST" && req.url === "/token") { + return await handleTokenExchange(req, res); + } + return json(res, 404, { error: "not_found" }); + } catch (error) { + console.error(error); + return json(res, 500, { error: "server_error", message: error.message }); + } + }) + .listen(PORT, "0.0.0.0", () => { + console.log(`token exchange issuer listening on ${PORT}`); + }); diff --git a/examples/spiffe-token-exchange-demo/k8s/workloads.yaml b/examples/spiffe-token-exchange-demo/k8s/workloads.yaml new file mode 100644 index 0000000000..2bb1639214 --- /dev/null +++ b/examples/spiffe-token-exchange-demo/k8s/workloads.yaml @@ -0,0 +1,230 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: token-exchange-issuer + namespace: default + labels: + app: token-exchange-issuer +spec: + replicas: 1 + selector: + matchLabels: + app: token-exchange-issuer + template: + metadata: + labels: + app: token-exchange-issuer + spec: + containers: + - name: token-exchange-issuer + image: node:22-alpine + imagePullPolicy: IfNotPresent + command: ["node", "/demo/token-issuer.js"] + ports: + - name: http + containerPort: 8080 + env: + - name: ACCESS_TOKEN_SECRET + valueFrom: + secretKeyRef: + name: openshell-spiffe-token-exchange-demo + key: access-token-secret + - name: ACCESS_TOKEN_ISSUER + value: http://token-exchange-issuer.default.svc.cluster.local + - name: SPIRE_JWKS_URI + value: https://spire-spiffe-oidc-discovery-provider.spire.svc.cluster.local/keys + - name: SPIRE_JWKS_CA_FILE + value: /etc/x509/spiffe-bundle/bundle.pem + - name: SPIRE_ISSUER + value: https://spire-spiffe-oidc-discovery-provider.spire.svc.cluster.local + - name: JWT_SVID_AUDIENCE + value: http://token-exchange-issuer.default.svc.cluster.local + - name: SUPERVISOR_TRUST_DOMAIN_PREFIX + value: spiffe://openshell.local/openshell/sandbox/ + - name: GATEWAY_TRUST_DOMAIN_PREFIX + value: spiffe://openshell.local/ns/openshell/sa/ + - name: DEMO_USER_SUBJECT + value: demo-user + readinessProbe: + httpGet: + path: /healthz + port: http + volumeMounts: + - name: scripts + mountPath: /demo + readOnly: true + - name: spiffe-bundle + mountPath: /etc/x509/spiffe-bundle + readOnly: true + - name: spiffe-helper + image: ghcr.io/spiffe/spiffe-helper:0.11.0 + imagePullPolicy: IfNotPresent + args: ["-config", "/etc/spiffe-helper/spiffe-helper.conf"] + volumeMounts: + - name: spiffe-socket + mountPath: /run/spire/sockets + readOnly: true + - name: spiffe-bundle + mountPath: /target + - name: helper-config + mountPath: /etc/spiffe-helper + readOnly: true + volumes: + - name: scripts + configMap: + name: openshell-spiffe-token-exchange-demo-scripts + - name: helper-config + configMap: + name: openshell-spiffe-token-exchange-demo-helper + - name: spiffe-socket + csi: + driver: csi.spiffe.io + readOnly: true + - name: spiffe-bundle + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: token-exchange-issuer + namespace: default +spec: + selector: + app: token-exchange-issuer + ports: + - name: http + port: 80 + targetPort: http +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: alpha-exchange + namespace: default + labels: + app: alpha-exchange +spec: + replicas: 1 + selector: + matchLabels: + app: alpha-exchange + template: + metadata: + labels: + app: alpha-exchange + spec: + containers: + - name: alpha-exchange + image: node:22-alpine + imagePullPolicy: IfNotPresent + command: ["node", "/demo/protected-service.js"] + ports: + - name: http + containerPort: 8080 + env: + - name: SERVICE_NAME + value: alpha + - name: EXPECTED_AUDIENCE + value: alpha + - name: EXPECTED_SCOPE + value: alpha + - name: ACCESS_TOKEN_SECRET + valueFrom: + secretKeyRef: + name: openshell-spiffe-token-exchange-demo + key: access-token-secret + - name: ACCESS_TOKEN_ISSUER + value: http://token-exchange-issuer.default.svc.cluster.local + readinessProbe: + httpGet: + path: /healthz + port: http + volumeMounts: + - name: scripts + mountPath: /demo + readOnly: true + volumes: + - name: scripts + configMap: + name: openshell-spiffe-token-exchange-demo-scripts +--- +apiVersion: v1 +kind: Service +metadata: + name: alpha-exchange + namespace: default +spec: + selector: + app: alpha-exchange + ports: + - name: http + port: 80 + targetPort: http +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: beta-exchange + namespace: default + labels: + app: beta-exchange +spec: + replicas: 1 + selector: + matchLabels: + app: beta-exchange + template: + metadata: + labels: + app: beta-exchange + spec: + containers: + - name: beta-exchange + image: node:22-alpine + imagePullPolicy: IfNotPresent + command: ["node", "/demo/protected-service.js"] + ports: + - name: http + containerPort: 8080 + env: + - name: SERVICE_NAME + value: beta + - name: EXPECTED_AUDIENCE + value: beta + - name: EXPECTED_SCOPE + value: beta + - name: ACCESS_TOKEN_SECRET + valueFrom: + secretKeyRef: + name: openshell-spiffe-token-exchange-demo + key: access-token-secret + - name: ACCESS_TOKEN_ISSUER + value: http://token-exchange-issuer.default.svc.cluster.local + readinessProbe: + httpGet: + path: /healthz + port: http + volumeMounts: + - name: scripts + mountPath: /demo + readOnly: true + volumes: + - name: scripts + configMap: + name: openshell-spiffe-token-exchange-demo-scripts +--- +apiVersion: v1 +kind: Service +metadata: + name: beta-exchange + namespace: default +spec: + selector: + app: beta-exchange + ports: + - name: http + port: 80 + targetPort: http diff --git a/examples/spiffe-token-exchange-demo/podman/README.md b/examples/spiffe-token-exchange-demo/podman/README.md new file mode 100644 index 0000000000..d8fdde4b4b --- /dev/null +++ b/examples/spiffe-token-exchange-demo/podman/README.md @@ -0,0 +1,316 @@ +# Podman SPIFFE Token Exchange Demo + +This variant runs the SPIFFE token exchange demo with local Podman containers +instead of Kubernetes workloads. + +The first version is intentionally single-sandbox. The script creates one +concrete SPIRE registration entry for the sandbox after OpenShell creates it. +It does not rely on SPIRE templating one entry into many per-sandbox SPIFFE IDs. + +## What Runs + +`demo.sh` starts these local Podman containers on the `openshell` network by +default: + +| Container | Purpose | +|---|---| +| `openshell-spiffe-demo-spire-server` | Local SPIRE server | +| `openshell-spiffe-demo-spire-agent` | Local SPIRE agent with a Workload API socket | +| `openshell-spiffe-demo-spire-oidc` | SPIRE OIDC discovery provider for JWKS | +| `openshell-spiffe-demo-token-issuer` | Dummy IdP/token exchange endpoint | +| `openshell-spiffe-demo-alpha` | Protected alpha service | +| `openshell-spiffe-demo-beta` | Protected beta service | + +The OpenShell gateway is not started by this script. Start it separately with +the Podman driver and SPIFFE provider token grant settings. + +For the most self-contained path, set `START_GATEWAY=1`. The script starts a +gateway container on the same Podman network as the token issuer and protected +services. That makes Podman's DNS alias +`token-exchange-issuer.default.svc.cluster.local` resolve inside the gateway +without host DNS changes. + +## Gateway Requirements + +Start the gateway with: + +```shell +export OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET=/path/to/demo/spire-agent.sock +``` + +Configure the Podman driver with the same socket path: + +```toml +[openshell.drivers.podman] +network_name = "openshell" +provider_spiffe_workload_api_socket = "/path/to/demo/spire-agent.sock" +``` + +When `demo.sh` starts SPIRE itself, it prints the exact socket path to use. +Restart the gateway with that path before the final alpha/beta calls if the +gateway was not already configured. + +The provider profile uses +`http://token-exchange-issuer.default.svc.cluster.local:8080/token` by default. +The token issuer container has that Podman network alias, so sandboxes on the +same Podman network can resolve it. The gateway also performs the intermediate +token exchange, so a host-running gateway must be able to resolve and reach the +same name. For local testing, add a hosts/DNS entry that maps +`token-exchange-issuer.default.svc.cluster.local` to the host address serving +the published `TOKEN_ISSUER_PORT`, or run the gateway in an environment attached +to the same Podman network. + +`START_GATEWAY=1` automates that same-network gateway setup. It mounts the host +Podman socket into the gateway container, writes a temporary gateway config with +`compute_drivers = ["podman"]`, and mounts the SPIRE Workload API socket at the +same absolute host path so the gateway can pass that path to sibling sandbox +containers. + +The script looks for a Podman API socket in the usual rootless and rootful +locations, plus `podman system connection list`. If none exists, it starts a +temporary rootless API service under the script's temporary directory and stops +it during cleanup. The self-started socket is mounted into demo containers with +Podman relabeling. Set `PODMAN_SOCKET=/path/to/podman.sock` or +`PODMAN_SOCKET=unix:///path/to/podman.sock` to use a specific existing socket. +The SPIRE agent and managed gateway containers run with +`--security-opt label=disable` because both must connect to the rootless Podman +API Unix socket. + +SPIRE runs as a non-root user in its upstream images. The script creates +throwaway state and socket directories under its temporary directory with broad +write permissions so rootless Podman UID mappings can create the SQLite +datastore and Workload API sockets. + +## Run + +From anywhere: + +```shell +export OPENSHELL_REPO=/path/to/OpenShell +export GATEWAY_NAME=local +export GATEWAY_ENDPOINT=http://127.0.0.1:8080 + +bash "$OPENSHELL_REPO/examples/spiffe-token-exchange-demo/podman/demo.sh" +``` + +Self-contained local path: + +```shell +START_GATEWAY=1 \ +bash "$OPENSHELL_REPO/examples/spiffe-token-exchange-demo/podman/demo.sh" +``` + +Common overrides: + +```shell +SANDBOX_NAME=spiffe-podman-demo +PODMAN_NETWORK=openshell +TOKEN_ISSUER_PORT=18080 +MANAGED_GATEWAY_PORT=18082 +MANAGED_GATEWAY_HEALTH_PORT=18083 +GATEWAY_IMAGE=ghcr.io/nvidia/openshell/gateway:latest +SANDBOX_IMAGE=ghcr.io/nvidia/openshell/sandbox:latest +SUPERVISOR_IMAGE=ghcr.io/nvidia/openshell/supervisor:latest +SANDBOX_IMAGE_PULL_POLICY=missing +PODMAN_STOP_TIMEOUT_SECS=3 +KEEP_DEMO=1 +KEEP_SANDBOX=1 +``` + +Keep `SANDBOX_NAME` at 19 characters or fewer for the Podman driver. The +default `spiffe-podman-demo` is 18 characters. + +`PODMAN_STOP_TIMEOUT_SECS` controls how long the managed gateway asks Podman to +wait for sandbox containers to stop before Podman force-kills them during +cleanup. The short demo default avoids waiting on long SIGTERM grace periods. + +When testing branch-local images with `START_GATEWAY=1`, override all three +OpenShell runtime images: + +```shell +START_GATEWAY=1 \ +GATEWAY_IMAGE=localhost/openshell/gateway:branch \ +SANDBOX_IMAGE=localhost/openshell/sandbox:branch \ +SUPERVISOR_IMAGE=localhost/openshell/supervisor:branch \ +SANDBOX_IMAGE_PULL_POLICY=never \ +bash "$OPENSHELL_REPO/examples/spiffe-token-exchange-demo/podman/demo.sh" +``` + +`SANDBOX_IMAGE` renders to `[openshell.drivers.podman].default_image`. +`SUPERVISOR_IMAGE` renders to `[openshell.drivers.podman].supervisor_image`. + +Use `START_SPIRE=0` only when you already have SPIRE running and can provide a +host path to a Workload API socket: + +```shell +START_SPIRE=0 \ +SPIRE_AGENT_SOCKET_HOST_PATH=/run/spire/agent.sock \ +bash "$OPENSHELL_REPO/examples/spiffe-token-exchange-demo/podman/demo.sh" +``` + +## SPIRE Startup Scripts + +The full demo uses these scripts internally, and you can also run them directly +when you want to manage SPIRE separately from the token exchange flow: + +```shell +SPIRE_STATE_DIR="$(mktemp -d)" \ +SPIRE_ENV_FILE=/tmp/openshell-spire-server.env \ +bash "$OPENSHELL_REPO/examples/spiffe-token-exchange-demo/podman/spire/start-server-oidc.sh" + +source /tmp/openshell-spire-server.env + +SPIRE_STATE_DIR="$(mktemp -d)" \ +SPIRE_ENV_FILE=/tmp/openshell-spire-agent.env \ +bash "$OPENSHELL_REPO/examples/spiffe-token-exchange-demo/podman/spire/start-agent.sh" + +source /tmp/openshell-spire-agent.env +printf "Workload API socket: %s\n" "$SPIRE_AGENT_SOCKET_HOST_PATH" +``` + +`start-server-oidc.sh` starts the SPIRE server and OIDC discovery provider. +`start-agent.sh` generates a join token from the server container and starts a +SPIRE agent with the Docker-compatible Podman workload attestor. Both scripts +honor the same image, container, network, trust-domain, and SPIRE parent ID +environment variables as `demo.sh`. + +## Gateway Startup Script + +After starting the SPIRE agent, start a Podman-backed OpenShell gateway with: + +```shell +SPIRE_AGENT_ENV_FILE=/tmp/openshell-spire-agent.env \ +GATEWAY_ENV_FILE=/tmp/openshell-spiffe-gateway.env \ +bash "$OPENSHELL_REPO/examples/spiffe-token-exchange-demo/podman/start-gateway.sh" +``` + +The gateway listens on `http://127.0.0.1:8888` by default, with health checks on +`http://127.0.0.1:8889`. Override those ports with `GATEWAY_PORT` and +`GATEWAY_HEALTH_PORT`. + +`start-gateway.sh` mounts the SPIRE agent Workload API socket into the gateway +container and writes a temporary gateway config with the Podman driver enabled. +Register the gateway SPIFFE entry as a separate step: + +```shell +GATEWAY_SELECTORS="docker:label:openshell.spiffe-demo:gateway" \ +bash "$OPENSHELL_REPO/examples/spiffe-token-exchange-demo/podman/spire/register-gateway.sh" +``` + +The script also honors `GATEWAY_IMAGE`, `SANDBOX_IMAGE`, `SUPERVISOR_IMAGE`, +`SANDBOX_IMAGE_PULL_POLICY`, `PODMAN_NETWORK`, `PODMAN_SOCKET`, and +`PODMAN_STOP_TIMEOUT_SECS`. + +To require OIDC login for user-facing gateway calls, set `GATEWAY_OIDC_ISSUER`. +The script then renders `[openshell.gateway.oidc]` and defaults +`allow_unauthenticated_users` to `false`: + +```shell +SPIRE_AGENT_ENV_FILE=/tmp/openshell-spire-agent.env \ +GATEWAY_OIDC_ISSUER=https://idp.example.com/realms/openshell \ +GATEWAY_OIDC_AUDIENCE=openshell-cli \ +GATEWAY_OIDC_CLIENT_ID=openshell-cli \ +GATEWAY_OIDC_LOGIN_SCOPES="openid profile email" \ +bash "$OPENSHELL_REPO/examples/spiffe-token-exchange-demo/podman/start-gateway.sh" +``` + +The script prints the matching `openshell gateway add ... --oidc-issuer ...` +command after the gateway is ready. You can also run it directly: + +```shell +openshell gateway add http://127.0.0.1:8888 \ + --name podman-spiffe-demo \ + --oidc-issuer https://idp.example.com/realms/openshell \ + --oidc-client-id openshell-cli \ + --oidc-audience openshell-cli \ + --oidc-scopes "openid profile email" +``` + +OIDC-related overrides: + +- `GATEWAY_OIDC_ISSUER` +- `GATEWAY_OIDC_AUDIENCE`, default `openshell-cli` +- `GATEWAY_OIDC_JWKS_TTL_SECS`, default `3600` +- `GATEWAY_OIDC_ROLES_CLAIM`, default `realm_access.roles` +- `GATEWAY_OIDC_ADMIN_ROLE`, default `openshell-admin` +- `GATEWAY_OIDC_USER_ROLE`, default `openshell-user` +- `GATEWAY_OIDC_SCOPES_CLAIM`, default empty +- `GATEWAY_OIDC_CLIENT_ID`, default `openshell-cli` +- `GATEWAY_OIDC_LOGIN_SCOPES`, default empty +- `GATEWAY_ALLOW_UNAUTHENTICATED_USERS`, default `false` when OIDC is enabled + and `true` otherwise + +## SPIRE Registration + +The helpers are: + +```shell +examples/spiffe-token-exchange-demo/podman/spire/register-gateway.sh +examples/spiffe-token-exchange-demo/podman/spire/register-sandbox.sh +``` + +Defaults: + +- SPIRE agent parent ID: + `spiffe://openshell.local/openshell/spire-agent/demo` +- Gateway SPIFFE ID: + `spiffe://openshell.local/openshell/gateway/demo` +- Gateway selectors: + `unix:uid:` plus `unix:path:` when + `openshell-server` is on `PATH` +- Managed gateway selectors: + `docker:label:openshell.spiffe-demo:gateway` +- Sandbox SPIFFE ID: + `spiffe://openshell.local/openshell/sandbox/` +- Sandbox selectors: + `docker:label:openshell.managed:true` and + `docker:label:openshell.ai/sandbox-id:` + +If your gateway binary is not named `openshell-server` or is not on `PATH`, set +`GATEWAY_WORKLOAD_PATH` or provide `GATEWAY_SELECTORS`: + +```shell +GATEWAY_WORKLOAD_PATH=/path/to/openshell-server \ + examples/spiffe-token-exchange-demo/podman/spire/register-gateway.sh +``` + +If your SPIRE setup cannot use Docker-style selectors against Podman, provide +explicit selectors: + +```shell +SANDBOX_SELECTORS="selector:a selector:b" \ + examples/spiffe-token-exchange-demo/podman/spire/register-sandbox.sh "$SANDBOX_ID" +``` + +## Expected Output + +The alpha and beta calls should include the demo user and the sandbox SPIFFE ID: + +```text +alpha called with path /: + sub: demo-user + aud: alpha, account + scope: alpha profile email + azp: spiffe://openshell.local/openshell/sandbox/ + client_id: spiffe://openshell.local/openshell/sandbox/ +``` + +## Cleanup + +The script deletes demo containers and the sandbox on exit unless you set +`KEEP_DEMO=1` or `KEEP_SANDBOX=1`. + +Manual cleanup: + +```shell +openshell --gateway "$GATEWAY_NAME" --gateway-endpoint "$GATEWAY_ENDPOINT" \ + sandbox delete spiffe-podman-demo + +podman rm -f \ + openshell-spiffe-demo-token-issuer \ + openshell-spiffe-demo-alpha \ + openshell-spiffe-demo-beta \ + openshell-spiffe-demo-spire-oidc \ + openshell-spiffe-demo-spire-agent \ + openshell-spiffe-demo-spire-server +``` diff --git a/examples/spiffe-token-exchange-demo/podman/demo.sh b/examples/spiffe-token-exchange-demo/podman/demo.sh new file mode 100755 index 0000000000..0c943c15f6 --- /dev/null +++ b/examples/spiffe-token-exchange-demo/podman/demo.sh @@ -0,0 +1,675 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEMO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +PROFILE_TEMPLATE="${SCRIPT_DIR}/provider-profile.yaml" +TOKEN_ISSUER_JS="${DEMO_ROOT}/k8s/token-issuer.js" +PROTECTED_SERVICE_JS="${DEMO_ROOT}/k8s/protected-service.js" + +SANDBOX_NAME="${SANDBOX_NAME:-spiffe-podman-demo}" +PROVIDER_NAME="${PROVIDER_NAME:-spiffe-token-exchange-demo-podman}" +PROFILE_ID="${PROFILE_ID:-spiffe-token-exchange-demo-podman}" +START_GATEWAY="${START_GATEWAY:-0}" +MANAGED_GATEWAY_PORT="${MANAGED_GATEWAY_PORT:-18082}" +MANAGED_GATEWAY_HEALTH_PORT="${MANAGED_GATEWAY_HEALTH_PORT:-18083}" +if [[ -z "${GATEWAY_ENDPOINT:-}" ]]; then + if [[ "$START_GATEWAY" == "1" ]]; then + GATEWAY_ENDPOINT="http://127.0.0.1:${MANAGED_GATEWAY_PORT}" + else + GATEWAY_ENDPOINT="http://127.0.0.1:8080" + fi +fi +PODMAN_NETWORK="${PODMAN_NETWORK:-openshell}" +TOKEN_ISSUER_PORT="${TOKEN_ISSUER_PORT:-18080}" +OIDC_PORT="${OIDC_PORT:-18081}" +TOKEN_ISSUER_SERVICE_HOST="${TOKEN_ISSUER_SERVICE_HOST:-token-exchange-issuer.default.svc.cluster.local}" +KEEP_SANDBOX="${KEEP_SANDBOX:-0}" +KEEP_DEMO="${KEEP_DEMO:-0}" +START_SPIRE="${START_SPIRE:-1}" +ACCESS_TOKEN_SECRET="${ACCESS_TOKEN_SECRET:-$(openssl rand -hex 32)}" +TRUST_DOMAIN="${TRUST_DOMAIN:-openshell.local}" +SPIRE_AGENT_PARENT_ID="${SPIRE_AGENT_PARENT_ID:-spiffe://${TRUST_DOMAIN}/openshell/spire-agent/demo}" +SPIRE_AGENT_SOCKET_HOST_PATH="${SPIRE_AGENT_SOCKET_HOST_PATH:-}" +SPIRE_SERVER_IMAGE="${SPIRE_SERVER_IMAGE:-ghcr.io/spiffe/spire-server:1.12.4}" +SPIRE_AGENT_IMAGE="${SPIRE_AGENT_IMAGE:-ghcr.io/spiffe/spire-agent:1.12.4}" +SPIRE_OIDC_IMAGE="${SPIRE_OIDC_IMAGE:-ghcr.io/spiffe/oidc-discovery-provider:1.12.4}" +NODE_IMAGE="${NODE_IMAGE:-node:22-alpine}" +SPIRE_SERVER_CONTAINER="${SPIRE_SERVER_CONTAINER:-openshell-spiffe-demo-spire-server}" +SPIRE_AGENT_CONTAINER="${SPIRE_AGENT_CONTAINER:-openshell-spiffe-demo-spire-agent}" +SPIRE_OIDC_CONTAINER="${SPIRE_OIDC_CONTAINER:-openshell-spiffe-demo-spire-oidc}" +GATEWAY_CONTAINER="${GATEWAY_CONTAINER:-openshell-spiffe-demo-gateway}" +GATEWAY_IMAGE="${GATEWAY_IMAGE:-ghcr.io/nvidia/openshell/gateway:latest}" +SANDBOX_IMAGE="${SANDBOX_IMAGE:-}" +SUPERVISOR_IMAGE="${SUPERVISOR_IMAGE:-}" +SANDBOX_IMAGE_PULL_POLICY="${SANDBOX_IMAGE_PULL_POLICY:-missing}" +PODMAN_STOP_TIMEOUT_SECS="${PODMAN_STOP_TIMEOUT_SECS:-3}" +TOKEN_ISSUER_CONTAINER="${TOKEN_ISSUER_CONTAINER:-openshell-spiffe-demo-token-issuer}" +ALPHA_CONTAINER="${ALPHA_CONTAINER:-openshell-spiffe-demo-alpha}" +BETA_CONTAINER="${BETA_CONTAINER:-openshell-spiffe-demo-beta}" + +TMP_DIR="$(mktemp -d)" +RENDERED_PROFILE="${TMP_DIR}/provider-profile.yaml" +MOUNT_DIR="${TMP_DIR}/mounts" +TOKEN_ISSUER_JS_MOUNT="${MOUNT_DIR}/token-issuer.js" +PROTECTED_SERVICE_JS_MOUNT="${MOUNT_DIR}/protected-service.js" +PODMAN_SERVICE_PID="" +PODMAN_SOCKET_DEMO_OWNED="0" + +default_gateway_name() { + if [[ -n "${GATEWAY_NAME:-}" ]]; then + printf "%s\n" "$GATEWAY_NAME" + return + fi + if [[ -n "${OPENSHELL_GATEWAY:-}" ]]; then + printf "%s\n" "$OPENSHELL_GATEWAY" + return + fi + if [[ "$START_GATEWAY" == "1" ]]; then + printf "podman-spiffe-demo\n" + return + fi + + local config_home="${XDG_CONFIG_HOME:-$HOME/.config}" + if [[ -s "${config_home}/openshell/active_gateway" ]]; then + head -n1 "${config_home}/openshell/active_gateway" + return + fi + if [[ -s /etc/openshell/active_gateway ]]; then + head -n1 /etc/openshell/active_gateway + return + fi + + printf "local\n" +} + +GATEWAY_NAME="$(default_gateway_name)" +OS=(openshell --gateway "$GATEWAY_NAME" --gateway-endpoint "$GATEWAY_ENDPOINT") + +run() { + printf "\n$ %s\n" "$*" + "$@" +} + +prepare_demo_mounts() { + mkdir -p "$MOUNT_DIR" + cp "$TOKEN_ISSUER_JS" "$TOKEN_ISSUER_JS_MOUNT" + cp "$PROTECTED_SERVICE_JS" "$PROTECTED_SERVICE_JS_MOUNT" + chmod 0644 \ + "$TOKEN_ISSUER_JS_MOUNT" \ + "$PROTECTED_SERVICE_JS_MOUNT" +} + +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + printf "missing required command: %s\n" "$cmd" >&2 + exit 1 + fi +} + +wait_for_port() { + local port="$1" + local label="$2" + for _ in $(seq 1 80); do + if nc -z 127.0.0.1 "$port" >/dev/null 2>&1; then + return 0 + fi + sleep 0.25 + done + printf "%s did not become reachable on 127.0.0.1:%s\n" "$label" "$port" >&2 + exit 1 +} + +subject_token_from_json() { + python3 -c 'import json, sys; print(json.load(sys.stdin)["access_token"])' +} + +assert_contains() { + local haystack="$1" + local needle="$2" + if [[ "$haystack" != *"$needle"* ]]; then + printf "expected output to contain: %s\n" "$needle" >&2 + printf "actual output:\n%s\n" "$haystack" >&2 + exit 1 + fi +} + +require_uint() { + local name="$1" + local value="$2" + if [[ ! "$value" =~ ^[0-9]+$ ]]; then + printf "%s must be a non-negative integer, got: %s\n" "$name" "$value" >&2 + exit 1 + fi +} + +toml_string_escape() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + printf "%s\n" "$value" +} + +normalize_podman_socket_path() { + local socket_path="$1" + if [[ "$socket_path" == unix://* ]]; then + socket_path="${socket_path#unix://}" + fi + printf "%s\n" "$socket_path" +} + +detect_podman_socket() { + if [[ -n "${PODMAN_SOCKET:-}" ]]; then + normalize_podman_socket_path "$PODMAN_SOCKET" + return + fi + if [[ -n "${XDG_RUNTIME_DIR:-}" && -S "${XDG_RUNTIME_DIR}/podman/podman.sock" ]]; then + printf "%s\n" "${XDG_RUNTIME_DIR}/podman/podman.sock" + return + fi + if [[ -S "/run/user/$(id -u)/podman/podman.sock" ]]; then + printf "%s\n" "/run/user/$(id -u)/podman/podman.sock" + return + fi + if [[ -S /run/podman/podman.sock ]]; then + printf "%s\n" /run/podman/podman.sock + return + fi + if [[ -S /var/run/docker.sock ]]; then + printf "%s\n" /var/run/docker.sock + return + fi + local connection_socket + connection_socket="$( + podman system connection list --format '{{.URI}}' 2>/dev/null | + sed -n 's|^unix://||p' | + while IFS= read -r candidate; do + if [[ -S "$candidate" ]]; then + printf "%s\n" "$candidate" + break + fi + done + )" + if [[ -n "$connection_socket" ]]; then + printf "%s\n" "$connection_socket" + return + fi + return 1 +} + +start_podman_service() { + local socket_path="$1" + + mkdir -p "$(dirname "$socket_path")" + printf "\n$ podman system service --time=0 unix://%s\n" "$socket_path" >&2 + podman system service --time=0 "unix://${socket_path}" & + PODMAN_SERVICE_PID="$!" + + for _ in $(seq 1 80); do + if [[ -S "$socket_path" ]]; then + chmod 0666 "$socket_path" || true + return + fi + if ! kill -0 "$PODMAN_SERVICE_PID" >/dev/null 2>&1; then + wait "$PODMAN_SERVICE_PID" || true + PODMAN_SERVICE_PID="" + break + fi + sleep 0.25 + done + + printf "Podman API service did not create %s; set PODMAN_SOCKET to a running API socket\n" "$socket_path" >&2 + exit 1 +} + +ensure_podman_socket() { + local detected_socket + if detected_socket="$(detect_podman_socket)"; then + PODMAN_SOCKET="$detected_socket" + PODMAN_SOCKET_DEMO_OWNED="0" + return + fi + + local socket_dir="${TMP_DIR}/podman" + local socket_path="${socket_dir}/podman.sock" + + printf "\nNo Podman API socket found; starting a temporary rootless Podman API service.\n" >&2 + start_podman_service "$socket_path" + PODMAN_SOCKET="$socket_path" + PODMAN_SOCKET_DEMO_OWNED="1" +} + +podman_socket_volume() { + local source="$1" + local target="$2" + if [[ "$PODMAN_SOCKET_DEMO_OWNED" == "1" ]]; then + printf "%s:%s:z\n" "$source" "$target" + else + printf "%s:%s\n" "$source" "$target" + fi +} + +cleanup_container() { + podman rm -f "$1" >/dev/null 2>&1 || true +} + +dump_diagnostics() { + set +e + + printf "\n=== diagnostics: openshell sandbox logs ===\n" >&2 + "${OS[@]}" logs "$SANDBOX_NAME" -n 120 --source sandbox >&2 + + printf "\n=== diagnostics: podman containers ===\n" >&2 + podman ps -a --filter "name=openshell-spiffe-demo" >&2 + + for container in \ + "$SPIRE_SERVER_CONTAINER" \ + "$SPIRE_AGENT_CONTAINER" \ + "$SPIRE_OIDC_CONTAINER" \ + "$GATEWAY_CONTAINER" \ + "$TOKEN_ISSUER_CONTAINER" \ + "$ALPHA_CONTAINER" \ + "$BETA_CONTAINER"; do + printf "\n=== diagnostics: %s logs ===\n" "$container" >&2 + podman logs "$container" >&2 + done + + printf "\n=== diagnostics: sandbox container labels ===\n" >&2 + podman ps -a \ + --filter "label=openshell.ai/sandbox-name=${SANDBOX_NAME}" \ + --format '{{.ID}} {{.Names}} {{.Labels}}' >&2 +} + +cleanup() { + if [[ "$KEEP_SANDBOX" != "1" ]]; then + "${OS[@]}" sandbox delete "$SANDBOX_NAME" >/dev/null 2>&1 || true + fi + + if [[ "$KEEP_DEMO" != "1" ]]; then + cleanup_container "$TOKEN_ISSUER_CONTAINER" + cleanup_container "$ALPHA_CONTAINER" + cleanup_container "$BETA_CONTAINER" + if [[ "$START_GATEWAY" == "1" ]]; then + cleanup_container "$GATEWAY_CONTAINER" + fi + if [[ "$START_SPIRE" == "1" ]]; then + cleanup_container "$SPIRE_OIDC_CONTAINER" + cleanup_container "$SPIRE_AGENT_CONTAINER" + cleanup_container "$SPIRE_SERVER_CONTAINER" + fi + if [[ -n "$PODMAN_SERVICE_PID" ]]; then + kill "$PODMAN_SERVICE_PID" >/dev/null 2>&1 || true + wait "$PODMAN_SERVICE_PID" >/dev/null 2>&1 || true + PODMAN_SERVICE_PID="" + fi + rm -rf "$TMP_DIR" + else + printf "\nKeeping demo resources. Temporary files: %s\n" "$TMP_DIR" >&2 + if [[ -n "$PODMAN_SERVICE_PID" ]]; then + printf "Temporary Podman API service PID: %s\n" "$PODMAN_SERVICE_PID" >&2 + fi + fi +} + +on_exit() { + local status="$1" + if [[ "$status" -ne 0 ]]; then + dump_diagnostics || true + fi + cleanup + exit "$status" +} +trap 'on_exit $?' EXIT + +ensure_network() { + if ! podman network exists "$PODMAN_NETWORK" >/dev/null 2>&1; then + run podman network create "$PODMAN_NETWORK" + fi +} + +start_spire() { + local podman_socket="$1" + local spire_state_dir="${TMP_DIR}/spire" + local server_env="${TMP_DIR}/spire-server.env" + local agent_env="${TMP_DIR}/spire-agent.env" + + PODMAN_NETWORK="$PODMAN_NETWORK" \ + TRUST_DOMAIN="$TRUST_DOMAIN" \ + SPIRE_AGENT_PARENT_ID="$SPIRE_AGENT_PARENT_ID" \ + SPIRE_SERVER_IMAGE="$SPIRE_SERVER_IMAGE" \ + SPIRE_OIDC_IMAGE="$SPIRE_OIDC_IMAGE" \ + SPIRE_SERVER_CONTAINER="$SPIRE_SERVER_CONTAINER" \ + SPIRE_OIDC_CONTAINER="$SPIRE_OIDC_CONTAINER" \ + SPIRE_STATE_DIR="$spire_state_dir" \ + SPIRE_ENV_FILE="$server_env" \ + OIDC_PORT="$OIDC_PORT" \ + bash "${SCRIPT_DIR}/spire/start-server-oidc.sh" + + # shellcheck disable=SC1090 + source "$server_env" + + PODMAN_NETWORK="$PODMAN_NETWORK" \ + TRUST_DOMAIN="$TRUST_DOMAIN" \ + SPIRE_AGENT_PARENT_ID="$SPIRE_AGENT_PARENT_ID" \ + SPIRE_AGENT_IMAGE="$SPIRE_AGENT_IMAGE" \ + SPIRE_SERVER_CONTAINER="$SPIRE_SERVER_CONTAINER" \ + SPIRE_AGENT_CONTAINER="$SPIRE_AGENT_CONTAINER" \ + SPIRE_STATE_DIR="$SPIRE_STATE_DIR" \ + SPIRE_ENV_FILE="$agent_env" \ + PODMAN_SOCKET="$podman_socket" \ + PODMAN_SOCKET_DEMO_OWNED="$PODMAN_SOCKET_DEMO_OWNED" \ + bash "${SCRIPT_DIR}/spire/start-agent.sh" + + # shellcheck disable=SC1090 + source "$agent_env" +} + +write_managed_gateway_config() { + local podman_socket_in_container="$1" + local gateway_dir="${TMP_DIR}/gateway" + local jwt_dir="${gateway_dir}/jwt" + local config_path="${gateway_dir}/gateway.toml" + local sandbox_image_line="" + local supervisor_image_line="" + + mkdir -p "$jwt_dir" + if [[ ! -s "${jwt_dir}/signing.pem" ]]; then + openssl genpkey -algorithm ed25519 -out "${jwt_dir}/signing.pem" >/dev/null 2>&1 + openssl pkey -in "${jwt_dir}/signing.pem" -pubout -out "${jwt_dir}/public.pem" >/dev/null 2>&1 + openssl rand -hex 8 >"${jwt_dir}/kid" + fi + if [[ -n "$SANDBOX_IMAGE" ]]; then + sandbox_image_line="default_image = \"$(toml_string_escape "$SANDBOX_IMAGE")\"" + fi + if [[ -n "$SUPERVISOR_IMAGE" ]]; then + supervisor_image_line="supervisor_image = \"$(toml_string_escape "$SUPERVISOR_IMAGE")\"" + fi + + cat >"$config_path" </dev/null 2>&1 || + curl -fsS "http://127.0.0.1:${MANAGED_GATEWAY_HEALTH_PORT}/healthz" >/dev/null 2>&1; then + return + fi + sleep 0.5 + done + + printf "managed OpenShell gateway did not become ready at %s\n" "$GATEWAY_ENDPOINT" >&2 + exit 1 +} + +start_demo_services() { + cleanup_container "$TOKEN_ISSUER_CONTAINER" + cleanup_container "$ALPHA_CONTAINER" + cleanup_container "$BETA_CONTAINER" + + run podman run -d \ + --name "$TOKEN_ISSUER_CONTAINER" \ + --network "$PODMAN_NETWORK" \ + --network-alias token-exchange-issuer \ + --network-alias "$TOKEN_ISSUER_SERVICE_HOST" \ + -p "127.0.0.1:${TOKEN_ISSUER_PORT}:8080" \ + -v "${TOKEN_ISSUER_JS_MOUNT}:/demo/token-issuer.js:ro,z" \ + -e "ACCESS_TOKEN_SECRET=${ACCESS_TOKEN_SECRET}" \ + -e "ACCESS_TOKEN_ISSUER=${TOKEN_ISSUER_BASE_URL}" \ + -e "SPIRE_JWKS_URI=http://spire-oidc:8080/keys" \ + -e "SPIRE_ISSUER=http://spire-oidc:8080" \ + -e "JWT_SVID_AUDIENCE=${TOKEN_ISSUER_BASE_URL}" \ + -e "SUPERVISOR_TRUST_DOMAIN_PREFIX=spiffe://${TRUST_DOMAIN}/openshell/sandbox/" \ + -e "GATEWAY_TRUST_DOMAIN_PREFIX=spiffe://${TRUST_DOMAIN}/openshell/gateway/" \ + -e "DEMO_USER_SUBJECT=demo-user" \ + "$NODE_IMAGE" \ + node /demo/token-issuer.js + + run podman run -d \ + --name "$ALPHA_CONTAINER" \ + --network "$PODMAN_NETWORK" \ + --network-alias alpha-exchange \ + --network-alias alpha-exchange.default.svc.cluster.local \ + -v "${PROTECTED_SERVICE_JS_MOUNT}:/demo/protected-service.js:ro,z" \ + -e SERVICE_NAME=alpha \ + -e EXPECTED_AUDIENCE=alpha \ + -e EXPECTED_SCOPE=alpha \ + -e "ACCESS_TOKEN_SECRET=${ACCESS_TOKEN_SECRET}" \ + -e "ACCESS_TOKEN_ISSUER=${TOKEN_ISSUER_BASE_URL}" \ + "$NODE_IMAGE" \ + node /demo/protected-service.js + + run podman run -d \ + --name "$BETA_CONTAINER" \ + --network "$PODMAN_NETWORK" \ + --network-alias beta-exchange \ + --network-alias beta-exchange.default.svc.cluster.local \ + -v "${PROTECTED_SERVICE_JS_MOUNT}:/demo/protected-service.js:ro,z" \ + -e SERVICE_NAME=beta \ + -e EXPECTED_AUDIENCE=beta \ + -e EXPECTED_SCOPE=beta \ + -e "ACCESS_TOKEN_SECRET=${ACCESS_TOKEN_SECRET}" \ + -e "ACCESS_TOKEN_ISSUER=${TOKEN_ISSUER_BASE_URL}" \ + "$NODE_IMAGE" \ + node /demo/protected-service.js + + wait_for_port "$TOKEN_ISSUER_PORT" "token issuer" +} + +render_provider_profile() { + sed "s|__TOKEN_ISSUER_BASE_URL__|${TOKEN_ISSUER_BASE_URL}|g" \ + "$PROFILE_TEMPLATE" >"$RENDERED_PROFILE" +} + +sandbox_id() { + local output + output="$("${OS[@]}" sandbox get "$SANDBOX_NAME")" + awk '/Id:/ && !found { print $2; found=1 }' <<<"$output" +} + +register_gateway_entry() { + if [[ "${REGISTER_GATEWAY_ENTRY:-1}" != "1" ]]; then + return + fi + if [[ "$START_GATEWAY" == "1" && -z "${GATEWAY_SELECTORS:-}" ]]; then + GATEWAY_SELECTORS="docker:label:openshell.spiffe-demo:gateway" + fi + TRUST_DOMAIN="$TRUST_DOMAIN" \ + GATEWAY_SELECTORS="${GATEWAY_SELECTORS:-}" \ + SPIRE_AGENT_PARENT_ID="$SPIRE_AGENT_PARENT_ID" \ + SPIRE_SERVER_CONTAINER="$SPIRE_SERVER_CONTAINER" \ + bash "${SCRIPT_DIR}/spire/register-gateway.sh" +} + +register_sandbox_entry() { + local id="$1" + TRUST_DOMAIN="$TRUST_DOMAIN" \ + SPIRE_AGENT_PARENT_ID="$SPIRE_AGENT_PARENT_ID" \ + SPIRE_SERVER_CONTAINER="$SPIRE_SERVER_CONTAINER" \ + bash "${SCRIPT_DIR}/spire/register-sandbox.sh" "$id" +} + +sandbox_curl_until() { + local label="$1" + local url="$2" + local expected="$3" + local output="" + + for attempt in $(seq 1 18); do + printf "\n$ openshell sandbox exec %s curl (attempt %s)\n" "$label" "$attempt" + if output=$("${OS[@]}" sandbox exec --name "$SANDBOX_NAME" --no-tty -- curl -sS --max-time 10 "$url" 2>&1); then + printf "%s\n" "$output" + if [[ "$output" == *"$expected"* ]]; then + SANDBOX_CURL_OUTPUT="$output" + return 0 + fi + else + printf "%s\n" "$output" + fi + sleep 2 + done + + printf "timed out waiting for %s to return expected output\n" "$label" >&2 + printf "last output:\n%s\n" "$output" >&2 + exit 1 +} + +require_cmd podman +require_cmd openssl +require_cmd openshell +require_cmd curl +require_cmd python3 +require_cmd nc +require_cmd awk +require_cmd sed + +require_uint PODMAN_STOP_TIMEOUT_SECS "$PODMAN_STOP_TIMEOUT_SECS" +prepare_demo_mounts +ensure_network + +ensure_podman_socket +if [[ "$START_SPIRE" == "1" ]]; then + start_spire "$PODMAN_SOCKET" +elif [[ -z "$SPIRE_AGENT_SOCKET_HOST_PATH" ]]; then + printf "START_SPIRE=0 requires SPIRE_AGENT_SOCKET_HOST_PATH\n" >&2 + exit 1 +fi + +if [[ "$START_GATEWAY" == "1" ]]; then + start_gateway "$PODMAN_SOCKET" +fi + +if [[ -z "${TOKEN_ISSUER_BASE_URL:-}" ]]; then + TOKEN_ISSUER_BASE_URL="http://${TOKEN_ISSUER_SERVICE_HOST}:8080" +fi + +printf "\nUsing OpenShell gateway '%s' at %s\n" "$GATEWAY_NAME" "$GATEWAY_ENDPOINT" +printf "Using Podman network '%s'\n" "$PODMAN_NETWORK" +printf "Using token issuer base URL '%s'\n" "$TOKEN_ISSUER_BASE_URL" +printf "SPIRE agent Workload API socket for gateway and Podman driver: %s\n" "$SPIRE_AGENT_SOCKET_HOST_PATH" +if [[ "$START_GATEWAY" == "1" ]]; then + printf "Managed gateway container: %s\n" "$GATEWAY_CONTAINER" + printf "Managed gateway image: %s\n" "$GATEWAY_IMAGE" + if [[ -n "$SANDBOX_IMAGE" ]]; then + printf "Managed sandbox image: %s\n" "$SANDBOX_IMAGE" + fi + if [[ -n "$SUPERVISOR_IMAGE" ]]; then + printf "Managed supervisor image: %s\n" "$SUPERVISOR_IMAGE" + fi + printf "Managed sandbox image pull policy: %s\n\n" "$SANDBOX_IMAGE_PULL_POLICY" + printf "Managed Podman stop timeout: %s seconds\n\n" "$PODMAN_STOP_TIMEOUT_SECS" +else + printf "\nThe gateway must already be running with:\n" + printf " OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET=%s\n" "$SPIRE_AGENT_SOCKET_HOST_PATH" + printf " [openshell.drivers.podman].provider_spiffe_workload_api_socket=%s\n" "$SPIRE_AGENT_SOCKET_HOST_PATH" + printf " [openshell.drivers.podman].network_name=%s\n\n" "$PODMAN_NETWORK" + printf "The gateway process must be able to resolve and reach %s.\n" "$TOKEN_ISSUER_SERVICE_HOST" + printf "For a host-running gateway, add local DNS/hosts routing to the published issuer port %s if needed.\n\n" "$TOKEN_ISSUER_PORT" +fi + +register_gateway_entry +start_demo_services +render_provider_profile + +SUBJECT_TOKEN="$(curl -fsS "http://127.0.0.1:${TOKEN_ISSUER_PORT}/demo-subject-token" | subject_token_from_json)" + +"${OS[@]}" sandbox delete "$SANDBOX_NAME" >/dev/null 2>&1 || true +"${OS[@]}" provider delete "$PROVIDER_NAME" >/dev/null 2>&1 || true +"${OS[@]}" provider profile delete "$PROFILE_ID" >/dev/null 2>&1 || true + +run "${OS[@]}" settings set --global --key providers_v2_enabled --value true --yes +run "${OS[@]}" provider profile lint -f "$RENDERED_PROFILE" +run "${OS[@]}" provider profile import -f "$RENDERED_PROFILE" +run "${OS[@]}" provider create --name "$PROVIDER_NAME" --type "$PROFILE_ID" --credential "subject_token=${SUBJECT_TOKEN}" +run "${OS[@]}" sandbox create --name "$SANDBOX_NAME" --provider "$PROVIDER_NAME" --keep --no-tty -- echo "sandbox ready" + +SANDBOX_ID="$(sandbox_id)" +if [[ -z "$SANDBOX_ID" ]]; then + printf "could not determine sandbox ID from openshell sandbox get\n" >&2 + exit 1 +fi +printf "\nSandbox ID: %s\n" "$SANDBOX_ID" + +register_sandbox_entry "$SANDBOX_ID" + +sandbox_curl_until "alpha" "http://alpha-exchange:8080/" "alpha called with path /:" +ALPHA_OUTPUT="$SANDBOX_CURL_OUTPUT" +assert_contains "$ALPHA_OUTPUT" "sub: demo-user" +assert_contains "$ALPHA_OUTPUT" "aud: alpha, account" +assert_contains "$ALPHA_OUTPUT" "scope: alpha profile email" +assert_contains "$ALPHA_OUTPUT" "azp: spiffe://${TRUST_DOMAIN}/openshell/sandbox/" +assert_contains "$ALPHA_OUTPUT" "client_id: spiffe://${TRUST_DOMAIN}/openshell/sandbox/" + +sandbox_curl_until "beta" "http://beta-exchange:8080/" "beta called with path /:" +BETA_OUTPUT="$SANDBOX_CURL_OUTPUT" +assert_contains "$BETA_OUTPUT" "sub: demo-user" +assert_contains "$BETA_OUTPUT" "aud: beta, account" +assert_contains "$BETA_OUTPUT" "scope: beta profile email" +assert_contains "$BETA_OUTPUT" "azp: spiffe://${TRUST_DOMAIN}/openshell/sandbox/" +assert_contains "$BETA_OUTPUT" "client_id: spiffe://${TRUST_DOMAIN}/openshell/sandbox/" + +printf "\nPodman SPIFFE token exchange demo succeeded.\n" diff --git a/examples/spiffe-token-exchange-demo/podman/provider-profile.yaml b/examples/spiffe-token-exchange-demo/podman/provider-profile.yaml new file mode 100644 index 0000000000..b7d1cf30e5 --- /dev/null +++ b/examples/spiffe-token-exchange-demo/podman/provider-profile.yaml @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Template rendered by podman/demo.sh. +# __TOKEN_ISSUER_BASE_URL__ must be reachable from both the gateway process and +# the sandbox container. For local HTTP, OpenShell profile validation permits +# loopback hosts and Kubernetes-style service DNS names. + +id: spiffe-token-exchange-demo-podman +display_name: SPIFFE token exchange demo (Podman) +description: Dynamic token exchange for local Podman alpha/beta demo services using stored user subject tokens and SPIFFE JWT-SVID authentication +category: other +credentials: + - name: subject_token + description: Demo user subject token stored for gateway-side token exchange only + required: true + - name: access_token + description: Access token obtained via RFC 8693 token exchange + required: false + auth_style: bearer + header_name: Authorization + token_grant: + grant_type: token_exchange + token_endpoint: __TOKEN_ISSUER_BASE_URL__/token + audience: demo-default + jwt_svid_audience: __TOKEN_ISSUER_BASE_URL__ + client_assertion_type: urn:ietf:params:oauth:client-assertion-type:jwt-spiffe + scopes: [demo] + cache_ttl_seconds: 60 + subject_token: + source: provider_credential + credential: subject_token + subject_token_type: urn:ietf:params:oauth:token-type:access_token + audience_overrides: + - host: alpha-exchange + port: 8080 + audience: alpha + scopes: [alpha] + - host: beta-exchange + port: 8080 + audience: beta + scopes: [beta] +endpoints: + - host: alpha-exchange + port: 8080 + protocol: rest + tls: none + access: read-write + enforcement: enforce + - host: beta-exchange + port: 8080 + protocol: rest + tls: none + access: read-write + enforcement: enforce +binaries: + - /usr/bin/curl + - /usr/local/bin/curl diff --git a/examples/spiffe-token-exchange-demo/podman/spire/agent.conf b/examples/spiffe-token-exchange-demo/podman/spire/agent.conf new file mode 100644 index 0000000000..0dbf8daba7 --- /dev/null +++ b/examples/spiffe-token-exchange-demo/podman/spire/agent.conf @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +agent { + data_dir = "/run/spire/agent/data" + log_level = "INFO" + server_address = "spire-server" + server_port = "8081" + socket_path = "/run/spire/agent/sockets/agent.sock" + trust_domain = "openshell.local" + insecure_bootstrap = true +} + +plugins { + NodeAttestor "join_token" { + plugin_data {} + } + + KeyManager "disk" { + plugin_data { + directory = "/run/spire/agent/data" + } + } + + WorkloadAttestor "unix" { + plugin_data {} + } + + # Podman exposes a Docker-compatible API. The demo mounts the Podman socket + # here so SPIRE can use Docker-style selectors for the sandbox container + # when the local SPIRE version supports them. + WorkloadAttestor "docker" { + plugin_data { + docker_socket_path = "unix:///run/podman/podman.sock" + } + } +} diff --git a/examples/spiffe-token-exchange-demo/podman/spire/common.sh b/examples/spiffe-token-exchange-demo/podman/spire/common.sh new file mode 100755 index 0000000000..24aae8dc93 --- /dev/null +++ b/examples/spiffe-token-exchange-demo/podman/spire/common.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +SPIRE_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +PODMAN_NETWORK="${PODMAN_NETWORK:-openshell}" +TRUST_DOMAIN="${TRUST_DOMAIN:-openshell.local}" +SPIRE_AGENT_PARENT_ID="${SPIRE_AGENT_PARENT_ID:-spiffe://${TRUST_DOMAIN}/openshell/spire-agent/demo}" +SPIRE_SERVER_IMAGE="${SPIRE_SERVER_IMAGE:-ghcr.io/spiffe/spire-server:1.12.4}" +SPIRE_AGENT_IMAGE="${SPIRE_AGENT_IMAGE:-ghcr.io/spiffe/spire-agent:1.12.4}" +SPIRE_OIDC_IMAGE="${SPIRE_OIDC_IMAGE:-ghcr.io/spiffe/oidc-discovery-provider:1.12.4}" +SPIRE_SERVER_CONTAINER="${SPIRE_SERVER_CONTAINER:-openshell-spiffe-demo-spire-server}" +SPIRE_AGENT_CONTAINER="${SPIRE_AGENT_CONTAINER:-openshell-spiffe-demo-spire-agent}" +SPIRE_OIDC_CONTAINER="${SPIRE_OIDC_CONTAINER:-openshell-spiffe-demo-spire-oidc}" +SPIRE_SERVER_BIN="${SPIRE_SERVER_BIN:-/opt/spire/bin/spire-server}" +OIDC_PORT="${OIDC_PORT:-18081}" +SPIRE_STATE_DIR="${SPIRE_STATE_DIR:-}" +SPIRE_ENV_FILE="${SPIRE_ENV_FILE:-}" +CLEANUP_EXISTING="${CLEANUP_EXISTING:-1}" +PODMAN_SOCKET_DEMO_OWNED="${PODMAN_SOCKET_DEMO_OWNED:-0}" + +run() { + printf "\n$ %s\n" "$*" >&2 + "$@" +} + +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + printf "missing required command: %s\n" "$cmd" >&2 + exit 1 + fi +} + +cleanup_container() { + podman rm -f "$1" >/dev/null 2>&1 || true +} + +ensure_network() { + if ! podman network exists "$PODMAN_NETWORK" >/dev/null 2>&1; then + run podman network create "$PODMAN_NETWORK" + fi +} + +normalize_podman_socket_path() { + local socket_path="$1" + if [[ "$socket_path" == unix://* ]]; then + socket_path="${socket_path#unix://}" + fi + printf "%s\n" "$socket_path" +} + +detect_podman_socket() { + if [[ -n "${PODMAN_SOCKET:-}" ]]; then + normalize_podman_socket_path "$PODMAN_SOCKET" + return + fi + if [[ -n "${XDG_RUNTIME_DIR:-}" && -S "${XDG_RUNTIME_DIR}/podman/podman.sock" ]]; then + printf "%s\n" "${XDG_RUNTIME_DIR}/podman/podman.sock" + return + fi + if [[ -S "/run/user/$(id -u)/podman/podman.sock" ]]; then + printf "%s\n" "/run/user/$(id -u)/podman/podman.sock" + return + fi + if [[ -S /run/podman/podman.sock ]]; then + printf "%s\n" /run/podman/podman.sock + return + fi + if [[ -S /var/run/docker.sock ]]; then + printf "%s\n" /var/run/docker.sock + return + fi + local connection_socket + connection_socket="$( + podman system connection list --format '{{.URI}}' 2>/dev/null | + sed -n 's|^unix://||p' | + while IFS= read -r candidate; do + if [[ -S "$candidate" ]]; then + printf "%s\n" "$candidate" + break + fi + done + )" + if [[ -n "$connection_socket" ]]; then + printf "%s\n" "$connection_socket" + return + fi + return 1 +} + +podman_socket_volume() { + local source="$1" + local target="$2" + if [[ "$PODMAN_SOCKET_DEMO_OWNED" == "1" ]]; then + printf "%s:%s:z\n" "$source" "$target" + else + printf "%s:%s\n" "$source" "$target" + fi +} + +quote_env_value() { + printf "%q" "$1" +} + +write_env_line() { + local name="$1" + local value="$2" + if [[ -n "$SPIRE_ENV_FILE" ]]; then + printf "%s=%s\n" "$name" "$(quote_env_value "$value")" >>"$SPIRE_ENV_FILE" + fi +} + +reset_env_file() { + if [[ -n "$SPIRE_ENV_FILE" ]]; then + mkdir -p "$(dirname "$SPIRE_ENV_FILE")" + : >"$SPIRE_ENV_FILE" + fi +} + +copy_config() { + local source="$1" + local dest="$2" + mkdir -p "$(dirname "$dest")" + cp "$source" "$dest" + chmod 0644 "$dest" +} + +wait_for_socket() { + local path="$1" + local label="$2" + for _ in $(seq 1 80); do + if [[ -S "$path" ]]; then + return + fi + sleep 0.25 + done + printf "%s was not created at %s\n" "$label" "$path" >&2 + return 1 +} + +wait_for_http() { + local url="$1" + local label="$2" + for _ in $(seq 1 80); do + if curl -fsS "$url" >/dev/null 2>&1; then + return + fi + sleep 0.25 + done + printf "%s did not become ready at %s\n" "$label" "$url" >&2 + return 1 +} diff --git a/examples/spiffe-token-exchange-demo/podman/spire/oidc-discovery-provider.conf b/examples/spiffe-token-exchange-demo/podman/spire/oidc-discovery-provider.conf new file mode 100644 index 0000000000..9a1093cba9 --- /dev/null +++ b/examples/spiffe-token-exchange-demo/podman/spire/oidc-discovery-provider.conf @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +log_level = "INFO" +domains = ["spire-oidc", "127.0.0.1", "localhost"] +allow_insecure_scheme = true +insecure_addr = "0.0.0.0:8080" +jwt_issuer = "http://spire-oidc:8080" +jwks_uri = "http://spire-oidc:8080/keys" + +server_api { + address = "unix:///run/spire/server/private/api.sock" +} + +health_checks { + bind_port = "8008" + ready_path = "/ready" + live_path = "/live" +} diff --git a/examples/spiffe-token-exchange-demo/podman/spire/register-gateway.sh b/examples/spiffe-token-exchange-demo/podman/spire/register-gateway.sh new file mode 100755 index 0000000000..88cfca4630 --- /dev/null +++ b/examples/spiffe-token-exchange-demo/podman/spire/register-gateway.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +TRUST_DOMAIN="${TRUST_DOMAIN:-openshell.local}" +GATEWAY_SPIFFE_ID="${GATEWAY_SPIFFE_ID:-spiffe://${TRUST_DOMAIN}/openshell/gateway/demo}" +SPIRE_AGENT_PARENT_ID="${SPIRE_AGENT_PARENT_ID:-spiffe://${TRUST_DOMAIN}/openshell/spire-agent/demo}" +SPIRE_SERVER_SOCKET="${SPIRE_SERVER_SOCKET:-/run/spire/server/private/api.sock}" +SPIRE_SERVER_CONTAINER="${SPIRE_SERVER_CONTAINER:-openshell-spiffe-demo-spire-server}" +SPIRE_SERVER_BIN="${SPIRE_SERVER_BIN:-/opt/spire/bin/spire-server}" + +if [[ -n "${GATEWAY_SELECTORS:-}" ]]; then + read -r -a selectors <<<"$GATEWAY_SELECTORS" +else + gateway_path="${GATEWAY_WORKLOAD_PATH:-$(command -v openshell-server || true)}" + if [[ -n "$gateway_path" ]]; then + selectors=("unix:uid:$(id -u)" "unix:path:${gateway_path}") + else + printf "GATEWAY_WORKLOAD_PATH is unset and openshell-server is not on PATH; falling back to unix:uid only\n" >&2 + selectors=("unix:uid:$(id -u)") + fi +fi + +args=( + entry create + -socketPath "$SPIRE_SERVER_SOCKET" + -parentID "$SPIRE_AGENT_PARENT_ID" + -spiffeID "$GATEWAY_SPIFFE_ID" + -jwtSVIDTTL 300 +) + +for selector in "${selectors[@]}"; do + args+=(-selector "$selector") +done + +printf "Registering gateway SPIFFE entry: %s\n" "$GATEWAY_SPIFFE_ID" >&2 +podman exec "$SPIRE_SERVER_CONTAINER" "$SPIRE_SERVER_BIN" "${args[@]}" diff --git a/examples/spiffe-token-exchange-demo/podman/spire/register-sandbox.sh b/examples/spiffe-token-exchange-demo/podman/spire/register-sandbox.sh new file mode 100755 index 0000000000..5213421cfe --- /dev/null +++ b/examples/spiffe-token-exchange-demo/podman/spire/register-sandbox.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +if [[ $# -ne 1 ]]; then + printf "usage: %s \n" "$0" >&2 + exit 2 +fi + +SANDBOX_ID="$1" +TRUST_DOMAIN="${TRUST_DOMAIN:-openshell.local}" +SANDBOX_SPIFFE_ID="${SANDBOX_SPIFFE_ID:-spiffe://${TRUST_DOMAIN}/openshell/sandbox/${SANDBOX_ID}}" +SPIRE_AGENT_PARENT_ID="${SPIRE_AGENT_PARENT_ID:-spiffe://${TRUST_DOMAIN}/openshell/spire-agent/demo}" +SPIRE_SERVER_SOCKET="${SPIRE_SERVER_SOCKET:-/run/spire/server/private/api.sock}" +SPIRE_SERVER_CONTAINER="${SPIRE_SERVER_CONTAINER:-openshell-spiffe-demo-spire-server}" +SPIRE_SERVER_BIN="${SPIRE_SERVER_BIN:-/opt/spire/bin/spire-server}" + +if [[ -n "${SANDBOX_SELECTORS:-}" ]]; then + read -r -a selectors <<<"$SANDBOX_SELECTORS" +else + selectors=( + "docker:label:openshell.managed:true" + "docker:label:openshell.ai/sandbox-id:${SANDBOX_ID}" + ) +fi + +args=( + entry create + -socketPath "$SPIRE_SERVER_SOCKET" + -parentID "$SPIRE_AGENT_PARENT_ID" + -spiffeID "$SANDBOX_SPIFFE_ID" + -jwtSVIDTTL 300 +) + +for selector in "${selectors[@]}"; do + args+=(-selector "$selector") +done + +printf "Registering sandbox SPIFFE entry: %s\n" "$SANDBOX_SPIFFE_ID" >&2 +podman exec "$SPIRE_SERVER_CONTAINER" "$SPIRE_SERVER_BIN" "${args[@]}" diff --git a/examples/spiffe-token-exchange-demo/podman/spire/server.conf b/examples/spiffe-token-exchange-demo/podman/spire/server.conf new file mode 100644 index 0000000000..59c7f9d154 --- /dev/null +++ b/examples/spiffe-token-exchange-demo/podman/spire/server.conf @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +server { + bind_address = "0.0.0.0" + bind_port = "8081" + socket_path = "/run/spire/server/private/api.sock" + trust_domain = "openshell.local" + data_dir = "/run/spire/server/data" + log_level = "INFO" + jwt_issuer = "http://spire-oidc:8080" + default_x509_svid_ttl = "1h" + default_jwt_svid_ttl = "5m" + + ca_subject { + country = ["US"] + organization = ["OpenShell Demo"] + common_name = "OpenShell Demo SPIRE" + } +} + +plugins { + DataStore "sql" { + plugin_data { + database_type = "sqlite3" + connection_string = "/run/spire/server/data/datastore.sqlite3" + } + } + + NodeAttestor "join_token" { + plugin_data {} + } + + KeyManager "disk" { + plugin_data { + keys_path = "/run/spire/server/data/keys.json" + } + } +} diff --git a/examples/spiffe-token-exchange-demo/podman/spire/start-agent.sh b/examples/spiffe-token-exchange-demo/podman/spire/start-agent.sh new file mode 100755 index 0000000000..a0fab6eddd --- /dev/null +++ b/examples/spiffe-token-exchange-demo/podman/spire/start-agent.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "${SCRIPT_DIR}/common.sh" + +require_cmd podman +require_cmd awk +require_cmd sed + +if [[ -z "$SPIRE_STATE_DIR" ]]; then + SPIRE_STATE_DIR="$(mktemp -d)" +fi + +if ! PODMAN_SOCKET="$(detect_podman_socket)"; then + printf "could not find a Podman API socket; set PODMAN_SOCKET\n" >&2 + exit 1 +fi + +reset_env_file +ensure_network + +agent_dir="${SPIRE_STATE_DIR}/agent" +mount_dir="${SPIRE_STATE_DIR}/mounts" +agent_conf_mount="${mount_dir}/agent.conf" +SPIRE_AGENT_SOCKET_HOST_PATH="${SPIRE_AGENT_SOCKET_HOST_PATH:-${agent_dir}/sockets/agent.sock}" + +mkdir -p "${agent_dir}/data" "${agent_dir}/sockets" "$mount_dir" +chmod 0777 "$SPIRE_STATE_DIR" "$agent_dir" "${agent_dir}/data" "${agent_dir}/sockets" +copy_config "${SPIRE_SCRIPT_DIR}/agent.conf" "$agent_conf_mount" + +if [[ "$CLEANUP_EXISTING" == "1" ]]; then + cleanup_container "$SPIRE_AGENT_CONTAINER" +fi + +join_token="$( + podman exec "$SPIRE_SERVER_CONTAINER" "$SPIRE_SERVER_BIN" token generate \ + -socketPath /run/spire/server/private/api.sock \ + -spiffeID "$SPIRE_AGENT_PARENT_ID" | + awk '/Token:/ { print $2; exit }' +)" +if [[ -z "$join_token" ]]; then + printf "failed to generate SPIRE agent join token\n" >&2 + exit 1 +fi + +run podman run -d \ + --name "$SPIRE_AGENT_CONTAINER" \ + --network "$PODMAN_NETWORK" \ + --pid=host \ + --security-opt label=disable \ + -v "${agent_conf_mount}:/run/spire/config/agent.conf:ro,z" \ + -v "${agent_dir}:/run/spire/agent:z" \ + -v "$(podman_socket_volume "$PODMAN_SOCKET" /run/podman/podman.sock)" \ + "$SPIRE_AGENT_IMAGE" \ + -config /run/spire/config/agent.conf -joinToken "$join_token" + +if ! wait_for_socket "$SPIRE_AGENT_SOCKET_HOST_PATH" "SPIRE agent Workload API socket"; then + podman logs "$SPIRE_AGENT_CONTAINER" >&2 || true + exit 1 +fi + +write_env_line SPIRE_STATE_DIR "$SPIRE_STATE_DIR" +write_env_line SPIRE_AGENT_DIR "$agent_dir" +write_env_line SPIRE_AGENT_SOCKET_HOST_PATH "$SPIRE_AGENT_SOCKET_HOST_PATH" +write_env_line PODMAN_SOCKET "$PODMAN_SOCKET" + +printf "SPIRE agent container: %s\n" "$SPIRE_AGENT_CONTAINER" +printf "SPIRE agent parent ID: %s\n" "$SPIRE_AGENT_PARENT_ID" +printf "SPIRE agent Workload API socket: %s\n" "$SPIRE_AGENT_SOCKET_HOST_PATH" +if [[ -n "$SPIRE_ENV_FILE" ]]; then + printf "Wrote environment file: %s\n" "$SPIRE_ENV_FILE" +fi diff --git a/examples/spiffe-token-exchange-demo/podman/spire/start-server-oidc.sh b/examples/spiffe-token-exchange-demo/podman/spire/start-server-oidc.sh new file mode 100755 index 0000000000..321ff9a2ea --- /dev/null +++ b/examples/spiffe-token-exchange-demo/podman/spire/start-server-oidc.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "${SCRIPT_DIR}/common.sh" + +require_cmd podman +require_cmd curl + +if [[ -z "$SPIRE_STATE_DIR" ]]; then + SPIRE_STATE_DIR="$(mktemp -d)" +fi + +reset_env_file +ensure_network + +server_dir="${SPIRE_STATE_DIR}/server" +mount_dir="${SPIRE_STATE_DIR}/mounts" +server_conf_mount="${mount_dir}/server.conf" +oidc_conf_mount="${mount_dir}/oidc-discovery-provider.conf" +server_socket_host_path="${server_dir}/private/api.sock" + +mkdir -p "${server_dir}/data" "${server_dir}/private" "$mount_dir" +chmod 0777 "$SPIRE_STATE_DIR" "$server_dir" "${server_dir}/data" "${server_dir}/private" +copy_config "${SPIRE_SCRIPT_DIR}/server.conf" "$server_conf_mount" +copy_config "${SPIRE_SCRIPT_DIR}/oidc-discovery-provider.conf" "$oidc_conf_mount" + +if [[ "$CLEANUP_EXISTING" == "1" ]]; then + cleanup_container "$SPIRE_OIDC_CONTAINER" + cleanup_container "$SPIRE_SERVER_CONTAINER" +fi + +printf "Starting SPIRE server container: %s\n" "$SPIRE_SERVER_CONTAINER" >&2 +run podman run -d \ + --name "$SPIRE_SERVER_CONTAINER" \ + --network "$PODMAN_NETWORK" \ + --network-alias spire-server \ + -v "${server_conf_mount}:/run/spire/config/server.conf:ro,z" \ + -v "${server_dir}:/run/spire/server:z" \ + "$SPIRE_SERVER_IMAGE" \ + -config /run/spire/config/server.conf + +if ! wait_for_socket "$server_socket_host_path" "SPIRE server API socket"; then + podman logs "$SPIRE_SERVER_CONTAINER" >&2 || true + exit 1 +fi + +printf "Starting SPIRE OIDC discovery provider container: %s\n" "$SPIRE_OIDC_CONTAINER" >&2 +run podman run -d \ + --name "$SPIRE_OIDC_CONTAINER" \ + --network "$PODMAN_NETWORK" \ + --network-alias spire-oidc \ + -p "127.0.0.1:${OIDC_PORT}:8080" \ + -v "${oidc_conf_mount}:/run/spire/config/oidc-discovery-provider.conf:ro,z" \ + -v "${server_dir}/private:/run/spire/server/private:z" \ + "$SPIRE_OIDC_IMAGE" \ + -config /run/spire/config/oidc-discovery-provider.conf + +if ! wait_for_http "http://127.0.0.1:${OIDC_PORT}/keys" "SPIRE OIDC discovery provider"; then + podman logs "$SPIRE_OIDC_CONTAINER" >&2 || true + exit 1 +fi + +write_env_line SPIRE_STATE_DIR "$SPIRE_STATE_DIR" +write_env_line SPIRE_SERVER_DIR "$server_dir" +write_env_line SPIRE_SERVER_SOCKET_HOST_PATH "$server_socket_host_path" +write_env_line SPIRE_OIDC_KEYS_URL "http://127.0.0.1:${OIDC_PORT}/keys" + +printf "SPIRE server container: %s\n" "$SPIRE_SERVER_CONTAINER" +printf "SPIRE server API socket: %s\n" "$server_socket_host_path" +printf "SPIRE OIDC discovery provider container: %s\n" "$SPIRE_OIDC_CONTAINER" +printf "SPIRE OIDC JWKS URL: http://127.0.0.1:%s/keys\n" "$OIDC_PORT" +if [[ -n "$SPIRE_ENV_FILE" ]]; then + printf "Wrote environment file: %s\n" "$SPIRE_ENV_FILE" +fi diff --git a/examples/spiffe-token-exchange-demo/podman/start-gateway.sh b/examples/spiffe-token-exchange-demo/podman/start-gateway.sh new file mode 100755 index 0000000000..fea89a117a --- /dev/null +++ b/examples/spiffe-token-exchange-demo/podman/start-gateway.sh @@ -0,0 +1,233 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=spire/common.sh +source "${SCRIPT_DIR}/spire/common.sh" + +require_cmd podman +require_cmd curl +require_cmd openssl + +if [[ -n "${SPIRE_AGENT_ENV_FILE:-}" ]]; then + # shellcheck disable=SC1090 + source "$SPIRE_AGENT_ENV_FILE" +fi + +GATEWAY_CONTAINER="${GATEWAY_CONTAINER:-openshell-spiffe-demo-gateway}" +GATEWAY_IMAGE="${GATEWAY_IMAGE:-ghcr.io/nvidia/openshell/gateway:latest}" +GATEWAY_ID="${GATEWAY_ID:-podman-spiffe-demo}" +GATEWAY_PORT="${GATEWAY_PORT:-8888}" +GATEWAY_HEALTH_PORT="${GATEWAY_HEALTH_PORT:-8889}" +GATEWAY_STATE_DIR="${GATEWAY_STATE_DIR:-}" +GATEWAY_ENV_FILE="${GATEWAY_ENV_FILE:-}" +SANDBOX_IMAGE="${SANDBOX_IMAGE:-}" +SUPERVISOR_IMAGE="${SUPERVISOR_IMAGE:-}" +SANDBOX_IMAGE_PULL_POLICY="${SANDBOX_IMAGE_PULL_POLICY:-missing}" +PODMAN_STOP_TIMEOUT_SECS="${PODMAN_STOP_TIMEOUT_SECS:-3}" +GATEWAY_OIDC_ISSUER="${GATEWAY_OIDC_ISSUER:-}" +GATEWAY_OIDC_AUDIENCE="${GATEWAY_OIDC_AUDIENCE:-openshell-cli}" +GATEWAY_OIDC_JWKS_TTL_SECS="${GATEWAY_OIDC_JWKS_TTL_SECS:-3600}" +GATEWAY_OIDC_ROLES_CLAIM="${GATEWAY_OIDC_ROLES_CLAIM:-realm_access.roles}" +GATEWAY_OIDC_ADMIN_ROLE="${GATEWAY_OIDC_ADMIN_ROLE:-openshell-admin}" +GATEWAY_OIDC_USER_ROLE="${GATEWAY_OIDC_USER_ROLE:-openshell-user}" +GATEWAY_OIDC_SCOPES_CLAIM="${GATEWAY_OIDC_SCOPES_CLAIM:-}" +GATEWAY_OIDC_CLIENT_ID="${GATEWAY_OIDC_CLIENT_ID:-openshell-cli}" +GATEWAY_OIDC_LOGIN_SCOPES="${GATEWAY_OIDC_LOGIN_SCOPES:-}" +if [[ -z "${GATEWAY_ALLOW_UNAUTHENTICATED_USERS:-}" ]]; then + if [[ -n "$GATEWAY_OIDC_ISSUER" ]]; then + GATEWAY_ALLOW_UNAUTHENTICATED_USERS="false" + else + GATEWAY_ALLOW_UNAUTHENTICATED_USERS="true" + fi +fi + +if [[ -z "${SPIRE_AGENT_SOCKET_HOST_PATH:-}" ]]; then + printf "SPIRE_AGENT_SOCKET_HOST_PATH is required; source start-agent.sh's env file or set SPIRE_AGENT_ENV_FILE\n" >&2 + exit 1 +fi +if [[ ! -S "$SPIRE_AGENT_SOCKET_HOST_PATH" ]]; then + printf "SPIRE agent Workload API socket does not exist: %s\n" "$SPIRE_AGENT_SOCKET_HOST_PATH" >&2 + exit 1 +fi +if ! PODMAN_SOCKET="$(detect_podman_socket)"; then + printf "could not find a Podman API socket; set PODMAN_SOCKET\n" >&2 + exit 1 +fi +if [[ ! "$PODMAN_STOP_TIMEOUT_SECS" =~ ^[0-9]+$ ]]; then + printf "PODMAN_STOP_TIMEOUT_SECS must be a non-negative integer, got: %s\n" "$PODMAN_STOP_TIMEOUT_SECS" >&2 + exit 1 +fi +if [[ ! "$GATEWAY_OIDC_JWKS_TTL_SECS" =~ ^[0-9]+$ ]]; then + printf "GATEWAY_OIDC_JWKS_TTL_SECS must be a non-negative integer, got: %s\n" "$GATEWAY_OIDC_JWKS_TTL_SECS" >&2 + exit 1 +fi +if [[ "$GATEWAY_ALLOW_UNAUTHENTICATED_USERS" != "true" && "$GATEWAY_ALLOW_UNAUTHENTICATED_USERS" != "false" ]]; then + printf "GATEWAY_ALLOW_UNAUTHENTICATED_USERS must be true or false, got: %s\n" "$GATEWAY_ALLOW_UNAUTHENTICATED_USERS" >&2 + exit 1 +fi + +if [[ -z "$GATEWAY_STATE_DIR" ]]; then + GATEWAY_STATE_DIR="$(mktemp -d)" +fi + +toml_string_escape() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + printf "%s\n" "$value" +} + +write_gateway_env_line() { + local name="$1" + local value="$2" + if [[ -n "$GATEWAY_ENV_FILE" ]]; then + printf "%s=%s\n" "$name" "$(quote_env_value "$value")" >>"$GATEWAY_ENV_FILE" + fi +} + +reset_gateway_env_file() { + if [[ -n "$GATEWAY_ENV_FILE" ]]; then + mkdir -p "$(dirname "$GATEWAY_ENV_FILE")" + : >"$GATEWAY_ENV_FILE" + fi +} + +write_gateway_config() { + local podman_socket_in_container="$1" + local jwt_dir="${GATEWAY_STATE_DIR}/jwt" + local config_path="${GATEWAY_STATE_DIR}/gateway.toml" + local sandbox_image_line="" + local supervisor_image_line="" + local oidc_block="" + + mkdir -p "$jwt_dir" + if [[ ! -s "${jwt_dir}/signing.pem" ]]; then + openssl genpkey -algorithm ed25519 -out "${jwt_dir}/signing.pem" >/dev/null 2>&1 + openssl pkey -in "${jwt_dir}/signing.pem" -pubout -out "${jwt_dir}/public.pem" >/dev/null 2>&1 + openssl rand -hex 8 >"${jwt_dir}/kid" + fi + if [[ -n "$SANDBOX_IMAGE" ]]; then + sandbox_image_line="default_image = \"$(toml_string_escape "$SANDBOX_IMAGE")\"" + fi + if [[ -n "$SUPERVISOR_IMAGE" ]]; then + supervisor_image_line="supervisor_image = \"$(toml_string_escape "$SUPERVISOR_IMAGE")\"" + fi + if [[ -n "$GATEWAY_OIDC_ISSUER" ]]; then + oidc_block=" +[openshell.gateway.oidc] +issuer = \"$(toml_string_escape "$GATEWAY_OIDC_ISSUER")\" +audience = \"$(toml_string_escape "$GATEWAY_OIDC_AUDIENCE")\" +jwks_ttl_secs = ${GATEWAY_OIDC_JWKS_TTL_SECS} +roles_claim = \"$(toml_string_escape "$GATEWAY_OIDC_ROLES_CLAIM")\" +admin_role = \"$(toml_string_escape "$GATEWAY_OIDC_ADMIN_ROLE")\" +user_role = \"$(toml_string_escape "$GATEWAY_OIDC_USER_ROLE")\" +scopes_claim = \"$(toml_string_escape "$GATEWAY_OIDC_SCOPES_CLAIM")\" +" + fi + + cat >"$config_path" <&2 || true + exit 1 +fi + +write_gateway_env_line GATEWAY_STATE_DIR "$GATEWAY_STATE_DIR" +write_gateway_env_line GATEWAY_CONFIG "$gateway_config" +write_gateway_env_line GATEWAY_CONTAINER "$GATEWAY_CONTAINER" +write_gateway_env_line GATEWAY_ENDPOINT "http://127.0.0.1:${GATEWAY_PORT}" +write_gateway_env_line GATEWAY_HEALTH_ENDPOINT "http://127.0.0.1:${GATEWAY_HEALTH_PORT}" +write_gateway_env_line GATEWAY_OIDC_ISSUER "$GATEWAY_OIDC_ISSUER" +write_gateway_env_line GATEWAY_OIDC_AUDIENCE "$GATEWAY_OIDC_AUDIENCE" +write_gateway_env_line GATEWAY_OIDC_CLIENT_ID "$GATEWAY_OIDC_CLIENT_ID" +write_gateway_env_line GATEWAY_OIDC_LOGIN_SCOPES "$GATEWAY_OIDC_LOGIN_SCOPES" + +printf "OpenShell gateway container: %s\n" "$GATEWAY_CONTAINER" +printf "OpenShell gateway endpoint: http://127.0.0.1:%s\n" "$GATEWAY_PORT" +printf "OpenShell gateway health endpoint: http://127.0.0.1:%s\n" "$GATEWAY_HEALTH_PORT" +printf "OpenShell gateway config: %s\n" "$gateway_config" +printf "SPIRE agent Workload API socket: %s\n" "$SPIRE_AGENT_SOCKET_HOST_PATH" +if [[ -n "$GATEWAY_OIDC_ISSUER" ]]; then + printf "OpenShell gateway OIDC issuer: %s\n" "$GATEWAY_OIDC_ISSUER" + printf "Register/login with:\n" + printf " openshell gateway add http://127.0.0.1:%s --name %s --oidc-issuer %s --oidc-client-id %s --oidc-audience %s" \ + "$GATEWAY_PORT" "$GATEWAY_ID" "$GATEWAY_OIDC_ISSUER" "$GATEWAY_OIDC_CLIENT_ID" "$GATEWAY_OIDC_AUDIENCE" + if [[ -n "$GATEWAY_OIDC_LOGIN_SCOPES" ]]; then + printf " --oidc-scopes %s" "$GATEWAY_OIDC_LOGIN_SCOPES" + fi + printf "\n" +fi +if [[ -n "$GATEWAY_ENV_FILE" ]]; then + printf "Wrote environment file: %s\n" "$GATEWAY_ENV_FILE" +fi diff --git a/examples/spiffe-token-exchange-demo/provider-profile.yaml b/examples/spiffe-token-exchange-demo/provider-profile.yaml new file mode 100644 index 0000000000..48e5dbe6c8 --- /dev/null +++ b/examples/spiffe-token-exchange-demo/provider-profile.yaml @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: spiffe-token-exchange-demo +display_name: SPIFFE token exchange demo +description: Dynamic token exchange for alpha/beta demo services using stored user subject tokens and SPIFFE JWT-SVID authentication +category: other +credentials: + - name: subject_token + description: Demo user subject token stored for gateway-side token exchange only + required: true + - name: access_token + description: Access token obtained via RFC 8693 token exchange + required: false + auth_style: bearer + header_name: Authorization + token_grant: + grant_type: token_exchange + token_endpoint: http://token-exchange-issuer.default.svc.cluster.local/token + audience: demo-default + jwt_svid_audience: http://token-exchange-issuer.default.svc.cluster.local + client_assertion_type: urn:ietf:params:oauth:client-assertion-type:jwt-spiffe + scopes: [demo] + cache_ttl_seconds: 60 + subject_token: + source: provider_credential + credential: subject_token + subject_token_type: urn:ietf:params:oauth:token-type:access_token + audience_overrides: + - host: alpha-exchange.default.svc.cluster.local + port: 80 + audience: alpha + scopes: [alpha] + - host: beta-exchange.default.svc.cluster.local + port: 80 + audience: beta + scopes: [beta] +endpoints: + - host: alpha-exchange.default.svc.cluster.local + port: 80 + protocol: rest + tls: none + access: read-write + enforcement: enforce + - host: beta-exchange.default.svc.cluster.local + port: 80 + protocol: rest + tls: none + access: read-write + enforcement: enforce +binaries: + - /usr/bin/curl + - /usr/local/bin/curl diff --git a/proto/openshell.proto b/proto/openshell.proto index 9f58103a5d..6f1481b521 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -426,6 +426,15 @@ service OpenShell { }; } + // Exchange a stored provider subject token for an intermediate token scoped + // to the calling supervisor's SPIFFE identity. + rpc ExchangeProviderSubjectToken(ExchangeProviderSubjectTokenRequest) + returns (ExchangeProviderSubjectTokenResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "sandbox" + }; + } + // Fetch recent sandbox logs (one-shot). rpc GetSandboxLogs(GetSandboxLogsRequest) returns (GetSandboxLogsResponse) { option (openshell.options.v1.authorization) = { @@ -1498,6 +1507,25 @@ message ProviderCredentialTokenGrantAudienceOverride { // Provider credential token grant configuration. // When present, the credential is obtained dynamically via OAuth2 grant when needed. +enum ProviderCredentialTokenGrantType { + PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_UNSPECIFIED = 0; + PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_CLIENT_CREDENTIALS = 1; + PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_TOKEN_EXCHANGE = 2; +} + +message ProviderCredentialTokenGrantSubjectToken { + // Source for the token exchange subject token. Phase one supports + // "provider_credential". + string source = 1; + + // Provider credential key that stores the subject token. + string credential = 2; + + // OAuth2 subject_token_type. If omitted, OpenShell uses + // urn:ietf:params:oauth:token-type:access_token. + string subject_token_type = 3; +} + message ProviderCredentialTokenGrant { // OAuth2 token endpoint URL (e.g., https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token) string token_endpoint = 1; @@ -1522,6 +1550,17 @@ message ProviderCredentialTokenGrant { // Optional: OAuth2 client_assertion_type value. If omitted, OpenShell uses // urn:ietf:params:oauth:client-assertion-type:jwt-bearer. string client_assertion_type = 7; + + // Grant type. If omitted/unspecified, OpenShell treats this as client_credentials + // for backwards compatibility. + ProviderCredentialTokenGrantType grant_type = 8; + + // Subject token metadata for token_exchange grants. + ProviderCredentialTokenGrantSubjectToken subject_token = 9; + + // OAuth2 requested_token_type. If omitted for token_exchange, OpenShell uses + // urn:ietf:params:oauth:token-type:access_token. + string requested_token_type = 10; } // Provider credential declaration. @@ -1868,6 +1907,27 @@ message GetSandboxProviderEnvironmentResponse { repeated string non_secret_environment_keys = 6; } +message ExchangeProviderSubjectTokenRequest { + // The sandbox ID. Must match the authenticated sandbox principal. + string sandbox_id = 1; + + // Attached provider record holding the configured subject token credential. + string provider = 2; + + // Provider profile credential that declares the token_exchange grant. + string credential_key = 3; + + // Supervisor JWT-SVID. The gateway verifies this and uses its `sub` claim + // as the requested audience for the intermediate token. + string supervisor_jwt_svid = 4 [(openshell.options.v1.secret) = true]; +} + +message ExchangeProviderSubjectTokenResponse { + string access_token = 1 [(openshell.options.v1.secret) = true]; + int64 expires_in = 2; + string token_type = 3; +} + // --------------------------------------------------------------------------- // Policy update messages // --------------------------------------------------------------------------- diff --git a/sdk/go/openshell/v1/internal/converter/coverage_test.go b/sdk/go/openshell/v1/internal/converter/coverage_test.go index 49532f59be..6481454d6d 100644 --- a/sdk/go/openshell/v1/internal/converter/coverage_test.go +++ b/sdk/go/openshell/v1/internal/converter/coverage_test.go @@ -265,6 +265,9 @@ func TestConverterCoversAllProtoFields_ProviderCredentialTokenGrant(t *testing.T "cache_ttl_seconds": true, "audience_overrides": true, "client_assertion_type": true, + "grant_type": true, + "subject_token": true, + "requested_token_type": true, } assertAllFieldsCovered(t, (&pb.ProviderCredentialTokenGrant{}).ProtoReflect().Descriptor(), handled, nil) diff --git a/sdk/go/openshell/v1/internal/converter/profile.go b/sdk/go/openshell/v1/internal/converter/profile.go index b0edaf6445..8bbb346829 100644 --- a/sdk/go/openshell/v1/internal/converter/profile.go +++ b/sdk/go/openshell/v1/internal/converter/profile.go @@ -55,6 +55,30 @@ func ProfileCategoryToProto(c types.ProfileCategory) pb.ProviderProfileCategory } } +// CredentialTokenGrantTypeFromProto converts a proto token grant type to an SDK token grant type. +func CredentialTokenGrantTypeFromProto(t pb.ProviderCredentialTokenGrantType) types.CredentialTokenGrantType { + switch t { + case pb.ProviderCredentialTokenGrantType_PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_CLIENT_CREDENTIALS: + return types.CredentialTokenGrantTypeClientCredentials + case pb.ProviderCredentialTokenGrantType_PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_TOKEN_EXCHANGE: + return types.CredentialTokenGrantTypeTokenExchange + default: + return types.CredentialTokenGrantType("") + } +} + +// CredentialTokenGrantTypeToProto converts an SDK token grant type to a proto token grant type. +func CredentialTokenGrantTypeToProto(t types.CredentialTokenGrantType) pb.ProviderCredentialTokenGrantType { + switch t { + case types.CredentialTokenGrantTypeClientCredentials: + return pb.ProviderCredentialTokenGrantType_PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_CLIENT_CREDENTIALS + case types.CredentialTokenGrantTypeTokenExchange: + return pb.ProviderCredentialTokenGrantType_PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_TOKEN_EXCHANGE + default: + return pb.ProviderCredentialTokenGrantType_PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_UNSPECIFIED + } +} + // --- NetworkEndpoint --- // NetworkEndpointFromProto converts a proto NetworkEndpoint to an SDK NetworkEndpoint. @@ -193,6 +217,9 @@ func tokenGrantFromProto(tg *pb.ProviderCredentialTokenGrant) *types.CredentialT Scopes: CopyStringSlice(tg.GetScopes()), CacheTTLSeconds: tg.GetCacheTtlSeconds(), ClientAssertionType: tg.GetClientAssertionType(), + GrantType: CredentialTokenGrantTypeFromProto(tg.GetGrantType()), + SubjectToken: subjectTokenFromProto(tg.GetSubjectToken()), + RequestedTokenType: tg.GetRequestedTokenType(), } if overrides := tg.GetAudienceOverrides(); len(overrides) > 0 { result.AudienceOverrides = make([]types.TokenGrantAudienceOverride, len(overrides)) @@ -214,6 +241,9 @@ func tokenGrantToProto(tg *types.CredentialTokenGrant) *pb.ProviderCredentialTok Scopes: CopyStringSlice(tg.Scopes), CacheTtlSeconds: tg.CacheTTLSeconds, ClientAssertionType: tg.ClientAssertionType, + GrantType: CredentialTokenGrantTypeToProto(tg.GrantType), + SubjectToken: subjectTokenToProto(tg.SubjectToken), + RequestedTokenType: tg.RequestedTokenType, } if len(tg.AudienceOverrides) > 0 { result.AudienceOverrides = make([]*pb.ProviderCredentialTokenGrantAudienceOverride, len(tg.AudienceOverrides)) @@ -224,6 +254,28 @@ func tokenGrantToProto(tg *types.CredentialTokenGrant) *pb.ProviderCredentialTok return result } +func subjectTokenFromProto(st *pb.ProviderCredentialTokenGrantSubjectToken) *types.TokenGrantSubjectToken { + if st == nil { + return nil + } + return &types.TokenGrantSubjectToken{ + Source: st.GetSource(), + Credential: st.GetCredential(), + SubjectTokenType: st.GetSubjectTokenType(), + } +} + +func subjectTokenToProto(st *types.TokenGrantSubjectToken) *pb.ProviderCredentialTokenGrantSubjectToken { + if st == nil { + return nil + } + return &pb.ProviderCredentialTokenGrantSubjectToken{ + Source: st.Source, + Credential: st.Credential, + SubjectTokenType: st.SubjectTokenType, + } +} + func audienceOverrideFromProto(o *pb.ProviderCredentialTokenGrantAudienceOverride) types.TokenGrantAudienceOverride { if o == nil { return types.TokenGrantAudienceOverride{} diff --git a/sdk/go/openshell/v1/internal/converter/profile_test.go b/sdk/go/openshell/v1/internal/converter/profile_test.go index 8efe8d294b..5d52c0a9ee 100644 --- a/sdk/go/openshell/v1/internal/converter/profile_test.go +++ b/sdk/go/openshell/v1/internal/converter/profile_test.go @@ -156,6 +156,13 @@ func TestProfileCredentialFromProto(t *testing.T) { Scopes: []string{"read", "write"}, CacheTtlSeconds: 300, ClientAssertionType: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + GrantType: pb.ProviderCredentialTokenGrantType_PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_TOKEN_EXCHANGE, + SubjectToken: &pb.ProviderCredentialTokenGrantSubjectToken{ + Source: "provider_credential", + Credential: "UPSTREAM_TOKEN", + SubjectTokenType: "urn:ietf:params:oauth:token-type:access_token", + }, + RequestedTokenType: "urn:ietf:params:oauth:token-type:refresh_token", AudienceOverrides: []*pb.ProviderCredentialTokenGrantAudienceOverride{ {Host: "special.example.com", Port: 8443, Path: "/api", Audience: "https://special.example.com", Scopes: []string{"admin"}}, }, @@ -182,6 +189,12 @@ func TestProfileCredentialFromProto(t *testing.T) { assert.Equal(t, []string{"read", "write"}, cred.TokenGrant.Scopes) assert.Equal(t, int64(300), cred.TokenGrant.CacheTTLSeconds) assert.Equal(t, "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", cred.TokenGrant.ClientAssertionType) + assert.Equal(t, v1.CredentialTokenGrantTypeTokenExchange, cred.TokenGrant.GrantType) + require.NotNil(t, cred.TokenGrant.SubjectToken) + assert.Equal(t, "provider_credential", cred.TokenGrant.SubjectToken.Source) + assert.Equal(t, "UPSTREAM_TOKEN", cred.TokenGrant.SubjectToken.Credential) + assert.Equal(t, "urn:ietf:params:oauth:token-type:access_token", cred.TokenGrant.SubjectToken.SubjectTokenType) + assert.Equal(t, "urn:ietf:params:oauth:token-type:refresh_token", cred.TokenGrant.RequestedTokenType) require.Len(t, cred.TokenGrant.AudienceOverrides, 1) assert.Equal(t, "special.example.com", cred.TokenGrant.AudienceOverrides[0].Host) assert.Equal(t, uint32(8443), cred.TokenGrant.AudienceOverrides[0].Port) @@ -261,6 +274,13 @@ func TestProfileCredentialToProto(t *testing.T) { Scopes: []string{"read"}, CacheTTLSeconds: 300, ClientAssertionType: "urn:custom", + GrantType: v1.CredentialTokenGrantTypeTokenExchange, + SubjectToken: &v1.TokenGrantSubjectToken{ + Source: "provider_credential", + Credential: "UPSTREAM_TOKEN", + SubjectTokenType: "urn:ietf:params:oauth:token-type:access_token", + }, + RequestedTokenType: "urn:ietf:params:oauth:token-type:refresh_token", AudienceOverrides: []v1.TokenGrantAudienceOverride{ {Host: "h", Port: 443, Path: "/p", Audience: "aud", Scopes: []string{"s"}}, }, @@ -292,6 +312,12 @@ func TestProfileCredentialToProto(t *testing.T) { assert.Equal(t, []string{"read"}, proto.TokenGrant.Scopes) assert.Equal(t, int64(300), proto.TokenGrant.CacheTtlSeconds) assert.Equal(t, "urn:custom", proto.TokenGrant.ClientAssertionType) + assert.Equal(t, pb.ProviderCredentialTokenGrantType_PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_TOKEN_EXCHANGE, proto.TokenGrant.GrantType) + require.NotNil(t, proto.TokenGrant.SubjectToken) + assert.Equal(t, "provider_credential", proto.TokenGrant.SubjectToken.Source) + assert.Equal(t, "UPSTREAM_TOKEN", proto.TokenGrant.SubjectToken.Credential) + assert.Equal(t, "urn:ietf:params:oauth:token-type:access_token", proto.TokenGrant.SubjectToken.SubjectTokenType) + assert.Equal(t, "urn:ietf:params:oauth:token-type:refresh_token", proto.TokenGrant.RequestedTokenType) require.Len(t, proto.TokenGrant.AudienceOverrides, 1) assert.Equal(t, "h", proto.TokenGrant.AudienceOverrides[0].Host) } diff --git a/sdk/go/openshell/v1/types/profile.go b/sdk/go/openshell/v1/types/profile.go index 2ca7da18ff..e519df634d 100644 --- a/sdk/go/openshell/v1/types/profile.go +++ b/sdk/go/openshell/v1/types/profile.go @@ -75,6 +75,15 @@ type ProfileCredentialRefreshOutput struct { Credential string } +// CredentialTokenGrantType describes which OAuth2 grant type to use for dynamic credentials. +type CredentialTokenGrantType string + +// CredentialTokenGrantType values. +const ( + CredentialTokenGrantTypeClientCredentials CredentialTokenGrantType = "ClientCredentials" + CredentialTokenGrantTypeTokenExchange CredentialTokenGrantType = "TokenExchange" +) + // CredentialTokenGrant configures dynamic credential acquisition via OAuth2 grant. type CredentialTokenGrant struct { TokenEndpoint string @@ -84,6 +93,16 @@ type CredentialTokenGrant struct { CacheTTLSeconds int64 AudienceOverrides []TokenGrantAudienceOverride ClientAssertionType string + GrantType CredentialTokenGrantType + SubjectToken *TokenGrantSubjectToken + RequestedTokenType string +} + +// TokenGrantSubjectToken configures the subject token for token exchange grants. +type TokenGrantSubjectToken struct { + Source string + Credential string + SubjectTokenType string } // TokenGrantAudienceOverride selects an endpoint-specific resource audience. diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index 7bdf99b333..778bf4f985 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -99,6 +99,57 @@ func (SandboxPhase) EnumDescriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{0} } +// Provider credential token grant configuration. +// When present, the credential is obtained dynamically via OAuth2 grant when needed. +type ProviderCredentialTokenGrantType int32 + +const ( + ProviderCredentialTokenGrantType_PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_UNSPECIFIED ProviderCredentialTokenGrantType = 0 + ProviderCredentialTokenGrantType_PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_CLIENT_CREDENTIALS ProviderCredentialTokenGrantType = 1 + ProviderCredentialTokenGrantType_PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_TOKEN_EXCHANGE ProviderCredentialTokenGrantType = 2 +) + +// Enum value maps for ProviderCredentialTokenGrantType. +var ( + ProviderCredentialTokenGrantType_name = map[int32]string{ + 0: "PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_UNSPECIFIED", + 1: "PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_CLIENT_CREDENTIALS", + 2: "PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_TOKEN_EXCHANGE", + } + ProviderCredentialTokenGrantType_value = map[string]int32{ + "PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_UNSPECIFIED": 0, + "PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_CLIENT_CREDENTIALS": 1, + "PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_TOKEN_EXCHANGE": 2, + } +) + +func (x ProviderCredentialTokenGrantType) Enum() *ProviderCredentialTokenGrantType { + p := new(ProviderCredentialTokenGrantType) + *p = x + return p +} + +func (x ProviderCredentialTokenGrantType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ProviderCredentialTokenGrantType) Descriptor() protoreflect.EnumDescriptor { + return file_openshell_proto_enumTypes[1].Descriptor() +} + +func (ProviderCredentialTokenGrantType) Type() protoreflect.EnumType { + return &file_openshell_proto_enumTypes[1] +} + +func (x ProviderCredentialTokenGrantType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ProviderCredentialTokenGrantType.Descriptor instead. +func (ProviderCredentialTokenGrantType) EnumDescriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{1} +} + type ProviderCredentialRefreshStrategy int32 const ( @@ -144,11 +195,11 @@ func (x ProviderCredentialRefreshStrategy) String() string { } func (ProviderCredentialRefreshStrategy) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[1].Descriptor() + return file_openshell_proto_enumTypes[2].Descriptor() } func (ProviderCredentialRefreshStrategy) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[1] + return &file_openshell_proto_enumTypes[2] } func (x ProviderCredentialRefreshStrategy) Number() protoreflect.EnumNumber { @@ -157,7 +208,7 @@ func (x ProviderCredentialRefreshStrategy) Number() protoreflect.EnumNumber { // Deprecated: Use ProviderCredentialRefreshStrategy.Descriptor instead. func (ProviderCredentialRefreshStrategy) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{1} + return file_openshell_proto_rawDescGZIP(), []int{2} } // Stable provider profile categories used by clients for grouping and filtering. @@ -209,11 +260,11 @@ func (x ProviderProfileCategory) String() string { } func (ProviderProfileCategory) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[2].Descriptor() + return file_openshell_proto_enumTypes[3].Descriptor() } func (ProviderProfileCategory) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[2] + return &file_openshell_proto_enumTypes[3] } func (x ProviderProfileCategory) Number() protoreflect.EnumNumber { @@ -222,7 +273,7 @@ func (x ProviderProfileCategory) Number() protoreflect.EnumNumber { // Deprecated: Use ProviderProfileCategory.Descriptor instead. func (ProviderProfileCategory) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{2} + return file_openshell_proto_rawDescGZIP(), []int{3} } // Policy load status. @@ -269,11 +320,11 @@ func (x PolicyStatus) String() string { } func (PolicyStatus) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[3].Descriptor() + return file_openshell_proto_enumTypes[4].Descriptor() } func (PolicyStatus) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[3] + return &file_openshell_proto_enumTypes[4] } func (x PolicyStatus) Number() protoreflect.EnumNumber { @@ -282,7 +333,7 @@ func (x PolicyStatus) Number() protoreflect.EnumNumber { // Deprecated: Use PolicyStatus.Descriptor instead. func (PolicyStatus) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{3} + return file_openshell_proto_rawDescGZIP(), []int{4} } // Service status enum. @@ -322,11 +373,11 @@ func (x ServiceStatus) String() string { } func (ServiceStatus) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[4].Descriptor() + return file_openshell_proto_enumTypes[5].Descriptor() } func (ServiceStatus) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[4] + return &file_openshell_proto_enumTypes[5] } func (x ServiceStatus) Number() protoreflect.EnumNumber { @@ -335,7 +386,7 @@ func (x ServiceStatus) Number() protoreflect.EnumNumber { // Deprecated: Use ServiceStatus.Descriptor instead. func (ServiceStatus) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{4} + return file_openshell_proto_rawDescGZIP(), []int{5} } // Workspace-scoped role for members. @@ -372,11 +423,11 @@ func (x WorkspaceRole) String() string { } func (WorkspaceRole) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[5].Descriptor() + return file_openshell_proto_enumTypes[6].Descriptor() } func (WorkspaceRole) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[5] + return &file_openshell_proto_enumTypes[6] } func (x WorkspaceRole) Number() protoreflect.EnumNumber { @@ -385,7 +436,7 @@ func (x WorkspaceRole) Number() protoreflect.EnumNumber { // Deprecated: Use WorkspaceRole.Descriptor instead. func (WorkspaceRole) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{5} + return file_openshell_proto_rawDescGZIP(), []int{6} } // IssueSandboxToken request. Empty body; identity is established by the @@ -5280,8 +5331,71 @@ func (x *ProviderCredentialTokenGrantAudienceOverride) GetScopes() []string { return nil } -// Provider credential token grant configuration. -// When present, the credential is obtained dynamically via OAuth2 grant when needed. +type ProviderCredentialTokenGrantSubjectToken struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Source for the token exchange subject token. Phase one supports + // "provider_credential". + Source string `protobuf:"bytes,1,opt,name=source,proto3" json:"source,omitempty"` + // Provider credential key that stores the subject token. + Credential string `protobuf:"bytes,2,opt,name=credential,proto3" json:"credential,omitempty"` + // OAuth2 subject_token_type. If omitted, OpenShell uses + // urn:ietf:params:oauth:token-type:access_token. + SubjectTokenType string `protobuf:"bytes,3,opt,name=subject_token_type,json=subjectTokenType,proto3" json:"subject_token_type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderCredentialTokenGrantSubjectToken) Reset() { + *x = ProviderCredentialTokenGrantSubjectToken{} + mi := &file_openshell_proto_msgTypes[73] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderCredentialTokenGrantSubjectToken) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderCredentialTokenGrantSubjectToken) ProtoMessage() {} + +func (x *ProviderCredentialTokenGrantSubjectToken) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[73] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProviderCredentialTokenGrantSubjectToken.ProtoReflect.Descriptor instead. +func (*ProviderCredentialTokenGrantSubjectToken) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{73} +} + +func (x *ProviderCredentialTokenGrantSubjectToken) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *ProviderCredentialTokenGrantSubjectToken) GetCredential() string { + if x != nil { + return x.Credential + } + return "" +} + +func (x *ProviderCredentialTokenGrantSubjectToken) GetSubjectTokenType() string { + if x != nil { + return x.SubjectTokenType + } + return "" +} + type ProviderCredentialTokenGrant struct { state protoimpl.MessageState `protogen:"open.v1"` // OAuth2 token endpoint URL (e.g., https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token) @@ -5301,13 +5415,21 @@ type ProviderCredentialTokenGrant struct { // Optional: OAuth2 client_assertion_type value. If omitted, OpenShell uses // urn:ietf:params:oauth:client-assertion-type:jwt-bearer. ClientAssertionType string `protobuf:"bytes,7,opt,name=client_assertion_type,json=clientAssertionType,proto3" json:"client_assertion_type,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Grant type. If omitted/unspecified, OpenShell treats this as client_credentials + // for backwards compatibility. + GrantType ProviderCredentialTokenGrantType `protobuf:"varint,8,opt,name=grant_type,json=grantType,proto3,enum=openshell.v1.ProviderCredentialTokenGrantType" json:"grant_type,omitempty"` + // Subject token metadata for token_exchange grants. + SubjectToken *ProviderCredentialTokenGrantSubjectToken `protobuf:"bytes,9,opt,name=subject_token,json=subjectToken,proto3" json:"subject_token,omitempty"` + // OAuth2 requested_token_type. If omitted for token_exchange, OpenShell uses + // urn:ietf:params:oauth:token-type:access_token. + RequestedTokenType string `protobuf:"bytes,10,opt,name=requested_token_type,json=requestedTokenType,proto3" json:"requested_token_type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ProviderCredentialTokenGrant) Reset() { *x = ProviderCredentialTokenGrant{} - mi := &file_openshell_proto_msgTypes[73] + mi := &file_openshell_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5319,7 +5441,7 @@ func (x *ProviderCredentialTokenGrant) String() string { func (*ProviderCredentialTokenGrant) ProtoMessage() {} func (x *ProviderCredentialTokenGrant) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[73] + mi := &file_openshell_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5332,7 +5454,7 @@ func (x *ProviderCredentialTokenGrant) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderCredentialTokenGrant.ProtoReflect.Descriptor instead. func (*ProviderCredentialTokenGrant) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{73} + return file_openshell_proto_rawDescGZIP(), []int{74} } func (x *ProviderCredentialTokenGrant) GetTokenEndpoint() string { @@ -5384,6 +5506,27 @@ func (x *ProviderCredentialTokenGrant) GetClientAssertionType() string { return "" } +func (x *ProviderCredentialTokenGrant) GetGrantType() ProviderCredentialTokenGrantType { + if x != nil { + return x.GrantType + } + return ProviderCredentialTokenGrantType_PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_UNSPECIFIED +} + +func (x *ProviderCredentialTokenGrant) GetSubjectToken() *ProviderCredentialTokenGrantSubjectToken { + if x != nil { + return x.SubjectToken + } + return nil +} + +func (x *ProviderCredentialTokenGrant) GetRequestedTokenType() string { + if x != nil { + return x.RequestedTokenType + } + return "" +} + // Provider credential declaration. type ProviderProfileCredential struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -5403,7 +5546,7 @@ type ProviderProfileCredential struct { func (x *ProviderProfileCredential) Reset() { *x = ProviderProfileCredential{} - mi := &file_openshell_proto_msgTypes[74] + mi := &file_openshell_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5415,7 +5558,7 @@ func (x *ProviderProfileCredential) String() string { func (*ProviderProfileCredential) ProtoMessage() {} func (x *ProviderProfileCredential) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[74] + mi := &file_openshell_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5428,7 +5571,7 @@ func (x *ProviderProfileCredential) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderProfileCredential.ProtoReflect.Descriptor instead. func (*ProviderProfileCredential) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{74} + return file_openshell_proto_rawDescGZIP(), []int{75} } func (x *ProviderProfileCredential) GetName() string { @@ -5513,7 +5656,7 @@ type ProviderCredentialRefreshMaterial struct { func (x *ProviderCredentialRefreshMaterial) Reset() { *x = ProviderCredentialRefreshMaterial{} - mi := &file_openshell_proto_msgTypes[75] + mi := &file_openshell_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5525,7 +5668,7 @@ func (x *ProviderCredentialRefreshMaterial) String() string { func (*ProviderCredentialRefreshMaterial) ProtoMessage() {} func (x *ProviderCredentialRefreshMaterial) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[75] + mi := &file_openshell_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5538,7 +5681,7 @@ func (x *ProviderCredentialRefreshMaterial) ProtoReflect() protoreflect.Message // Deprecated: Use ProviderCredentialRefreshMaterial.ProtoReflect.Descriptor instead. func (*ProviderCredentialRefreshMaterial) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{75} + return file_openshell_proto_rawDescGZIP(), []int{76} } func (x *ProviderCredentialRefreshMaterial) GetName() string { @@ -5583,7 +5726,7 @@ type ProviderCredentialRefreshOutput struct { func (x *ProviderCredentialRefreshOutput) Reset() { *x = ProviderCredentialRefreshOutput{} - mi := &file_openshell_proto_msgTypes[76] + mi := &file_openshell_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5595,7 +5738,7 @@ func (x *ProviderCredentialRefreshOutput) String() string { func (*ProviderCredentialRefreshOutput) ProtoMessage() {} func (x *ProviderCredentialRefreshOutput) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[76] + mi := &file_openshell_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5608,7 +5751,7 @@ func (x *ProviderCredentialRefreshOutput) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderCredentialRefreshOutput.ProtoReflect.Descriptor instead. func (*ProviderCredentialRefreshOutput) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{76} + return file_openshell_proto_rawDescGZIP(), []int{77} } func (x *ProviderCredentialRefreshOutput) GetOutput() string { @@ -5640,7 +5783,7 @@ type ProviderCredentialRefresh struct { func (x *ProviderCredentialRefresh) Reset() { *x = ProviderCredentialRefresh{} - mi := &file_openshell_proto_msgTypes[77] + mi := &file_openshell_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5652,7 +5795,7 @@ func (x *ProviderCredentialRefresh) String() string { func (*ProviderCredentialRefresh) ProtoMessage() {} func (x *ProviderCredentialRefresh) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[77] + mi := &file_openshell_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5665,7 +5808,7 @@ func (x *ProviderCredentialRefresh) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderCredentialRefresh.ProtoReflect.Descriptor instead. func (*ProviderCredentialRefresh) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{77} + return file_openshell_proto_rawDescGZIP(), []int{78} } func (x *ProviderCredentialRefresh) GetStrategy() ProviderCredentialRefreshStrategy { @@ -5734,7 +5877,7 @@ type ProviderCredentialRefreshStatus struct { func (x *ProviderCredentialRefreshStatus) Reset() { *x = ProviderCredentialRefreshStatus{} - mi := &file_openshell_proto_msgTypes[78] + mi := &file_openshell_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5746,7 +5889,7 @@ func (x *ProviderCredentialRefreshStatus) String() string { func (*ProviderCredentialRefreshStatus) ProtoMessage() {} func (x *ProviderCredentialRefreshStatus) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[78] + mi := &file_openshell_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5759,7 +5902,7 @@ func (x *ProviderCredentialRefreshStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderCredentialRefreshStatus.ProtoReflect.Descriptor instead. func (*ProviderCredentialRefreshStatus) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{78} + return file_openshell_proto_rawDescGZIP(), []int{79} } func (x *ProviderCredentialRefreshStatus) GetProviderName() string { @@ -5836,7 +5979,7 @@ type ProviderProfileDiscovery struct { func (x *ProviderProfileDiscovery) Reset() { *x = ProviderProfileDiscovery{} - mi := &file_openshell_proto_msgTypes[79] + mi := &file_openshell_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5848,7 +5991,7 @@ func (x *ProviderProfileDiscovery) String() string { func (*ProviderProfileDiscovery) ProtoMessage() {} func (x *ProviderProfileDiscovery) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[79] + mi := &file_openshell_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5861,7 +6004,7 @@ func (x *ProviderProfileDiscovery) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderProfileDiscovery.ProtoReflect.Descriptor instead. func (*ProviderProfileDiscovery) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{79} + return file_openshell_proto_rawDescGZIP(), []int{80} } func (x *ProviderProfileDiscovery) GetCredentials() []string { @@ -5918,7 +6061,7 @@ type StoredProviderCredentialRefreshState struct { func (x *StoredProviderCredentialRefreshState) Reset() { *x = StoredProviderCredentialRefreshState{} - mi := &file_openshell_proto_msgTypes[80] + mi := &file_openshell_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5930,7 +6073,7 @@ func (x *StoredProviderCredentialRefreshState) String() string { func (*StoredProviderCredentialRefreshState) ProtoMessage() {} func (x *StoredProviderCredentialRefreshState) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[80] + mi := &file_openshell_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5943,7 +6086,7 @@ func (x *StoredProviderCredentialRefreshState) ProtoReflect() protoreflect.Messa // Deprecated: Use StoredProviderCredentialRefreshState.ProtoReflect.Descriptor instead. func (*StoredProviderCredentialRefreshState) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{80} + return file_openshell_proto_rawDescGZIP(), []int{81} } func (x *StoredProviderCredentialRefreshState) GetMetadata() *datamodelv1.ObjectMeta { @@ -6098,7 +6241,7 @@ type StoredRefreshMaterialDeletion struct { func (x *StoredRefreshMaterialDeletion) Reset() { *x = StoredRefreshMaterialDeletion{} - mi := &file_openshell_proto_msgTypes[81] + mi := &file_openshell_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6110,7 +6253,7 @@ func (x *StoredRefreshMaterialDeletion) String() string { func (*StoredRefreshMaterialDeletion) ProtoMessage() {} func (x *StoredRefreshMaterialDeletion) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[81] + mi := &file_openshell_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6123,7 +6266,7 @@ func (x *StoredRefreshMaterialDeletion) ProtoReflect() protoreflect.Message { // Deprecated: Use StoredRefreshMaterialDeletion.ProtoReflect.Descriptor instead. func (*StoredRefreshMaterialDeletion) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{81} + return file_openshell_proto_rawDescGZIP(), []int{82} } func (x *StoredRefreshMaterialDeletion) GetMaterialKey() string { @@ -6152,7 +6295,7 @@ type GetProviderRefreshStatusRequest struct { func (x *GetProviderRefreshStatusRequest) Reset() { *x = GetProviderRefreshStatusRequest{} - mi := &file_openshell_proto_msgTypes[82] + mi := &file_openshell_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6164,7 +6307,7 @@ func (x *GetProviderRefreshStatusRequest) String() string { func (*GetProviderRefreshStatusRequest) ProtoMessage() {} func (x *GetProviderRefreshStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[82] + mi := &file_openshell_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6177,7 +6320,7 @@ func (x *GetProviderRefreshStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetProviderRefreshStatusRequest.ProtoReflect.Descriptor instead. func (*GetProviderRefreshStatusRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{82} + return file_openshell_proto_rawDescGZIP(), []int{83} } func (x *GetProviderRefreshStatusRequest) GetProvider() string { @@ -6210,7 +6353,7 @@ type GetProviderRefreshStatusResponse struct { func (x *GetProviderRefreshStatusResponse) Reset() { *x = GetProviderRefreshStatusResponse{} - mi := &file_openshell_proto_msgTypes[83] + mi := &file_openshell_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6222,7 +6365,7 @@ func (x *GetProviderRefreshStatusResponse) String() string { func (*GetProviderRefreshStatusResponse) ProtoMessage() {} func (x *GetProviderRefreshStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[83] + mi := &file_openshell_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6235,7 +6378,7 @@ func (x *GetProviderRefreshStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetProviderRefreshStatusResponse.ProtoReflect.Descriptor instead. func (*GetProviderRefreshStatusResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{83} + return file_openshell_proto_rawDescGZIP(), []int{84} } func (x *GetProviderRefreshStatusResponse) GetCredentials() []*ProviderCredentialRefreshStatus { @@ -6264,7 +6407,7 @@ type ConfigureProviderRefreshRequest struct { func (x *ConfigureProviderRefreshRequest) Reset() { *x = ConfigureProviderRefreshRequest{} - mi := &file_openshell_proto_msgTypes[84] + mi := &file_openshell_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6276,7 +6419,7 @@ func (x *ConfigureProviderRefreshRequest) String() string { func (*ConfigureProviderRefreshRequest) ProtoMessage() {} func (x *ConfigureProviderRefreshRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[84] + mi := &file_openshell_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6289,7 +6432,7 @@ func (x *ConfigureProviderRefreshRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigureProviderRefreshRequest.ProtoReflect.Descriptor instead. func (*ConfigureProviderRefreshRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{84} + return file_openshell_proto_rawDescGZIP(), []int{85} } func (x *ConfigureProviderRefreshRequest) GetProvider() string { @@ -6350,7 +6493,7 @@ type ConfigureProviderRefreshResponse struct { func (x *ConfigureProviderRefreshResponse) Reset() { *x = ConfigureProviderRefreshResponse{} - mi := &file_openshell_proto_msgTypes[85] + mi := &file_openshell_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6362,7 +6505,7 @@ func (x *ConfigureProviderRefreshResponse) String() string { func (*ConfigureProviderRefreshResponse) ProtoMessage() {} func (x *ConfigureProviderRefreshResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[85] + mi := &file_openshell_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6375,7 +6518,7 @@ func (x *ConfigureProviderRefreshResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigureProviderRefreshResponse.ProtoReflect.Descriptor instead. func (*ConfigureProviderRefreshResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{85} + return file_openshell_proto_rawDescGZIP(), []int{86} } func (x *ConfigureProviderRefreshResponse) GetStatus() *ProviderCredentialRefreshStatus { @@ -6397,7 +6540,7 @@ type RotateProviderCredentialRequest struct { func (x *RotateProviderCredentialRequest) Reset() { *x = RotateProviderCredentialRequest{} - mi := &file_openshell_proto_msgTypes[86] + mi := &file_openshell_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6409,7 +6552,7 @@ func (x *RotateProviderCredentialRequest) String() string { func (*RotateProviderCredentialRequest) ProtoMessage() {} func (x *RotateProviderCredentialRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[86] + mi := &file_openshell_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6422,7 +6565,7 @@ func (x *RotateProviderCredentialRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RotateProviderCredentialRequest.ProtoReflect.Descriptor instead. func (*RotateProviderCredentialRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{86} + return file_openshell_proto_rawDescGZIP(), []int{87} } func (x *RotateProviderCredentialRequest) GetProvider() string { @@ -6455,7 +6598,7 @@ type RotateProviderCredentialResponse struct { func (x *RotateProviderCredentialResponse) Reset() { *x = RotateProviderCredentialResponse{} - mi := &file_openshell_proto_msgTypes[87] + mi := &file_openshell_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6467,7 +6610,7 @@ func (x *RotateProviderCredentialResponse) String() string { func (*RotateProviderCredentialResponse) ProtoMessage() {} func (x *RotateProviderCredentialResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[87] + mi := &file_openshell_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6480,7 +6623,7 @@ func (x *RotateProviderCredentialResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RotateProviderCredentialResponse.ProtoReflect.Descriptor instead. func (*RotateProviderCredentialResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{87} + return file_openshell_proto_rawDescGZIP(), []int{88} } func (x *RotateProviderCredentialResponse) GetStatus() *ProviderCredentialRefreshStatus { @@ -6502,7 +6645,7 @@ type DeleteProviderRefreshRequest struct { func (x *DeleteProviderRefreshRequest) Reset() { *x = DeleteProviderRefreshRequest{} - mi := &file_openshell_proto_msgTypes[88] + mi := &file_openshell_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6514,7 +6657,7 @@ func (x *DeleteProviderRefreshRequest) String() string { func (*DeleteProviderRefreshRequest) ProtoMessage() {} func (x *DeleteProviderRefreshRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[88] + mi := &file_openshell_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6527,7 +6670,7 @@ func (x *DeleteProviderRefreshRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteProviderRefreshRequest.ProtoReflect.Descriptor instead. func (*DeleteProviderRefreshRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{88} + return file_openshell_proto_rawDescGZIP(), []int{89} } func (x *DeleteProviderRefreshRequest) GetProvider() string { @@ -6560,7 +6703,7 @@ type DeleteProviderRefreshResponse struct { func (x *DeleteProviderRefreshResponse) Reset() { *x = DeleteProviderRefreshResponse{} - mi := &file_openshell_proto_msgTypes[89] + mi := &file_openshell_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6572,7 +6715,7 @@ func (x *DeleteProviderRefreshResponse) String() string { func (*DeleteProviderRefreshResponse) ProtoMessage() {} func (x *DeleteProviderRefreshResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[89] + mi := &file_openshell_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6585,7 +6728,7 @@ func (x *DeleteProviderRefreshResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteProviderRefreshResponse.ProtoReflect.Descriptor instead. func (*DeleteProviderRefreshResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{89} + return file_openshell_proto_rawDescGZIP(), []int{90} } func (x *DeleteProviderRefreshResponse) GetDeleted() bool { @@ -6625,7 +6768,7 @@ type ProviderProfile struct { func (x *ProviderProfile) Reset() { *x = ProviderProfile{} - mi := &file_openshell_proto_msgTypes[90] + mi := &file_openshell_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6637,7 +6780,7 @@ func (x *ProviderProfile) String() string { func (*ProviderProfile) ProtoMessage() {} func (x *ProviderProfile) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[90] + mi := &file_openshell_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6650,7 +6793,7 @@ func (x *ProviderProfile) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderProfile.ProtoReflect.Descriptor instead. func (*ProviderProfile) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{90} + return file_openshell_proto_rawDescGZIP(), []int{91} } func (x *ProviderProfile) GetId() string { @@ -6755,7 +6898,7 @@ type StoredProviderProfile struct { func (x *StoredProviderProfile) Reset() { *x = StoredProviderProfile{} - mi := &file_openshell_proto_msgTypes[91] + mi := &file_openshell_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6767,7 +6910,7 @@ func (x *StoredProviderProfile) String() string { func (*StoredProviderProfile) ProtoMessage() {} func (x *StoredProviderProfile) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[91] + mi := &file_openshell_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6780,7 +6923,7 @@ func (x *StoredProviderProfile) ProtoReflect() protoreflect.Message { // Deprecated: Use StoredProviderProfile.ProtoReflect.Descriptor instead. func (*StoredProviderProfile) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{91} + return file_openshell_proto_rawDescGZIP(), []int{92} } func (x *StoredProviderProfile) GetMetadata() *datamodelv1.ObjectMeta { @@ -6807,7 +6950,7 @@ type ProviderProfileResponse struct { func (x *ProviderProfileResponse) Reset() { *x = ProviderProfileResponse{} - mi := &file_openshell_proto_msgTypes[92] + mi := &file_openshell_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6819,7 +6962,7 @@ func (x *ProviderProfileResponse) String() string { func (*ProviderProfileResponse) ProtoMessage() {} func (x *ProviderProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[92] + mi := &file_openshell_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6832,7 +6975,7 @@ func (x *ProviderProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderProfileResponse.ProtoReflect.Descriptor instead. func (*ProviderProfileResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{92} + return file_openshell_proto_rawDescGZIP(), []int{93} } func (x *ProviderProfileResponse) GetProfile() *ProviderProfile { @@ -6852,7 +6995,7 @@ type ListProviderProfilesResponse struct { func (x *ListProviderProfilesResponse) Reset() { *x = ListProviderProfilesResponse{} - mi := &file_openshell_proto_msgTypes[93] + mi := &file_openshell_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6864,7 +7007,7 @@ func (x *ListProviderProfilesResponse) String() string { func (*ListProviderProfilesResponse) ProtoMessage() {} func (x *ListProviderProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[93] + mi := &file_openshell_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6877,7 +7020,7 @@ func (x *ListProviderProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProviderProfilesResponse.ProtoReflect.Descriptor instead. func (*ListProviderProfilesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{93} + return file_openshell_proto_rawDescGZIP(), []int{94} } func (x *ListProviderProfilesResponse) GetProfiles() []*ProviderProfile { @@ -6900,7 +7043,7 @@ type ImportProviderProfilesRequest struct { func (x *ImportProviderProfilesRequest) Reset() { *x = ImportProviderProfilesRequest{} - mi := &file_openshell_proto_msgTypes[94] + mi := &file_openshell_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6912,7 +7055,7 @@ func (x *ImportProviderProfilesRequest) String() string { func (*ImportProviderProfilesRequest) ProtoMessage() {} func (x *ImportProviderProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[94] + mi := &file_openshell_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6925,7 +7068,7 @@ func (x *ImportProviderProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ImportProviderProfilesRequest.ProtoReflect.Descriptor instead. func (*ImportProviderProfilesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{94} + return file_openshell_proto_rawDescGZIP(), []int{95} } func (x *ImportProviderProfilesRequest) GetProfiles() []*ProviderProfileImportItem { @@ -6954,7 +7097,7 @@ type ImportProviderProfilesResponse struct { func (x *ImportProviderProfilesResponse) Reset() { *x = ImportProviderProfilesResponse{} - mi := &file_openshell_proto_msgTypes[95] + mi := &file_openshell_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6966,7 +7109,7 @@ func (x *ImportProviderProfilesResponse) String() string { func (*ImportProviderProfilesResponse) ProtoMessage() {} func (x *ImportProviderProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[95] + mi := &file_openshell_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6979,7 +7122,7 @@ func (x *ImportProviderProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ImportProviderProfilesResponse.ProtoReflect.Descriptor instead. func (*ImportProviderProfilesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{95} + return file_openshell_proto_rawDescGZIP(), []int{96} } func (x *ImportProviderProfilesResponse) GetDiagnostics() []*ProviderProfileDiagnostic { @@ -7023,7 +7166,7 @@ type UpdateProviderProfilesRequest struct { func (x *UpdateProviderProfilesRequest) Reset() { *x = UpdateProviderProfilesRequest{} - mi := &file_openshell_proto_msgTypes[96] + mi := &file_openshell_proto_msgTypes[97] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7035,7 +7178,7 @@ func (x *UpdateProviderProfilesRequest) String() string { func (*UpdateProviderProfilesRequest) ProtoMessage() {} func (x *UpdateProviderProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[96] + mi := &file_openshell_proto_msgTypes[97] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7048,7 +7191,7 @@ func (x *UpdateProviderProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateProviderProfilesRequest.ProtoReflect.Descriptor instead. func (*UpdateProviderProfilesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{96} + return file_openshell_proto_rawDescGZIP(), []int{97} } func (x *UpdateProviderProfilesRequest) GetProfile() *ProviderProfileImportItem { @@ -7091,7 +7234,7 @@ type UpdateProviderProfilesResponse struct { func (x *UpdateProviderProfilesResponse) Reset() { *x = UpdateProviderProfilesResponse{} - mi := &file_openshell_proto_msgTypes[97] + mi := &file_openshell_proto_msgTypes[98] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7103,7 +7246,7 @@ func (x *UpdateProviderProfilesResponse) String() string { func (*UpdateProviderProfilesResponse) ProtoMessage() {} func (x *UpdateProviderProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[97] + mi := &file_openshell_proto_msgTypes[98] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7116,7 +7259,7 @@ func (x *UpdateProviderProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateProviderProfilesResponse.ProtoReflect.Descriptor instead. func (*UpdateProviderProfilesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{97} + return file_openshell_proto_rawDescGZIP(), []int{98} } func (x *UpdateProviderProfilesResponse) GetDiagnostics() []*ProviderProfileDiagnostic { @@ -7153,7 +7296,7 @@ type LintProviderProfilesRequest struct { func (x *LintProviderProfilesRequest) Reset() { *x = LintProviderProfilesRequest{} - mi := &file_openshell_proto_msgTypes[98] + mi := &file_openshell_proto_msgTypes[99] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7165,7 +7308,7 @@ func (x *LintProviderProfilesRequest) String() string { func (*LintProviderProfilesRequest) ProtoMessage() {} func (x *LintProviderProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[98] + mi := &file_openshell_proto_msgTypes[99] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7178,7 +7321,7 @@ func (x *LintProviderProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LintProviderProfilesRequest.ProtoReflect.Descriptor instead. func (*LintProviderProfilesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{98} + return file_openshell_proto_rawDescGZIP(), []int{99} } func (x *LintProviderProfilesRequest) GetProfiles() []*ProviderProfileImportItem { @@ -7206,7 +7349,7 @@ type LintProviderProfilesResponse struct { func (x *LintProviderProfilesResponse) Reset() { *x = LintProviderProfilesResponse{} - mi := &file_openshell_proto_msgTypes[99] + mi := &file_openshell_proto_msgTypes[100] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7218,7 +7361,7 @@ func (x *LintProviderProfilesResponse) String() string { func (*LintProviderProfilesResponse) ProtoMessage() {} func (x *LintProviderProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[99] + mi := &file_openshell_proto_msgTypes[100] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7231,7 +7374,7 @@ func (x *LintProviderProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LintProviderProfilesResponse.ProtoReflect.Descriptor instead. func (*LintProviderProfilesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{99} + return file_openshell_proto_rawDescGZIP(), []int{100} } func (x *LintProviderProfilesResponse) GetDiagnostics() []*ProviderProfileDiagnostic { @@ -7258,7 +7401,7 @@ type DeleteProviderResponse struct { func (x *DeleteProviderResponse) Reset() { *x = DeleteProviderResponse{} - mi := &file_openshell_proto_msgTypes[100] + mi := &file_openshell_proto_msgTypes[101] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7270,7 +7413,7 @@ func (x *DeleteProviderResponse) String() string { func (*DeleteProviderResponse) ProtoMessage() {} func (x *DeleteProviderResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[100] + mi := &file_openshell_proto_msgTypes[101] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7283,7 +7426,7 @@ func (x *DeleteProviderResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteProviderResponse.ProtoReflect.Descriptor instead. func (*DeleteProviderResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{100} + return file_openshell_proto_rawDescGZIP(), []int{101} } func (x *DeleteProviderResponse) GetDeleted() bool { @@ -7306,7 +7449,7 @@ type DeleteProviderProfileRequest struct { func (x *DeleteProviderProfileRequest) Reset() { *x = DeleteProviderProfileRequest{} - mi := &file_openshell_proto_msgTypes[101] + mi := &file_openshell_proto_msgTypes[102] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7318,7 +7461,7 @@ func (x *DeleteProviderProfileRequest) String() string { func (*DeleteProviderProfileRequest) ProtoMessage() {} func (x *DeleteProviderProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[101] + mi := &file_openshell_proto_msgTypes[102] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7331,7 +7474,7 @@ func (x *DeleteProviderProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteProviderProfileRequest.ProtoReflect.Descriptor instead. func (*DeleteProviderProfileRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{101} + return file_openshell_proto_rawDescGZIP(), []int{102} } func (x *DeleteProviderProfileRequest) GetId() string { @@ -7358,7 +7501,7 @@ type DeleteProviderProfileResponse struct { func (x *DeleteProviderProfileResponse) Reset() { *x = DeleteProviderProfileResponse{} - mi := &file_openshell_proto_msgTypes[102] + mi := &file_openshell_proto_msgTypes[103] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7370,7 +7513,7 @@ func (x *DeleteProviderProfileResponse) String() string { func (*DeleteProviderProfileResponse) ProtoMessage() {} func (x *DeleteProviderProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[102] + mi := &file_openshell_proto_msgTypes[103] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7383,7 +7526,7 @@ func (x *DeleteProviderProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteProviderProfileResponse.ProtoReflect.Descriptor instead. func (*DeleteProviderProfileResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{102} + return file_openshell_proto_rawDescGZIP(), []int{103} } func (x *DeleteProviderProfileResponse) GetDeleted() bool { @@ -7408,7 +7551,7 @@ type GetSandboxProviderEnvironmentRequest struct { func (x *GetSandboxProviderEnvironmentRequest) Reset() { *x = GetSandboxProviderEnvironmentRequest{} - mi := &file_openshell_proto_msgTypes[103] + mi := &file_openshell_proto_msgTypes[104] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7420,7 +7563,7 @@ func (x *GetSandboxProviderEnvironmentRequest) String() string { func (*GetSandboxProviderEnvironmentRequest) ProtoMessage() {} func (x *GetSandboxProviderEnvironmentRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[103] + mi := &file_openshell_proto_msgTypes[104] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7433,7 +7576,7 @@ func (x *GetSandboxProviderEnvironmentRequest) ProtoReflect() protoreflect.Messa // Deprecated: Use GetSandboxProviderEnvironmentRequest.ProtoReflect.Descriptor instead. func (*GetSandboxProviderEnvironmentRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{103} + return file_openshell_proto_rawDescGZIP(), []int{104} } func (x *GetSandboxProviderEnvironmentRequest) GetSandboxId() string { @@ -7462,7 +7605,7 @@ type StaticCredentialEndpointBinding struct { func (x *StaticCredentialEndpointBinding) Reset() { *x = StaticCredentialEndpointBinding{} - mi := &file_openshell_proto_msgTypes[104] + mi := &file_openshell_proto_msgTypes[105] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7474,7 +7617,7 @@ func (x *StaticCredentialEndpointBinding) String() string { func (*StaticCredentialEndpointBinding) ProtoMessage() {} func (x *StaticCredentialEndpointBinding) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[104] + mi := &file_openshell_proto_msgTypes[105] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7487,7 +7630,7 @@ func (x *StaticCredentialEndpointBinding) ProtoReflect() protoreflect.Message { // Deprecated: Use StaticCredentialEndpointBinding.ProtoReflect.Descriptor instead. func (*StaticCredentialEndpointBinding) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{104} + return file_openshell_proto_rawDescGZIP(), []int{105} } func (x *StaticCredentialEndpointBinding) GetHost() string { @@ -7531,7 +7674,7 @@ type StaticCredentialBinding struct { func (x *StaticCredentialBinding) Reset() { *x = StaticCredentialBinding{} - mi := &file_openshell_proto_msgTypes[105] + mi := &file_openshell_proto_msgTypes[106] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7543,7 +7686,7 @@ func (x *StaticCredentialBinding) String() string { func (*StaticCredentialBinding) ProtoMessage() {} func (x *StaticCredentialBinding) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[105] + mi := &file_openshell_proto_msgTypes[106] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7556,7 +7699,7 @@ func (x *StaticCredentialBinding) ProtoReflect() protoreflect.Message { // Deprecated: Use StaticCredentialBinding.ProtoReflect.Descriptor instead. func (*StaticCredentialBinding) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{105} + return file_openshell_proto_rawDescGZIP(), []int{106} } func (x *StaticCredentialBinding) GetEndpoints() []*StaticCredentialEndpointBinding { @@ -7607,7 +7750,7 @@ type GetSandboxProviderEnvironmentResponse struct { func (x *GetSandboxProviderEnvironmentResponse) Reset() { *x = GetSandboxProviderEnvironmentResponse{} - mi := &file_openshell_proto_msgTypes[106] + mi := &file_openshell_proto_msgTypes[107] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7619,7 +7762,7 @@ func (x *GetSandboxProviderEnvironmentResponse) String() string { func (*GetSandboxProviderEnvironmentResponse) ProtoMessage() {} func (x *GetSandboxProviderEnvironmentResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[106] + mi := &file_openshell_proto_msgTypes[107] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7632,7 +7775,7 @@ func (x *GetSandboxProviderEnvironmentResponse) ProtoReflect() protoreflect.Mess // Deprecated: Use GetSandboxProviderEnvironmentResponse.ProtoReflect.Descriptor instead. func (*GetSandboxProviderEnvironmentResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{106} + return file_openshell_proto_rawDescGZIP(), []int{107} } func (x *GetSandboxProviderEnvironmentResponse) GetEnvironment() map[string]string { @@ -7677,6 +7820,139 @@ func (x *GetSandboxProviderEnvironmentResponse) GetNonSecretEnvironmentKeys() [] return nil } +type ExchangeProviderSubjectTokenRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The sandbox ID. Must match the authenticated sandbox principal. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Attached provider record holding the configured subject token credential. + Provider string `protobuf:"bytes,2,opt,name=provider,proto3" json:"provider,omitempty"` + // Provider profile credential that declares the token_exchange grant. + CredentialKey string `protobuf:"bytes,3,opt,name=credential_key,json=credentialKey,proto3" json:"credential_key,omitempty"` + // Supervisor JWT-SVID. The gateway verifies this and uses its `sub` claim + // as the requested audience for the intermediate token. + SupervisorJwtSvid string `protobuf:"bytes,4,opt,name=supervisor_jwt_svid,json=supervisorJwtSvid,proto3" json:"supervisor_jwt_svid,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExchangeProviderSubjectTokenRequest) Reset() { + *x = ExchangeProviderSubjectTokenRequest{} + mi := &file_openshell_proto_msgTypes[108] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExchangeProviderSubjectTokenRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExchangeProviderSubjectTokenRequest) ProtoMessage() {} + +func (x *ExchangeProviderSubjectTokenRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[108] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExchangeProviderSubjectTokenRequest.ProtoReflect.Descriptor instead. +func (*ExchangeProviderSubjectTokenRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{108} +} + +func (x *ExchangeProviderSubjectTokenRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *ExchangeProviderSubjectTokenRequest) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *ExchangeProviderSubjectTokenRequest) GetCredentialKey() string { + if x != nil { + return x.CredentialKey + } + return "" +} + +func (x *ExchangeProviderSubjectTokenRequest) GetSupervisorJwtSvid() string { + if x != nil { + return x.SupervisorJwtSvid + } + return "" +} + +type ExchangeProviderSubjectTokenResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + AccessToken string `protobuf:"bytes,1,opt,name=access_token,json=accessToken,proto3" json:"access_token,omitempty"` + ExpiresIn int64 `protobuf:"varint,2,opt,name=expires_in,json=expiresIn,proto3" json:"expires_in,omitempty"` + TokenType string `protobuf:"bytes,3,opt,name=token_type,json=tokenType,proto3" json:"token_type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExchangeProviderSubjectTokenResponse) Reset() { + *x = ExchangeProviderSubjectTokenResponse{} + mi := &file_openshell_proto_msgTypes[109] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExchangeProviderSubjectTokenResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExchangeProviderSubjectTokenResponse) ProtoMessage() {} + +func (x *ExchangeProviderSubjectTokenResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[109] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExchangeProviderSubjectTokenResponse.ProtoReflect.Descriptor instead. +func (*ExchangeProviderSubjectTokenResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{109} +} + +func (x *ExchangeProviderSubjectTokenResponse) GetAccessToken() string { + if x != nil { + return x.AccessToken + } + return "" +} + +func (x *ExchangeProviderSubjectTokenResponse) GetExpiresIn() int64 { + if x != nil { + return x.ExpiresIn + } + return 0 +} + +func (x *ExchangeProviderSubjectTokenResponse) GetTokenType() string { + if x != nil { + return x.TokenType + } + return "" +} + // Update sandbox policy request. type UpdateConfigRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -7724,7 +8000,7 @@ type UpdateConfigRequest struct { func (x *UpdateConfigRequest) Reset() { *x = UpdateConfigRequest{} - mi := &file_openshell_proto_msgTypes[107] + mi := &file_openshell_proto_msgTypes[110] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7736,7 +8012,7 @@ func (x *UpdateConfigRequest) String() string { func (*UpdateConfigRequest) ProtoMessage() {} func (x *UpdateConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[107] + mi := &file_openshell_proto_msgTypes[110] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7749,7 +8025,7 @@ func (x *UpdateConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateConfigRequest.ProtoReflect.Descriptor instead. func (*UpdateConfigRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{107} + return file_openshell_proto_rawDescGZIP(), []int{110} } func (x *UpdateConfigRequest) GetName() string { @@ -7839,7 +8115,7 @@ type PolicyMergeOperation struct { func (x *PolicyMergeOperation) Reset() { *x = PolicyMergeOperation{} - mi := &file_openshell_proto_msgTypes[108] + mi := &file_openshell_proto_msgTypes[111] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7851,7 +8127,7 @@ func (x *PolicyMergeOperation) String() string { func (*PolicyMergeOperation) ProtoMessage() {} func (x *PolicyMergeOperation) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[108] + mi := &file_openshell_proto_msgTypes[111] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7864,7 +8140,7 @@ func (x *PolicyMergeOperation) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyMergeOperation.ProtoReflect.Descriptor instead. func (*PolicyMergeOperation) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{108} + return file_openshell_proto_rawDescGZIP(), []int{111} } func (x *PolicyMergeOperation) GetOperation() isPolicyMergeOperation_Operation { @@ -7978,7 +8254,7 @@ type AddNetworkRule struct { func (x *AddNetworkRule) Reset() { *x = AddNetworkRule{} - mi := &file_openshell_proto_msgTypes[109] + mi := &file_openshell_proto_msgTypes[112] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7990,7 +8266,7 @@ func (x *AddNetworkRule) String() string { func (*AddNetworkRule) ProtoMessage() {} func (x *AddNetworkRule) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[109] + mi := &file_openshell_proto_msgTypes[112] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8003,7 +8279,7 @@ func (x *AddNetworkRule) ProtoReflect() protoreflect.Message { // Deprecated: Use AddNetworkRule.ProtoReflect.Descriptor instead. func (*AddNetworkRule) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{109} + return file_openshell_proto_rawDescGZIP(), []int{112} } func (x *AddNetworkRule) GetRuleName() string { @@ -8031,7 +8307,7 @@ type RemoveNetworkEndpoint struct { func (x *RemoveNetworkEndpoint) Reset() { *x = RemoveNetworkEndpoint{} - mi := &file_openshell_proto_msgTypes[110] + mi := &file_openshell_proto_msgTypes[113] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8043,7 +8319,7 @@ func (x *RemoveNetworkEndpoint) String() string { func (*RemoveNetworkEndpoint) ProtoMessage() {} func (x *RemoveNetworkEndpoint) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[110] + mi := &file_openshell_proto_msgTypes[113] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8056,7 +8332,7 @@ func (x *RemoveNetworkEndpoint) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveNetworkEndpoint.ProtoReflect.Descriptor instead. func (*RemoveNetworkEndpoint) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{110} + return file_openshell_proto_rawDescGZIP(), []int{113} } func (x *RemoveNetworkEndpoint) GetRuleName() string { @@ -8089,7 +8365,7 @@ type RemoveNetworkRule struct { func (x *RemoveNetworkRule) Reset() { *x = RemoveNetworkRule{} - mi := &file_openshell_proto_msgTypes[111] + mi := &file_openshell_proto_msgTypes[114] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8101,7 +8377,7 @@ func (x *RemoveNetworkRule) String() string { func (*RemoveNetworkRule) ProtoMessage() {} func (x *RemoveNetworkRule) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[111] + mi := &file_openshell_proto_msgTypes[114] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8114,7 +8390,7 @@ func (x *RemoveNetworkRule) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveNetworkRule.ProtoReflect.Descriptor instead. func (*RemoveNetworkRule) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{111} + return file_openshell_proto_rawDescGZIP(), []int{114} } func (x *RemoveNetworkRule) GetRuleName() string { @@ -8135,7 +8411,7 @@ type AddDenyRules struct { func (x *AddDenyRules) Reset() { *x = AddDenyRules{} - mi := &file_openshell_proto_msgTypes[112] + mi := &file_openshell_proto_msgTypes[115] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8147,7 +8423,7 @@ func (x *AddDenyRules) String() string { func (*AddDenyRules) ProtoMessage() {} func (x *AddDenyRules) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[112] + mi := &file_openshell_proto_msgTypes[115] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8160,7 +8436,7 @@ func (x *AddDenyRules) ProtoReflect() protoreflect.Message { // Deprecated: Use AddDenyRules.ProtoReflect.Descriptor instead. func (*AddDenyRules) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{112} + return file_openshell_proto_rawDescGZIP(), []int{115} } func (x *AddDenyRules) GetHost() string { @@ -8195,7 +8471,7 @@ type AddAllowRules struct { func (x *AddAllowRules) Reset() { *x = AddAllowRules{} - mi := &file_openshell_proto_msgTypes[113] + mi := &file_openshell_proto_msgTypes[116] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8207,7 +8483,7 @@ func (x *AddAllowRules) String() string { func (*AddAllowRules) ProtoMessage() {} func (x *AddAllowRules) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[113] + mi := &file_openshell_proto_msgTypes[116] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8220,7 +8496,7 @@ func (x *AddAllowRules) ProtoReflect() protoreflect.Message { // Deprecated: Use AddAllowRules.ProtoReflect.Descriptor instead. func (*AddAllowRules) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{113} + return file_openshell_proto_rawDescGZIP(), []int{116} } func (x *AddAllowRules) GetHost() string { @@ -8254,7 +8530,7 @@ type RemoveNetworkBinary struct { func (x *RemoveNetworkBinary) Reset() { *x = RemoveNetworkBinary{} - mi := &file_openshell_proto_msgTypes[114] + mi := &file_openshell_proto_msgTypes[117] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8266,7 +8542,7 @@ func (x *RemoveNetworkBinary) String() string { func (*RemoveNetworkBinary) ProtoMessage() {} func (x *RemoveNetworkBinary) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[114] + mi := &file_openshell_proto_msgTypes[117] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8279,7 +8555,7 @@ func (x *RemoveNetworkBinary) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveNetworkBinary.ProtoReflect.Descriptor instead. func (*RemoveNetworkBinary) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{114} + return file_openshell_proto_rawDescGZIP(), []int{117} } func (x *RemoveNetworkBinary) GetRuleName() string { @@ -8315,7 +8591,7 @@ type UpdateConfigResponse struct { func (x *UpdateConfigResponse) Reset() { *x = UpdateConfigResponse{} - mi := &file_openshell_proto_msgTypes[115] + mi := &file_openshell_proto_msgTypes[118] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8327,7 +8603,7 @@ func (x *UpdateConfigResponse) String() string { func (*UpdateConfigResponse) ProtoMessage() {} func (x *UpdateConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[115] + mi := &file_openshell_proto_msgTypes[118] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8340,7 +8616,7 @@ func (x *UpdateConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateConfigResponse.ProtoReflect.Descriptor instead. func (*UpdateConfigResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{115} + return file_openshell_proto_rawDescGZIP(), []int{118} } func (x *UpdateConfigResponse) GetVersion() uint32 { @@ -8395,7 +8671,7 @@ type GetSandboxPolicyStatusRequest struct { func (x *GetSandboxPolicyStatusRequest) Reset() { *x = GetSandboxPolicyStatusRequest{} - mi := &file_openshell_proto_msgTypes[116] + mi := &file_openshell_proto_msgTypes[119] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8407,7 +8683,7 @@ func (x *GetSandboxPolicyStatusRequest) String() string { func (*GetSandboxPolicyStatusRequest) ProtoMessage() {} func (x *GetSandboxPolicyStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[116] + mi := &file_openshell_proto_msgTypes[119] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8420,7 +8696,7 @@ func (x *GetSandboxPolicyStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxPolicyStatusRequest.ProtoReflect.Descriptor instead. func (*GetSandboxPolicyStatusRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{116} + return file_openshell_proto_rawDescGZIP(), []int{119} } func (x *GetSandboxPolicyStatusRequest) GetName() string { @@ -8464,7 +8740,7 @@ type GetSandboxPolicyStatusResponse struct { func (x *GetSandboxPolicyStatusResponse) Reset() { *x = GetSandboxPolicyStatusResponse{} - mi := &file_openshell_proto_msgTypes[117] + mi := &file_openshell_proto_msgTypes[120] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8476,7 +8752,7 @@ func (x *GetSandboxPolicyStatusResponse) String() string { func (*GetSandboxPolicyStatusResponse) ProtoMessage() {} func (x *GetSandboxPolicyStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[117] + mi := &file_openshell_proto_msgTypes[120] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8489,7 +8765,7 @@ func (x *GetSandboxPolicyStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxPolicyStatusResponse.ProtoReflect.Descriptor instead. func (*GetSandboxPolicyStatusResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{117} + return file_openshell_proto_rawDescGZIP(), []int{120} } func (x *GetSandboxPolicyStatusResponse) GetRevision() *SandboxPolicyRevision { @@ -8523,7 +8799,7 @@ type ListSandboxPoliciesRequest struct { func (x *ListSandboxPoliciesRequest) Reset() { *x = ListSandboxPoliciesRequest{} - mi := &file_openshell_proto_msgTypes[118] + mi := &file_openshell_proto_msgTypes[121] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8535,7 +8811,7 @@ func (x *ListSandboxPoliciesRequest) String() string { func (*ListSandboxPoliciesRequest) ProtoMessage() {} func (x *ListSandboxPoliciesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[118] + mi := &file_openshell_proto_msgTypes[121] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8548,7 +8824,7 @@ func (x *ListSandboxPoliciesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSandboxPoliciesRequest.ProtoReflect.Descriptor instead. func (*ListSandboxPoliciesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{118} + return file_openshell_proto_rawDescGZIP(), []int{121} } func (x *ListSandboxPoliciesRequest) GetName() string { @@ -8596,7 +8872,7 @@ type ListSandboxPoliciesResponse struct { func (x *ListSandboxPoliciesResponse) Reset() { *x = ListSandboxPoliciesResponse{} - mi := &file_openshell_proto_msgTypes[119] + mi := &file_openshell_proto_msgTypes[122] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8608,7 +8884,7 @@ func (x *ListSandboxPoliciesResponse) String() string { func (*ListSandboxPoliciesResponse) ProtoMessage() {} func (x *ListSandboxPoliciesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[119] + mi := &file_openshell_proto_msgTypes[122] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8621,7 +8897,7 @@ func (x *ListSandboxPoliciesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSandboxPoliciesResponse.ProtoReflect.Descriptor instead. func (*ListSandboxPoliciesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{119} + return file_openshell_proto_rawDescGZIP(), []int{122} } func (x *ListSandboxPoliciesResponse) GetRevisions() []*SandboxPolicyRevision { @@ -8648,7 +8924,7 @@ type ReportPolicyStatusRequest struct { func (x *ReportPolicyStatusRequest) Reset() { *x = ReportPolicyStatusRequest{} - mi := &file_openshell_proto_msgTypes[120] + mi := &file_openshell_proto_msgTypes[123] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8660,7 +8936,7 @@ func (x *ReportPolicyStatusRequest) String() string { func (*ReportPolicyStatusRequest) ProtoMessage() {} func (x *ReportPolicyStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[120] + mi := &file_openshell_proto_msgTypes[123] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8673,7 +8949,7 @@ func (x *ReportPolicyStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportPolicyStatusRequest.ProtoReflect.Descriptor instead. func (*ReportPolicyStatusRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{120} + return file_openshell_proto_rawDescGZIP(), []int{123} } func (x *ReportPolicyStatusRequest) GetSandboxId() string { @@ -8713,7 +8989,7 @@ type ReportPolicyStatusResponse struct { func (x *ReportPolicyStatusResponse) Reset() { *x = ReportPolicyStatusResponse{} - mi := &file_openshell_proto_msgTypes[121] + mi := &file_openshell_proto_msgTypes[124] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8725,7 +9001,7 @@ func (x *ReportPolicyStatusResponse) String() string { func (*ReportPolicyStatusResponse) ProtoMessage() {} func (x *ReportPolicyStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[121] + mi := &file_openshell_proto_msgTypes[124] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8738,7 +9014,7 @@ func (x *ReportPolicyStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportPolicyStatusResponse.ProtoReflect.Descriptor instead. func (*ReportPolicyStatusResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{121} + return file_openshell_proto_rawDescGZIP(), []int{124} } // A versioned policy revision with metadata. @@ -8766,7 +9042,7 @@ type SandboxPolicyRevision struct { func (x *SandboxPolicyRevision) Reset() { *x = SandboxPolicyRevision{} - mi := &file_openshell_proto_msgTypes[122] + mi := &file_openshell_proto_msgTypes[125] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8778,7 +9054,7 @@ func (x *SandboxPolicyRevision) String() string { func (*SandboxPolicyRevision) ProtoMessage() {} func (x *SandboxPolicyRevision) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[122] + mi := &file_openshell_proto_msgTypes[125] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8791,7 +9067,7 @@ func (x *SandboxPolicyRevision) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxPolicyRevision.ProtoReflect.Descriptor instead. func (*SandboxPolicyRevision) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{122} + return file_openshell_proto_rawDescGZIP(), []int{125} } func (x *SandboxPolicyRevision) GetVersion() uint32 { @@ -8871,7 +9147,7 @@ type GetSandboxLogsRequest struct { func (x *GetSandboxLogsRequest) Reset() { *x = GetSandboxLogsRequest{} - mi := &file_openshell_proto_msgTypes[123] + mi := &file_openshell_proto_msgTypes[126] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8883,7 +9159,7 @@ func (x *GetSandboxLogsRequest) String() string { func (*GetSandboxLogsRequest) ProtoMessage() {} func (x *GetSandboxLogsRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[123] + mi := &file_openshell_proto_msgTypes[126] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8896,7 +9172,7 @@ func (x *GetSandboxLogsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxLogsRequest.ProtoReflect.Descriptor instead. func (*GetSandboxLogsRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{123} + return file_openshell_proto_rawDescGZIP(), []int{126} } func (x *GetSandboxLogsRequest) GetSandboxId() string { @@ -8954,7 +9230,7 @@ type PushSandboxLogsRequest struct { func (x *PushSandboxLogsRequest) Reset() { *x = PushSandboxLogsRequest{} - mi := &file_openshell_proto_msgTypes[124] + mi := &file_openshell_proto_msgTypes[127] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8966,7 +9242,7 @@ func (x *PushSandboxLogsRequest) String() string { func (*PushSandboxLogsRequest) ProtoMessage() {} func (x *PushSandboxLogsRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[124] + mi := &file_openshell_proto_msgTypes[127] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8979,7 +9255,7 @@ func (x *PushSandboxLogsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PushSandboxLogsRequest.ProtoReflect.Descriptor instead. func (*PushSandboxLogsRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{124} + return file_openshell_proto_rawDescGZIP(), []int{127} } func (x *PushSandboxLogsRequest) GetSandboxId() string { @@ -9005,7 +9281,7 @@ type PushSandboxLogsResponse struct { func (x *PushSandboxLogsResponse) Reset() { *x = PushSandboxLogsResponse{} - mi := &file_openshell_proto_msgTypes[125] + mi := &file_openshell_proto_msgTypes[128] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9017,7 +9293,7 @@ func (x *PushSandboxLogsResponse) String() string { func (*PushSandboxLogsResponse) ProtoMessage() {} func (x *PushSandboxLogsResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[125] + mi := &file_openshell_proto_msgTypes[128] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9030,7 +9306,7 @@ func (x *PushSandboxLogsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PushSandboxLogsResponse.ProtoReflect.Descriptor instead. func (*PushSandboxLogsResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{125} + return file_openshell_proto_rawDescGZIP(), []int{128} } // Get sandbox logs response. @@ -9046,7 +9322,7 @@ type GetSandboxLogsResponse struct { func (x *GetSandboxLogsResponse) Reset() { *x = GetSandboxLogsResponse{} - mi := &file_openshell_proto_msgTypes[126] + mi := &file_openshell_proto_msgTypes[129] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9058,7 +9334,7 @@ func (x *GetSandboxLogsResponse) String() string { func (*GetSandboxLogsResponse) ProtoMessage() {} func (x *GetSandboxLogsResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[126] + mi := &file_openshell_proto_msgTypes[129] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9071,7 +9347,7 @@ func (x *GetSandboxLogsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxLogsResponse.ProtoReflect.Descriptor instead. func (*GetSandboxLogsResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{126} + return file_openshell_proto_rawDescGZIP(), []int{129} } func (x *GetSandboxLogsResponse) GetLogs() []*SandboxLogLine { @@ -9104,7 +9380,7 @@ type SupervisorMessage struct { func (x *SupervisorMessage) Reset() { *x = SupervisorMessage{} - mi := &file_openshell_proto_msgTypes[127] + mi := &file_openshell_proto_msgTypes[130] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9116,7 +9392,7 @@ func (x *SupervisorMessage) String() string { func (*SupervisorMessage) ProtoMessage() {} func (x *SupervisorMessage) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[127] + mi := &file_openshell_proto_msgTypes[130] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9129,7 +9405,7 @@ func (x *SupervisorMessage) ProtoReflect() protoreflect.Message { // Deprecated: Use SupervisorMessage.ProtoReflect.Descriptor instead. func (*SupervisorMessage) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{127} + return file_openshell_proto_rawDescGZIP(), []int{130} } func (x *SupervisorMessage) GetPayload() isSupervisorMessage_Payload { @@ -9220,7 +9496,7 @@ type GatewayMessage struct { func (x *GatewayMessage) Reset() { *x = GatewayMessage{} - mi := &file_openshell_proto_msgTypes[128] + mi := &file_openshell_proto_msgTypes[131] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9232,7 +9508,7 @@ func (x *GatewayMessage) String() string { func (*GatewayMessage) ProtoMessage() {} func (x *GatewayMessage) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[128] + mi := &file_openshell_proto_msgTypes[131] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9245,7 +9521,7 @@ func (x *GatewayMessage) ProtoReflect() protoreflect.Message { // Deprecated: Use GatewayMessage.ProtoReflect.Descriptor instead. func (*GatewayMessage) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{128} + return file_openshell_proto_rawDescGZIP(), []int{131} } func (x *GatewayMessage) GetPayload() isGatewayMessage_Payload { @@ -9347,7 +9623,7 @@ type SupervisorHello struct { func (x *SupervisorHello) Reset() { *x = SupervisorHello{} - mi := &file_openshell_proto_msgTypes[129] + mi := &file_openshell_proto_msgTypes[132] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9359,7 +9635,7 @@ func (x *SupervisorHello) String() string { func (*SupervisorHello) ProtoMessage() {} func (x *SupervisorHello) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[129] + mi := &file_openshell_proto_msgTypes[132] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9372,7 +9648,7 @@ func (x *SupervisorHello) ProtoReflect() protoreflect.Message { // Deprecated: Use SupervisorHello.ProtoReflect.Descriptor instead. func (*SupervisorHello) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{129} + return file_openshell_proto_rawDescGZIP(), []int{132} } func (x *SupervisorHello) GetSandboxId() string { @@ -9402,7 +9678,7 @@ type SessionAccepted struct { func (x *SessionAccepted) Reset() { *x = SessionAccepted{} - mi := &file_openshell_proto_msgTypes[130] + mi := &file_openshell_proto_msgTypes[133] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9414,7 +9690,7 @@ func (x *SessionAccepted) String() string { func (*SessionAccepted) ProtoMessage() {} func (x *SessionAccepted) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[130] + mi := &file_openshell_proto_msgTypes[133] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9427,7 +9703,7 @@ func (x *SessionAccepted) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionAccepted.ProtoReflect.Descriptor instead. func (*SessionAccepted) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{130} + return file_openshell_proto_rawDescGZIP(), []int{133} } func (x *SessionAccepted) GetSessionId() string { @@ -9455,7 +9731,7 @@ type SessionRejected struct { func (x *SessionRejected) Reset() { *x = SessionRejected{} - mi := &file_openshell_proto_msgTypes[131] + mi := &file_openshell_proto_msgTypes[134] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9467,7 +9743,7 @@ func (x *SessionRejected) String() string { func (*SessionRejected) ProtoMessage() {} func (x *SessionRejected) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[131] + mi := &file_openshell_proto_msgTypes[134] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9480,7 +9756,7 @@ func (x *SessionRejected) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionRejected.ProtoReflect.Descriptor instead. func (*SessionRejected) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{131} + return file_openshell_proto_rawDescGZIP(), []int{134} } func (x *SessionRejected) GetReason() string { @@ -9499,7 +9775,7 @@ type SupervisorHeartbeat struct { func (x *SupervisorHeartbeat) Reset() { *x = SupervisorHeartbeat{} - mi := &file_openshell_proto_msgTypes[132] + mi := &file_openshell_proto_msgTypes[135] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9511,7 +9787,7 @@ func (x *SupervisorHeartbeat) String() string { func (*SupervisorHeartbeat) ProtoMessage() {} func (x *SupervisorHeartbeat) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[132] + mi := &file_openshell_proto_msgTypes[135] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9524,7 +9800,7 @@ func (x *SupervisorHeartbeat) ProtoReflect() protoreflect.Message { // Deprecated: Use SupervisorHeartbeat.ProtoReflect.Descriptor instead. func (*SupervisorHeartbeat) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{132} + return file_openshell_proto_rawDescGZIP(), []int{135} } // Gateway heartbeat. @@ -9536,7 +9812,7 @@ type GatewayHeartbeat struct { func (x *GatewayHeartbeat) Reset() { *x = GatewayHeartbeat{} - mi := &file_openshell_proto_msgTypes[133] + mi := &file_openshell_proto_msgTypes[136] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9548,7 +9824,7 @@ func (x *GatewayHeartbeat) String() string { func (*GatewayHeartbeat) ProtoMessage() {} func (x *GatewayHeartbeat) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[133] + mi := &file_openshell_proto_msgTypes[136] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9561,7 +9837,7 @@ func (x *GatewayHeartbeat) ProtoReflect() protoreflect.Message { // Deprecated: Use GatewayHeartbeat.ProtoReflect.Descriptor instead. func (*GatewayHeartbeat) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{133} + return file_openshell_proto_rawDescGZIP(), []int{136} } // Terminal result reported before the supervisor shuts down. A successful RPC @@ -9578,7 +9854,7 @@ type ReportMainProcessExitRequest struct { func (x *ReportMainProcessExitRequest) Reset() { *x = ReportMainProcessExitRequest{} - mi := &file_openshell_proto_msgTypes[134] + mi := &file_openshell_proto_msgTypes[137] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9590,7 +9866,7 @@ func (x *ReportMainProcessExitRequest) String() string { func (*ReportMainProcessExitRequest) ProtoMessage() {} func (x *ReportMainProcessExitRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[134] + mi := &file_openshell_proto_msgTypes[137] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9603,7 +9879,7 @@ func (x *ReportMainProcessExitRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportMainProcessExitRequest.ProtoReflect.Descriptor instead. func (*ReportMainProcessExitRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{134} + return file_openshell_proto_rawDescGZIP(), []int{137} } func (x *ReportMainProcessExitRequest) GetSandboxId() string { @@ -9635,7 +9911,7 @@ type ReportMainProcessExitResponse struct { func (x *ReportMainProcessExitResponse) Reset() { *x = ReportMainProcessExitResponse{} - mi := &file_openshell_proto_msgTypes[135] + mi := &file_openshell_proto_msgTypes[138] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9647,7 +9923,7 @@ func (x *ReportMainProcessExitResponse) String() string { func (*ReportMainProcessExitResponse) ProtoMessage() {} func (x *ReportMainProcessExitResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[135] + mi := &file_openshell_proto_msgTypes[138] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9660,7 +9936,7 @@ func (x *ReportMainProcessExitResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportMainProcessExitResponse.ProtoReflect.Descriptor instead. func (*ReportMainProcessExitResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{135} + return file_openshell_proto_rawDescGZIP(), []int{138} } // Gateway requests the supervisor to open a relay channel. @@ -9689,7 +9965,7 @@ type RelayOpen struct { func (x *RelayOpen) Reset() { *x = RelayOpen{} - mi := &file_openshell_proto_msgTypes[136] + mi := &file_openshell_proto_msgTypes[139] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9701,7 +9977,7 @@ func (x *RelayOpen) String() string { func (*RelayOpen) ProtoMessage() {} func (x *RelayOpen) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[136] + mi := &file_openshell_proto_msgTypes[139] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9714,7 +9990,7 @@ func (x *RelayOpen) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayOpen.ProtoReflect.Descriptor instead. func (*RelayOpen) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{136} + return file_openshell_proto_rawDescGZIP(), []int{139} } func (x *RelayOpen) GetChannelId() string { @@ -9781,7 +10057,7 @@ type SshRelayTarget struct { func (x *SshRelayTarget) Reset() { *x = SshRelayTarget{} - mi := &file_openshell_proto_msgTypes[137] + mi := &file_openshell_proto_msgTypes[140] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9793,7 +10069,7 @@ func (x *SshRelayTarget) String() string { func (*SshRelayTarget) ProtoMessage() {} func (x *SshRelayTarget) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[137] + mi := &file_openshell_proto_msgTypes[140] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9806,7 +10082,7 @@ func (x *SshRelayTarget) ProtoReflect() protoreflect.Message { // Deprecated: Use SshRelayTarget.ProtoReflect.Descriptor instead. func (*SshRelayTarget) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{137} + return file_openshell_proto_rawDescGZIP(), []int{140} } // TCP target dialed by the supervisor from inside the sandbox. @@ -9822,7 +10098,7 @@ type TcpRelayTarget struct { func (x *TcpRelayTarget) Reset() { *x = TcpRelayTarget{} - mi := &file_openshell_proto_msgTypes[138] + mi := &file_openshell_proto_msgTypes[141] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9834,7 +10110,7 @@ func (x *TcpRelayTarget) String() string { func (*TcpRelayTarget) ProtoMessage() {} func (x *TcpRelayTarget) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[138] + mi := &file_openshell_proto_msgTypes[141] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9847,7 +10123,7 @@ func (x *TcpRelayTarget) ProtoReflect() protoreflect.Message { // Deprecated: Use TcpRelayTarget.ProtoReflect.Descriptor instead. func (*TcpRelayTarget) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{138} + return file_openshell_proto_rawDescGZIP(), []int{141} } func (x *TcpRelayTarget) GetHost() string { @@ -9875,7 +10151,7 @@ type RelayInit struct { func (x *RelayInit) Reset() { *x = RelayInit{} - mi := &file_openshell_proto_msgTypes[139] + mi := &file_openshell_proto_msgTypes[142] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9887,7 +10163,7 @@ func (x *RelayInit) String() string { func (*RelayInit) ProtoMessage() {} func (x *RelayInit) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[139] + mi := &file_openshell_proto_msgTypes[142] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9900,7 +10176,7 @@ func (x *RelayInit) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayInit.ProtoReflect.Descriptor instead. func (*RelayInit) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{139} + return file_openshell_proto_rawDescGZIP(), []int{142} } func (x *RelayInit) GetChannelId() string { @@ -9927,7 +10203,7 @@ type RelayFrame struct { func (x *RelayFrame) Reset() { *x = RelayFrame{} - mi := &file_openshell_proto_msgTypes[140] + mi := &file_openshell_proto_msgTypes[143] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9939,7 +10215,7 @@ func (x *RelayFrame) String() string { func (*RelayFrame) ProtoMessage() {} func (x *RelayFrame) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[140] + mi := &file_openshell_proto_msgTypes[143] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9952,7 +10228,7 @@ func (x *RelayFrame) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayFrame.ProtoReflect.Descriptor instead. func (*RelayFrame) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{140} + return file_openshell_proto_rawDescGZIP(), []int{143} } func (x *RelayFrame) GetPayload() isRelayFrame_Payload { @@ -10011,7 +10287,7 @@ type RelayOpenResult struct { func (x *RelayOpenResult) Reset() { *x = RelayOpenResult{} - mi := &file_openshell_proto_msgTypes[141] + mi := &file_openshell_proto_msgTypes[144] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10023,7 +10299,7 @@ func (x *RelayOpenResult) String() string { func (*RelayOpenResult) ProtoMessage() {} func (x *RelayOpenResult) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[141] + mi := &file_openshell_proto_msgTypes[144] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10036,7 +10312,7 @@ func (x *RelayOpenResult) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayOpenResult.ProtoReflect.Descriptor instead. func (*RelayOpenResult) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{141} + return file_openshell_proto_rawDescGZIP(), []int{144} } func (x *RelayOpenResult) GetChannelId() string { @@ -10073,7 +10349,7 @@ type RelayClose struct { func (x *RelayClose) Reset() { *x = RelayClose{} - mi := &file_openshell_proto_msgTypes[142] + mi := &file_openshell_proto_msgTypes[145] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10085,7 +10361,7 @@ func (x *RelayClose) String() string { func (*RelayClose) ProtoMessage() {} func (x *RelayClose) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[142] + mi := &file_openshell_proto_msgTypes[145] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10098,7 +10374,7 @@ func (x *RelayClose) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayClose.ProtoReflect.Descriptor instead. func (*RelayClose) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{142} + return file_openshell_proto_rawDescGZIP(), []int{145} } func (x *RelayClose) GetChannelId() string { @@ -10132,7 +10408,7 @@ type L7RequestSample struct { func (x *L7RequestSample) Reset() { *x = L7RequestSample{} - mi := &file_openshell_proto_msgTypes[143] + mi := &file_openshell_proto_msgTypes[146] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10144,7 +10420,7 @@ func (x *L7RequestSample) String() string { func (*L7RequestSample) ProtoMessage() {} func (x *L7RequestSample) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[143] + mi := &file_openshell_proto_msgTypes[146] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10157,7 +10433,7 @@ func (x *L7RequestSample) ProtoReflect() protoreflect.Message { // Deprecated: Use L7RequestSample.ProtoReflect.Descriptor instead. func (*L7RequestSample) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{143} + return file_openshell_proto_rawDescGZIP(), []int{146} } func (x *L7RequestSample) GetMethod() string { @@ -10231,7 +10507,7 @@ type DenialSummary struct { func (x *DenialSummary) Reset() { *x = DenialSummary{} - mi := &file_openshell_proto_msgTypes[144] + mi := &file_openshell_proto_msgTypes[147] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10243,7 +10519,7 @@ func (x *DenialSummary) String() string { func (*DenialSummary) ProtoMessage() {} func (x *DenialSummary) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[144] + mi := &file_openshell_proto_msgTypes[147] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10256,7 +10532,7 @@ func (x *DenialSummary) ProtoReflect() protoreflect.Message { // Deprecated: Use DenialSummary.ProtoReflect.Descriptor instead. func (*DenialSummary) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{144} + return file_openshell_proto_rawDescGZIP(), []int{147} } func (x *DenialSummary) GetSandboxId() string { @@ -10391,7 +10667,7 @@ type DenialGroupCount struct { func (x *DenialGroupCount) Reset() { *x = DenialGroupCount{} - mi := &file_openshell_proto_msgTypes[145] + mi := &file_openshell_proto_msgTypes[148] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10403,7 +10679,7 @@ func (x *DenialGroupCount) String() string { func (*DenialGroupCount) ProtoMessage() {} func (x *DenialGroupCount) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[145] + mi := &file_openshell_proto_msgTypes[148] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10416,7 +10692,7 @@ func (x *DenialGroupCount) ProtoReflect() protoreflect.Message { // Deprecated: Use DenialGroupCount.ProtoReflect.Descriptor instead. func (*DenialGroupCount) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{145} + return file_openshell_proto_rawDescGZIP(), []int{148} } func (x *DenialGroupCount) GetDenyGroup() string { @@ -10449,7 +10725,7 @@ type NetworkActivitySummary struct { func (x *NetworkActivitySummary) Reset() { *x = NetworkActivitySummary{} - mi := &file_openshell_proto_msgTypes[146] + mi := &file_openshell_proto_msgTypes[149] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10461,7 +10737,7 @@ func (x *NetworkActivitySummary) String() string { func (*NetworkActivitySummary) ProtoMessage() {} func (x *NetworkActivitySummary) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[146] + mi := &file_openshell_proto_msgTypes[149] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10474,7 +10750,7 @@ func (x *NetworkActivitySummary) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkActivitySummary.ProtoReflect.Descriptor instead. func (*NetworkActivitySummary) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{146} + return file_openshell_proto_rawDescGZIP(), []int{149} } func (x *NetworkActivitySummary) GetNetworkActivityCount() uint32 { @@ -10562,7 +10838,7 @@ type PolicyChunk struct { func (x *PolicyChunk) Reset() { *x = PolicyChunk{} - mi := &file_openshell_proto_msgTypes[147] + mi := &file_openshell_proto_msgTypes[150] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10574,7 +10850,7 @@ func (x *PolicyChunk) String() string { func (*PolicyChunk) ProtoMessage() {} func (x *PolicyChunk) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[147] + mi := &file_openshell_proto_msgTypes[150] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10587,7 +10863,7 @@ func (x *PolicyChunk) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyChunk.ProtoReflect.Descriptor instead. func (*PolicyChunk) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{147} + return file_openshell_proto_rawDescGZIP(), []int{150} } func (x *PolicyChunk) GetId() string { @@ -10775,7 +11051,7 @@ type DraftPolicyUpdate struct { func (x *DraftPolicyUpdate) Reset() { *x = DraftPolicyUpdate{} - mi := &file_openshell_proto_msgTypes[148] + mi := &file_openshell_proto_msgTypes[151] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10787,7 +11063,7 @@ func (x *DraftPolicyUpdate) String() string { func (*DraftPolicyUpdate) ProtoMessage() {} func (x *DraftPolicyUpdate) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[148] + mi := &file_openshell_proto_msgTypes[151] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10800,7 +11076,7 @@ func (x *DraftPolicyUpdate) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftPolicyUpdate.ProtoReflect.Descriptor instead. func (*DraftPolicyUpdate) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{148} + return file_openshell_proto_rawDescGZIP(), []int{151} } func (x *DraftPolicyUpdate) GetDraftVersion() uint64 { @@ -10858,7 +11134,7 @@ type SubmitPolicyAnalysisRequest struct { func (x *SubmitPolicyAnalysisRequest) Reset() { *x = SubmitPolicyAnalysisRequest{} - mi := &file_openshell_proto_msgTypes[149] + mi := &file_openshell_proto_msgTypes[152] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10870,7 +11146,7 @@ func (x *SubmitPolicyAnalysisRequest) String() string { func (*SubmitPolicyAnalysisRequest) ProtoMessage() {} func (x *SubmitPolicyAnalysisRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[149] + mi := &file_openshell_proto_msgTypes[152] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10883,7 +11159,7 @@ func (x *SubmitPolicyAnalysisRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitPolicyAnalysisRequest.ProtoReflect.Descriptor instead. func (*SubmitPolicyAnalysisRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{149} + return file_openshell_proto_rawDescGZIP(), []int{152} } func (x *SubmitPolicyAnalysisRequest) GetSummaries() []*DenialSummary { @@ -10946,7 +11222,7 @@ type SubmitPolicyAnalysisResponse struct { func (x *SubmitPolicyAnalysisResponse) Reset() { *x = SubmitPolicyAnalysisResponse{} - mi := &file_openshell_proto_msgTypes[150] + mi := &file_openshell_proto_msgTypes[153] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10958,7 +11234,7 @@ func (x *SubmitPolicyAnalysisResponse) String() string { func (*SubmitPolicyAnalysisResponse) ProtoMessage() {} func (x *SubmitPolicyAnalysisResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[150] + mi := &file_openshell_proto_msgTypes[153] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10971,7 +11247,7 @@ func (x *SubmitPolicyAnalysisResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitPolicyAnalysisResponse.ProtoReflect.Descriptor instead. func (*SubmitPolicyAnalysisResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{150} + return file_openshell_proto_rawDescGZIP(), []int{153} } func (x *SubmitPolicyAnalysisResponse) GetAcceptedChunks() uint32 { @@ -11017,7 +11293,7 @@ type GetDraftPolicyRequest struct { func (x *GetDraftPolicyRequest) Reset() { *x = GetDraftPolicyRequest{} - mi := &file_openshell_proto_msgTypes[151] + mi := &file_openshell_proto_msgTypes[154] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11029,7 +11305,7 @@ func (x *GetDraftPolicyRequest) String() string { func (*GetDraftPolicyRequest) ProtoMessage() {} func (x *GetDraftPolicyRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[151] + mi := &file_openshell_proto_msgTypes[154] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11042,7 +11318,7 @@ func (x *GetDraftPolicyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftPolicyRequest.ProtoReflect.Descriptor instead. func (*GetDraftPolicyRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{151} + return file_openshell_proto_rawDescGZIP(), []int{154} } func (x *GetDraftPolicyRequest) GetName() string { @@ -11082,7 +11358,7 @@ type GetDraftPolicyResponse struct { func (x *GetDraftPolicyResponse) Reset() { *x = GetDraftPolicyResponse{} - mi := &file_openshell_proto_msgTypes[152] + mi := &file_openshell_proto_msgTypes[155] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11094,7 +11370,7 @@ func (x *GetDraftPolicyResponse) String() string { func (*GetDraftPolicyResponse) ProtoMessage() {} func (x *GetDraftPolicyResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[152] + mi := &file_openshell_proto_msgTypes[155] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11107,7 +11383,7 @@ func (x *GetDraftPolicyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftPolicyResponse.ProtoReflect.Descriptor instead. func (*GetDraftPolicyResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{152} + return file_openshell_proto_rawDescGZIP(), []int{155} } func (x *GetDraftPolicyResponse) GetChunks() []*PolicyChunk { @@ -11156,7 +11432,7 @@ type ApproveDraftChunkRequest struct { func (x *ApproveDraftChunkRequest) Reset() { *x = ApproveDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[153] + mi := &file_openshell_proto_msgTypes[156] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11168,7 +11444,7 @@ func (x *ApproveDraftChunkRequest) String() string { func (*ApproveDraftChunkRequest) ProtoMessage() {} func (x *ApproveDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[153] + mi := &file_openshell_proto_msgTypes[156] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11181,7 +11457,7 @@ func (x *ApproveDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveDraftChunkRequest.ProtoReflect.Descriptor instead. func (*ApproveDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{153} + return file_openshell_proto_rawDescGZIP(), []int{156} } func (x *ApproveDraftChunkRequest) GetName() string { @@ -11224,7 +11500,7 @@ type ApproveDraftChunkResponse struct { func (x *ApproveDraftChunkResponse) Reset() { *x = ApproveDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[154] + mi := &file_openshell_proto_msgTypes[157] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11236,7 +11512,7 @@ func (x *ApproveDraftChunkResponse) String() string { func (*ApproveDraftChunkResponse) ProtoMessage() {} func (x *ApproveDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[154] + mi := &file_openshell_proto_msgTypes[157] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11249,7 +11525,7 @@ func (x *ApproveDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveDraftChunkResponse.ProtoReflect.Descriptor instead. func (*ApproveDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{154} + return file_openshell_proto_rawDescGZIP(), []int{157} } func (x *ApproveDraftChunkResponse) GetPolicyVersion() uint32 { @@ -11283,7 +11559,7 @@ type RejectDraftChunkRequest struct { func (x *RejectDraftChunkRequest) Reset() { *x = RejectDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[155] + mi := &file_openshell_proto_msgTypes[158] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11295,7 +11571,7 @@ func (x *RejectDraftChunkRequest) String() string { func (*RejectDraftChunkRequest) ProtoMessage() {} func (x *RejectDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[155] + mi := &file_openshell_proto_msgTypes[158] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11308,7 +11584,7 @@ func (x *RejectDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RejectDraftChunkRequest.ProtoReflect.Descriptor instead. func (*RejectDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{155} + return file_openshell_proto_rawDescGZIP(), []int{158} } func (x *RejectDraftChunkRequest) GetName() string { @@ -11347,7 +11623,7 @@ type RejectDraftChunkResponse struct { func (x *RejectDraftChunkResponse) Reset() { *x = RejectDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[156] + mi := &file_openshell_proto_msgTypes[159] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11359,7 +11635,7 @@ func (x *RejectDraftChunkResponse) String() string { func (*RejectDraftChunkResponse) ProtoMessage() {} func (x *RejectDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[156] + mi := &file_openshell_proto_msgTypes[159] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11372,7 +11648,7 @@ func (x *RejectDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RejectDraftChunkResponse.ProtoReflect.Descriptor instead. func (*RejectDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{156} + return file_openshell_proto_rawDescGZIP(), []int{159} } // Approve all pending chunks. @@ -11386,7 +11662,7 @@ type DraftChunkApproval struct { func (x *DraftChunkApproval) Reset() { *x = DraftChunkApproval{} - mi := &file_openshell_proto_msgTypes[157] + mi := &file_openshell_proto_msgTypes[160] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11398,7 +11674,7 @@ func (x *DraftChunkApproval) String() string { func (*DraftChunkApproval) ProtoMessage() {} func (x *DraftChunkApproval) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[157] + mi := &file_openshell_proto_msgTypes[160] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11411,7 +11687,7 @@ func (x *DraftChunkApproval) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftChunkApproval.ProtoReflect.Descriptor instead. func (*DraftChunkApproval) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{157} + return file_openshell_proto_rawDescGZIP(), []int{160} } func (x *DraftChunkApproval) GetChunkId() string { @@ -11445,7 +11721,7 @@ type ApproveAllDraftChunksRequest struct { func (x *ApproveAllDraftChunksRequest) Reset() { *x = ApproveAllDraftChunksRequest{} - mi := &file_openshell_proto_msgTypes[158] + mi := &file_openshell_proto_msgTypes[161] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11457,7 +11733,7 @@ func (x *ApproveAllDraftChunksRequest) String() string { func (*ApproveAllDraftChunksRequest) ProtoMessage() {} func (x *ApproveAllDraftChunksRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[158] + mi := &file_openshell_proto_msgTypes[161] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11470,7 +11746,7 @@ func (x *ApproveAllDraftChunksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveAllDraftChunksRequest.ProtoReflect.Descriptor instead. func (*ApproveAllDraftChunksRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{158} + return file_openshell_proto_rawDescGZIP(), []int{161} } func (x *ApproveAllDraftChunksRequest) GetName() string { @@ -11518,7 +11794,7 @@ type ApproveAllDraftChunksResponse struct { func (x *ApproveAllDraftChunksResponse) Reset() { *x = ApproveAllDraftChunksResponse{} - mi := &file_openshell_proto_msgTypes[159] + mi := &file_openshell_proto_msgTypes[162] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11530,7 +11806,7 @@ func (x *ApproveAllDraftChunksResponse) String() string { func (*ApproveAllDraftChunksResponse) ProtoMessage() {} func (x *ApproveAllDraftChunksResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[159] + mi := &file_openshell_proto_msgTypes[162] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11543,7 +11819,7 @@ func (x *ApproveAllDraftChunksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveAllDraftChunksResponse.ProtoReflect.Descriptor instead. func (*ApproveAllDraftChunksResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{159} + return file_openshell_proto_rawDescGZIP(), []int{162} } func (x *ApproveAllDraftChunksResponse) GetPolicyVersion() uint32 { @@ -11591,7 +11867,7 @@ type EditDraftChunkRequest struct { func (x *EditDraftChunkRequest) Reset() { *x = EditDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[160] + mi := &file_openshell_proto_msgTypes[163] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11603,7 +11879,7 @@ func (x *EditDraftChunkRequest) String() string { func (*EditDraftChunkRequest) ProtoMessage() {} func (x *EditDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[160] + mi := &file_openshell_proto_msgTypes[163] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11616,7 +11892,7 @@ func (x *EditDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EditDraftChunkRequest.ProtoReflect.Descriptor instead. func (*EditDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{160} + return file_openshell_proto_rawDescGZIP(), []int{163} } func (x *EditDraftChunkRequest) GetName() string { @@ -11655,7 +11931,7 @@ type EditDraftChunkResponse struct { func (x *EditDraftChunkResponse) Reset() { *x = EditDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[161] + mi := &file_openshell_proto_msgTypes[164] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11667,7 +11943,7 @@ func (x *EditDraftChunkResponse) String() string { func (*EditDraftChunkResponse) ProtoMessage() {} func (x *EditDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[161] + mi := &file_openshell_proto_msgTypes[164] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11680,7 +11956,7 @@ func (x *EditDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EditDraftChunkResponse.ProtoReflect.Descriptor instead. func (*EditDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{161} + return file_openshell_proto_rawDescGZIP(), []int{164} } // Reverse an approval (remove merged rule from active policy). @@ -11698,7 +11974,7 @@ type UndoDraftChunkRequest struct { func (x *UndoDraftChunkRequest) Reset() { *x = UndoDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[162] + mi := &file_openshell_proto_msgTypes[165] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11710,7 +11986,7 @@ func (x *UndoDraftChunkRequest) String() string { func (*UndoDraftChunkRequest) ProtoMessage() {} func (x *UndoDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[162] + mi := &file_openshell_proto_msgTypes[165] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11723,7 +11999,7 @@ func (x *UndoDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UndoDraftChunkRequest.ProtoReflect.Descriptor instead. func (*UndoDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{162} + return file_openshell_proto_rawDescGZIP(), []int{165} } func (x *UndoDraftChunkRequest) GetName() string { @@ -11759,7 +12035,7 @@ type UndoDraftChunkResponse struct { func (x *UndoDraftChunkResponse) Reset() { *x = UndoDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[163] + mi := &file_openshell_proto_msgTypes[166] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11771,7 +12047,7 @@ func (x *UndoDraftChunkResponse) String() string { func (*UndoDraftChunkResponse) ProtoMessage() {} func (x *UndoDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[163] + mi := &file_openshell_proto_msgTypes[166] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11784,7 +12060,7 @@ func (x *UndoDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UndoDraftChunkResponse.ProtoReflect.Descriptor instead. func (*UndoDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{163} + return file_openshell_proto_rawDescGZIP(), []int{166} } func (x *UndoDraftChunkResponse) GetPolicyVersion() uint32 { @@ -11814,7 +12090,7 @@ type ClearDraftChunksRequest struct { func (x *ClearDraftChunksRequest) Reset() { *x = ClearDraftChunksRequest{} - mi := &file_openshell_proto_msgTypes[164] + mi := &file_openshell_proto_msgTypes[167] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11826,7 +12102,7 @@ func (x *ClearDraftChunksRequest) String() string { func (*ClearDraftChunksRequest) ProtoMessage() {} func (x *ClearDraftChunksRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[164] + mi := &file_openshell_proto_msgTypes[167] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11839,7 +12115,7 @@ func (x *ClearDraftChunksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ClearDraftChunksRequest.ProtoReflect.Descriptor instead. func (*ClearDraftChunksRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{164} + return file_openshell_proto_rawDescGZIP(), []int{167} } func (x *ClearDraftChunksRequest) GetName() string { @@ -11866,7 +12142,7 @@ type ClearDraftChunksResponse struct { func (x *ClearDraftChunksResponse) Reset() { *x = ClearDraftChunksResponse{} - mi := &file_openshell_proto_msgTypes[165] + mi := &file_openshell_proto_msgTypes[168] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11878,7 +12154,7 @@ func (x *ClearDraftChunksResponse) String() string { func (*ClearDraftChunksResponse) ProtoMessage() {} func (x *ClearDraftChunksResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[165] + mi := &file_openshell_proto_msgTypes[168] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11891,7 +12167,7 @@ func (x *ClearDraftChunksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ClearDraftChunksResponse.ProtoReflect.Descriptor instead. func (*ClearDraftChunksResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{165} + return file_openshell_proto_rawDescGZIP(), []int{168} } func (x *ClearDraftChunksResponse) GetChunksCleared() uint32 { @@ -11914,7 +12190,7 @@ type GetDraftHistoryRequest struct { func (x *GetDraftHistoryRequest) Reset() { *x = GetDraftHistoryRequest{} - mi := &file_openshell_proto_msgTypes[166] + mi := &file_openshell_proto_msgTypes[169] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11926,7 +12202,7 @@ func (x *GetDraftHistoryRequest) String() string { func (*GetDraftHistoryRequest) ProtoMessage() {} func (x *GetDraftHistoryRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[166] + mi := &file_openshell_proto_msgTypes[169] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11939,7 +12215,7 @@ func (x *GetDraftHistoryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftHistoryRequest.ProtoReflect.Descriptor instead. func (*GetDraftHistoryRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{166} + return file_openshell_proto_rawDescGZIP(), []int{169} } func (x *GetDraftHistoryRequest) GetName() string { @@ -11973,7 +12249,7 @@ type DraftHistoryEntry struct { func (x *DraftHistoryEntry) Reset() { *x = DraftHistoryEntry{} - mi := &file_openshell_proto_msgTypes[167] + mi := &file_openshell_proto_msgTypes[170] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11985,7 +12261,7 @@ func (x *DraftHistoryEntry) String() string { func (*DraftHistoryEntry) ProtoMessage() {} func (x *DraftHistoryEntry) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[167] + mi := &file_openshell_proto_msgTypes[170] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11998,7 +12274,7 @@ func (x *DraftHistoryEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftHistoryEntry.ProtoReflect.Descriptor instead. func (*DraftHistoryEntry) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{167} + return file_openshell_proto_rawDescGZIP(), []int{170} } func (x *DraftHistoryEntry) GetTimestampMs() int64 { @@ -12039,7 +12315,7 @@ type GetDraftHistoryResponse struct { func (x *GetDraftHistoryResponse) Reset() { *x = GetDraftHistoryResponse{} - mi := &file_openshell_proto_msgTypes[168] + mi := &file_openshell_proto_msgTypes[171] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12051,7 +12327,7 @@ func (x *GetDraftHistoryResponse) String() string { func (*GetDraftHistoryResponse) ProtoMessage() {} func (x *GetDraftHistoryResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[168] + mi := &file_openshell_proto_msgTypes[171] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12064,7 +12340,7 @@ func (x *GetDraftHistoryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftHistoryResponse.ProtoReflect.Descriptor instead. func (*GetDraftHistoryResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{168} + return file_openshell_proto_rawDescGZIP(), []int{171} } func (x *GetDraftHistoryResponse) GetEntries() []*DraftHistoryEntry { @@ -12093,7 +12369,7 @@ type PolicyRevisionPayload struct { func (x *PolicyRevisionPayload) Reset() { *x = PolicyRevisionPayload{} - mi := &file_openshell_proto_msgTypes[169] + mi := &file_openshell_proto_msgTypes[172] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12105,7 +12381,7 @@ func (x *PolicyRevisionPayload) String() string { func (*PolicyRevisionPayload) ProtoMessage() {} func (x *PolicyRevisionPayload) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[169] + mi := &file_openshell_proto_msgTypes[172] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12118,7 +12394,7 @@ func (x *PolicyRevisionPayload) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyRevisionPayload.ProtoReflect.Descriptor instead. func (*PolicyRevisionPayload) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{169} + return file_openshell_proto_rawDescGZIP(), []int{172} } func (x *PolicyRevisionPayload) GetPolicy() *sandboxv1.SandboxPolicy { @@ -12197,7 +12473,7 @@ type DraftChunkPayload struct { func (x *DraftChunkPayload) Reset() { *x = DraftChunkPayload{} - mi := &file_openshell_proto_msgTypes[170] + mi := &file_openshell_proto_msgTypes[173] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12209,7 +12485,7 @@ func (x *DraftChunkPayload) String() string { func (*DraftChunkPayload) ProtoMessage() {} func (x *DraftChunkPayload) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[170] + mi := &file_openshell_proto_msgTypes[173] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12222,7 +12498,7 @@ func (x *DraftChunkPayload) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftChunkPayload.ProtoReflect.Descriptor instead. func (*DraftChunkPayload) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{170} + return file_openshell_proto_rawDescGZIP(), []int{173} } func (x *DraftChunkPayload) GetRuleName() string { @@ -12370,7 +12646,7 @@ type StoredPolicyRevision struct { func (x *StoredPolicyRevision) Reset() { *x = StoredPolicyRevision{} - mi := &file_openshell_proto_msgTypes[171] + mi := &file_openshell_proto_msgTypes[174] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12382,7 +12658,7 @@ func (x *StoredPolicyRevision) String() string { func (*StoredPolicyRevision) ProtoMessage() {} func (x *StoredPolicyRevision) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[171] + mi := &file_openshell_proto_msgTypes[174] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12395,7 +12671,7 @@ func (x *StoredPolicyRevision) ProtoReflect() protoreflect.Message { // Deprecated: Use StoredPolicyRevision.ProtoReflect.Descriptor instead. func (*StoredPolicyRevision) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{171} + return file_openshell_proto_rawDescGZIP(), []int{174} } func (x *StoredPolicyRevision) GetId() string { @@ -12504,7 +12780,7 @@ type StoredDraftChunk struct { func (x *StoredDraftChunk) Reset() { *x = StoredDraftChunk{} - mi := &file_openshell_proto_msgTypes[172] + mi := &file_openshell_proto_msgTypes[175] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12516,7 +12792,7 @@ func (x *StoredDraftChunk) String() string { func (*StoredDraftChunk) ProtoMessage() {} func (x *StoredDraftChunk) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[172] + mi := &file_openshell_proto_msgTypes[175] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12529,7 +12805,7 @@ func (x *StoredDraftChunk) ProtoReflect() protoreflect.Message { // Deprecated: Use StoredDraftChunk.ProtoReflect.Descriptor instead. func (*StoredDraftChunk) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{172} + return file_openshell_proto_rawDescGZIP(), []int{175} } func (x *StoredDraftChunk) GetId() string { @@ -12720,7 +12996,7 @@ type CreateWorkspaceRequest struct { func (x *CreateWorkspaceRequest) Reset() { *x = CreateWorkspaceRequest{} - mi := &file_openshell_proto_msgTypes[173] + mi := &file_openshell_proto_msgTypes[176] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12732,7 +13008,7 @@ func (x *CreateWorkspaceRequest) String() string { func (*CreateWorkspaceRequest) ProtoMessage() {} func (x *CreateWorkspaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[173] + mi := &file_openshell_proto_msgTypes[176] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12745,7 +13021,7 @@ func (x *CreateWorkspaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateWorkspaceRequest.ProtoReflect.Descriptor instead. func (*CreateWorkspaceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{173} + return file_openshell_proto_rawDescGZIP(), []int{176} } func (x *CreateWorkspaceRequest) GetName() string { @@ -12772,7 +13048,7 @@ type CreateWorkspaceResponse struct { func (x *CreateWorkspaceResponse) Reset() { *x = CreateWorkspaceResponse{} - mi := &file_openshell_proto_msgTypes[174] + mi := &file_openshell_proto_msgTypes[177] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12784,7 +13060,7 @@ func (x *CreateWorkspaceResponse) String() string { func (*CreateWorkspaceResponse) ProtoMessage() {} func (x *CreateWorkspaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[174] + mi := &file_openshell_proto_msgTypes[177] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12797,7 +13073,7 @@ func (x *CreateWorkspaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateWorkspaceResponse.ProtoReflect.Descriptor instead. func (*CreateWorkspaceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{174} + return file_openshell_proto_rawDescGZIP(), []int{177} } func (x *CreateWorkspaceResponse) GetWorkspace() *datamodelv1.Workspace { @@ -12818,7 +13094,7 @@ type GetWorkspaceRequest struct { func (x *GetWorkspaceRequest) Reset() { *x = GetWorkspaceRequest{} - mi := &file_openshell_proto_msgTypes[175] + mi := &file_openshell_proto_msgTypes[178] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12830,7 +13106,7 @@ func (x *GetWorkspaceRequest) String() string { func (*GetWorkspaceRequest) ProtoMessage() {} func (x *GetWorkspaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[175] + mi := &file_openshell_proto_msgTypes[178] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12843,7 +13119,7 @@ func (x *GetWorkspaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetWorkspaceRequest.ProtoReflect.Descriptor instead. func (*GetWorkspaceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{175} + return file_openshell_proto_rawDescGZIP(), []int{178} } func (x *GetWorkspaceRequest) GetName() string { @@ -12863,7 +13139,7 @@ type GetWorkspaceResponse struct { func (x *GetWorkspaceResponse) Reset() { *x = GetWorkspaceResponse{} - mi := &file_openshell_proto_msgTypes[176] + mi := &file_openshell_proto_msgTypes[179] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12875,7 +13151,7 @@ func (x *GetWorkspaceResponse) String() string { func (*GetWorkspaceResponse) ProtoMessage() {} func (x *GetWorkspaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[176] + mi := &file_openshell_proto_msgTypes[179] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12888,7 +13164,7 @@ func (x *GetWorkspaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetWorkspaceResponse.ProtoReflect.Descriptor instead. func (*GetWorkspaceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{176} + return file_openshell_proto_rawDescGZIP(), []int{179} } func (x *GetWorkspaceResponse) GetWorkspace() *datamodelv1.Workspace { @@ -12911,7 +13187,7 @@ type ListWorkspacesRequest struct { func (x *ListWorkspacesRequest) Reset() { *x = ListWorkspacesRequest{} - mi := &file_openshell_proto_msgTypes[177] + mi := &file_openshell_proto_msgTypes[180] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12923,7 +13199,7 @@ func (x *ListWorkspacesRequest) String() string { func (*ListWorkspacesRequest) ProtoMessage() {} func (x *ListWorkspacesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[177] + mi := &file_openshell_proto_msgTypes[180] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12936,7 +13212,7 @@ func (x *ListWorkspacesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspacesRequest.ProtoReflect.Descriptor instead. func (*ListWorkspacesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{177} + return file_openshell_proto_rawDescGZIP(), []int{180} } func (x *ListWorkspacesRequest) GetLimit() uint32 { @@ -12970,7 +13246,7 @@ type ListWorkspacesResponse struct { func (x *ListWorkspacesResponse) Reset() { *x = ListWorkspacesResponse{} - mi := &file_openshell_proto_msgTypes[178] + mi := &file_openshell_proto_msgTypes[181] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12982,7 +13258,7 @@ func (x *ListWorkspacesResponse) String() string { func (*ListWorkspacesResponse) ProtoMessage() {} func (x *ListWorkspacesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[178] + mi := &file_openshell_proto_msgTypes[181] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12995,7 +13271,7 @@ func (x *ListWorkspacesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspacesResponse.ProtoReflect.Descriptor instead. func (*ListWorkspacesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{178} + return file_openshell_proto_rawDescGZIP(), []int{181} } func (x *ListWorkspacesResponse) GetWorkspaces() []*datamodelv1.Workspace { @@ -13016,7 +13292,7 @@ type DeleteWorkspaceRequest struct { func (x *DeleteWorkspaceRequest) Reset() { *x = DeleteWorkspaceRequest{} - mi := &file_openshell_proto_msgTypes[179] + mi := &file_openshell_proto_msgTypes[182] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13028,7 +13304,7 @@ func (x *DeleteWorkspaceRequest) String() string { func (*DeleteWorkspaceRequest) ProtoMessage() {} func (x *DeleteWorkspaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[179] + mi := &file_openshell_proto_msgTypes[182] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13041,7 +13317,7 @@ func (x *DeleteWorkspaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteWorkspaceRequest.ProtoReflect.Descriptor instead. func (*DeleteWorkspaceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{179} + return file_openshell_proto_rawDescGZIP(), []int{182} } func (x *DeleteWorkspaceRequest) GetName() string { @@ -13061,7 +13337,7 @@ type DeleteWorkspaceResponse struct { func (x *DeleteWorkspaceResponse) Reset() { *x = DeleteWorkspaceResponse{} - mi := &file_openshell_proto_msgTypes[180] + mi := &file_openshell_proto_msgTypes[183] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13073,7 +13349,7 @@ func (x *DeleteWorkspaceResponse) String() string { func (*DeleteWorkspaceResponse) ProtoMessage() {} func (x *DeleteWorkspaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[180] + mi := &file_openshell_proto_msgTypes[183] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13086,7 +13362,7 @@ func (x *DeleteWorkspaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteWorkspaceResponse.ProtoReflect.Descriptor instead. func (*DeleteWorkspaceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{180} + return file_openshell_proto_rawDescGZIP(), []int{183} } func (x *DeleteWorkspaceResponse) GetDeleted() bool { @@ -13110,7 +13386,7 @@ type WorkspaceMember struct { func (x *WorkspaceMember) Reset() { *x = WorkspaceMember{} - mi := &file_openshell_proto_msgTypes[181] + mi := &file_openshell_proto_msgTypes[184] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13122,7 +13398,7 @@ func (x *WorkspaceMember) String() string { func (*WorkspaceMember) ProtoMessage() {} func (x *WorkspaceMember) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[181] + mi := &file_openshell_proto_msgTypes[184] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13135,7 +13411,7 @@ func (x *WorkspaceMember) ProtoReflect() protoreflect.Message { // Deprecated: Use WorkspaceMember.ProtoReflect.Descriptor instead. func (*WorkspaceMember) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{181} + return file_openshell_proto_rawDescGZIP(), []int{184} } func (x *WorkspaceMember) GetMetadata() *datamodelv1.ObjectMeta { @@ -13174,7 +13450,7 @@ type AddWorkspaceMemberRequest struct { func (x *AddWorkspaceMemberRequest) Reset() { *x = AddWorkspaceMemberRequest{} - mi := &file_openshell_proto_msgTypes[182] + mi := &file_openshell_proto_msgTypes[185] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13186,7 +13462,7 @@ func (x *AddWorkspaceMemberRequest) String() string { func (*AddWorkspaceMemberRequest) ProtoMessage() {} func (x *AddWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[182] + mi := &file_openshell_proto_msgTypes[185] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13199,7 +13475,7 @@ func (x *AddWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AddWorkspaceMemberRequest.ProtoReflect.Descriptor instead. func (*AddWorkspaceMemberRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{182} + return file_openshell_proto_rawDescGZIP(), []int{185} } func (x *AddWorkspaceMemberRequest) GetWorkspace() string { @@ -13233,7 +13509,7 @@ type AddWorkspaceMemberResponse struct { func (x *AddWorkspaceMemberResponse) Reset() { *x = AddWorkspaceMemberResponse{} - mi := &file_openshell_proto_msgTypes[183] + mi := &file_openshell_proto_msgTypes[186] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13245,7 +13521,7 @@ func (x *AddWorkspaceMemberResponse) String() string { func (*AddWorkspaceMemberResponse) ProtoMessage() {} func (x *AddWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[183] + mi := &file_openshell_proto_msgTypes[186] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13258,7 +13534,7 @@ func (x *AddWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AddWorkspaceMemberResponse.ProtoReflect.Descriptor instead. func (*AddWorkspaceMemberResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{183} + return file_openshell_proto_rawDescGZIP(), []int{186} } func (x *AddWorkspaceMemberResponse) GetMember() *WorkspaceMember { @@ -13281,7 +13557,7 @@ type RemoveWorkspaceMemberRequest struct { func (x *RemoveWorkspaceMemberRequest) Reset() { *x = RemoveWorkspaceMemberRequest{} - mi := &file_openshell_proto_msgTypes[184] + mi := &file_openshell_proto_msgTypes[187] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13293,7 +13569,7 @@ func (x *RemoveWorkspaceMemberRequest) String() string { func (*RemoveWorkspaceMemberRequest) ProtoMessage() {} func (x *RemoveWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[184] + mi := &file_openshell_proto_msgTypes[187] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13306,7 +13582,7 @@ func (x *RemoveWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveWorkspaceMemberRequest.ProtoReflect.Descriptor instead. func (*RemoveWorkspaceMemberRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{184} + return file_openshell_proto_rawDescGZIP(), []int{187} } func (x *RemoveWorkspaceMemberRequest) GetWorkspace() string { @@ -13333,7 +13609,7 @@ type RemoveWorkspaceMemberResponse struct { func (x *RemoveWorkspaceMemberResponse) Reset() { *x = RemoveWorkspaceMemberResponse{} - mi := &file_openshell_proto_msgTypes[185] + mi := &file_openshell_proto_msgTypes[188] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13345,7 +13621,7 @@ func (x *RemoveWorkspaceMemberResponse) String() string { func (*RemoveWorkspaceMemberResponse) ProtoMessage() {} func (x *RemoveWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[185] + mi := &file_openshell_proto_msgTypes[188] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13358,7 +13634,7 @@ func (x *RemoveWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveWorkspaceMemberResponse.ProtoReflect.Descriptor instead. func (*RemoveWorkspaceMemberResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{185} + return file_openshell_proto_rawDescGZIP(), []int{188} } func (x *RemoveWorkspaceMemberResponse) GetRemoved() bool { @@ -13381,7 +13657,7 @@ type ListWorkspaceMembersRequest struct { func (x *ListWorkspaceMembersRequest) Reset() { *x = ListWorkspaceMembersRequest{} - mi := &file_openshell_proto_msgTypes[186] + mi := &file_openshell_proto_msgTypes[189] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13393,7 +13669,7 @@ func (x *ListWorkspaceMembersRequest) String() string { func (*ListWorkspaceMembersRequest) ProtoMessage() {} func (x *ListWorkspaceMembersRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[186] + mi := &file_openshell_proto_msgTypes[189] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13406,7 +13682,7 @@ func (x *ListWorkspaceMembersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspaceMembersRequest.ProtoReflect.Descriptor instead. func (*ListWorkspaceMembersRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{186} + return file_openshell_proto_rawDescGZIP(), []int{189} } func (x *ListWorkspaceMembersRequest) GetWorkspace() string { @@ -13440,7 +13716,7 @@ type ListWorkspaceMembersResponse struct { func (x *ListWorkspaceMembersResponse) Reset() { *x = ListWorkspaceMembersResponse{} - mi := &file_openshell_proto_msgTypes[187] + mi := &file_openshell_proto_msgTypes[190] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13452,7 +13728,7 @@ func (x *ListWorkspaceMembersResponse) String() string { func (*ListWorkspaceMembersResponse) ProtoMessage() {} func (x *ListWorkspaceMembersResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[187] + mi := &file_openshell_proto_msgTypes[190] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13465,7 +13741,7 @@ func (x *ListWorkspaceMembersResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspaceMembersResponse.ProtoReflect.Descriptor instead. func (*ListWorkspaceMembersResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{187} + return file_openshell_proto_rawDescGZIP(), []int{190} } func (x *ListWorkspaceMembersResponse) GetMembers() []*WorkspaceMember { @@ -13493,7 +13769,7 @@ type ExtensionServiceCredential struct { func (x *ExtensionServiceCredential) Reset() { *x = ExtensionServiceCredential{} - mi := &file_openshell_proto_msgTypes[188] + mi := &file_openshell_proto_msgTypes[191] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13505,7 +13781,7 @@ func (x *ExtensionServiceCredential) String() string { func (*ExtensionServiceCredential) ProtoMessage() {} func (x *ExtensionServiceCredential) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[188] + mi := &file_openshell_proto_msgTypes[191] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13518,7 +13794,7 @@ func (x *ExtensionServiceCredential) ProtoReflect() protoreflect.Message { // Deprecated: Use ExtensionServiceCredential.ProtoReflect.Descriptor instead. func (*ExtensionServiceCredential) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{188} + return file_openshell_proto_rawDescGZIP(), []int{191} } func (x *ExtensionServiceCredential) GetServiceName() string { @@ -13904,7 +14180,13 @@ const file_openshell_proto_rawDesc = "" + "\x04port\x18\x02 \x01(\rR\x04port\x12\x12\n" + "\x04path\x18\x03 \x01(\tR\x04path\x12\x1a\n" + "\baudience\x18\x04 \x01(\tR\baudience\x12\x16\n" + - "\x06scopes\x18\x05 \x03(\tR\x06scopes\"\xf0\x02\n" + + "\x06scopes\x18\x05 \x03(\tR\x06scopes\"\x90\x01\n" + + "(ProviderCredentialTokenGrantSubjectToken\x12\x16\n" + + "\x06source\x18\x01 \x01(\tR\x06source\x12\x1e\n" + + "\n" + + "credential\x18\x02 \x01(\tR\n" + + "credential\x12,\n" + + "\x12subject_token_type\x18\x03 \x01(\tR\x10subjectTokenType\"\xce\x04\n" + "\x1cProviderCredentialTokenGrant\x12%\n" + "\x0etoken_endpoint\x18\x01 \x01(\tR\rtokenEndpoint\x12\x1a\n" + "\baudience\x18\x02 \x01(\tR\baudience\x12*\n" + @@ -13912,7 +14194,12 @@ const file_openshell_proto_rawDesc = "" + "\x06scopes\x18\x03 \x03(\tR\x06scopes\x12*\n" + "\x11cache_ttl_seconds\x18\x04 \x01(\x03R\x0fcacheTtlSeconds\x12i\n" + "\x12audience_overrides\x18\x05 \x03(\v2:.openshell.v1.ProviderCredentialTokenGrantAudienceOverrideR\x11audienceOverrides\x122\n" + - "\x15client_assertion_type\x18\a \x01(\tR\x13clientAssertionType\"\x9e\x03\n" + + "\x15client_assertion_type\x18\a \x01(\tR\x13clientAssertionType\x12M\n" + + "\n" + + "grant_type\x18\b \x01(\x0e2..openshell.v1.ProviderCredentialTokenGrantTypeR\tgrantType\x12[\n" + + "\rsubject_token\x18\t \x01(\v26.openshell.v1.ProviderCredentialTokenGrantSubjectTokenR\fsubjectToken\x120\n" + + "\x14requested_token_type\x18\n" + + " \x01(\tR\x12requestedTokenType\"\x9e\x03\n" + "\x19ProviderProfileCredential\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + "\vdescription\x18\x02 \x01(\tR\vdescription\x12\x19\n" + @@ -14113,7 +14400,19 @@ const file_openshell_proto_rawDesc = "" + "\x05value\x18\x02 \x01(\v2'.openshell.v1.ProviderProfileCredentialR\x05value:\x028\x01\x1ar\n" + "\x1dStaticCredentialBindingsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12;\n" + - "\x05value\x18\x02 \x01(\v2%.openshell.v1.StaticCredentialBindingR\x05value:\x028\x01\"\xce\x04\n" + + "\x05value\x18\x02 \x01(\v2%.openshell.v1.StaticCredentialBindingR\x05value:\x028\x01\"\xbd\x01\n" + + "#ExchangeProviderSubjectTokenRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1a\n" + + "\bprovider\x18\x02 \x01(\tR\bprovider\x12%\n" + + "\x0ecredential_key\x18\x03 \x01(\tR\rcredentialKey\x124\n" + + "\x13supervisor_jwt_svid\x18\x04 \x01(\tB\x04\x88\xb5\x18\x01R\x11supervisorJwtSvid\"\x8d\x01\n" + + "$ExchangeProviderSubjectTokenResponse\x12'\n" + + "\faccess_token\x18\x01 \x01(\tB\x04\x88\xb5\x18\x01R\vaccessToken\x12\x1d\n" + + "\n" + + "expires_in\x18\x02 \x01(\x03R\texpiresIn\x12\x1d\n" + + "\n" + + "token_type\x18\x03 \x01(\tR\ttokenType\"\xce\x04\n" + "\x13UpdateConfigRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12;\n" + "\x06policy\x18\x02 \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x06policy\x12\x1f\n" + @@ -14595,7 +14894,11 @@ const file_openshell_proto_rawDesc = "" + "\x15SANDBOX_PHASE_UNKNOWN\x10\x05\x12\x1a\n" + "\x16SANDBOX_PHASE_STOPPING\x10\x06\x12\x19\n" + "\x15SANDBOX_PHASE_STOPPED\x10\a\x12\x1a\n" + - "\x16SANDBOX_PHASE_STARTING\x10\b*\xc3\x03\n" + + "\x16SANDBOX_PHASE_STARTING\x10\b*\xce\x01\n" + + " ProviderCredentialTokenGrantType\x124\n" + + "0PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_UNSPECIFIED\x10\x00\x12;\n" + + "7PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_CLIENT_CREDENTIALS\x10\x01\x127\n" + + "3PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_TOKEN_EXCHANGE\x10\x02*\xc3\x03\n" + "!ProviderCredentialRefreshStrategy\x124\n" + "0PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED\x10\x00\x12/\n" + "+PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC\x10\x01\x121\n" + @@ -14627,7 +14930,7 @@ const file_openshell_proto_rawDesc = "" + "\rWorkspaceRole\x12\x1e\n" + "\x1aWORKSPACE_ROLE_UNSPECIFIED\x10\x00\x12\x17\n" + "\x13WORKSPACE_ROLE_USER\x10\x01\x12\x18\n" + - "\x14WORKSPACE_ROLE_ADMIN\x10\x022\x95E\n" + + "\x14WORKSPACE_ROLE_ADMIN\x10\x022\xacF\n" + "\tOpenShell\x12Z\n" + "\x06Health\x12\x1b.openshell.v1.HealthRequest\x1a\x1c.openshell.v1.HealthResponse\"\x15\x82\xb5\x18\x11\n" + "\x0funauthenticated\x12i\n" + @@ -14717,6 +15020,8 @@ const file_openshell_proto_rawDesc = "" + "\x12ReportPolicyStatus\x12'.openshell.v1.ReportPolicyStatusRequest\x1a(.openshell.v1.ReportPolicyStatusResponse\"\r\x82\xb5\x18\t\n" + "\asandbox\x12\x97\x01\n" + "\x1dGetSandboxProviderEnvironment\x122.openshell.v1.GetSandboxProviderEnvironmentRequest\x1a3.openshell.v1.GetSandboxProviderEnvironmentResponse\"\r\x82\xb5\x18\t\n" + + "\asandbox\x12\x94\x01\n" + + "\x1cExchangeProviderSubjectToken\x121.openshell.v1.ExchangeProviderSubjectTokenRequest\x1a2.openshell.v1.ExchangeProviderSubjectTokenResponse\"\r\x82\xb5\x18\t\n" + "\asandbox\x12}\n" + "\x0eGetSandboxLogs\x12#.openshell.v1.GetSandboxLogsRequest\x1a$.openshell.v1.GetSandboxLogsResponse\" \x82\xb5\x18\x1c\n" + "\x06bearer\x12\x04user\"\fsandbox:read\x12o\n" + @@ -14779,548 +15084,556 @@ func file_openshell_proto_rawDescGZIP() []byte { return file_openshell_proto_rawDescData } -var file_openshell_proto_enumTypes = make([]protoimpl.EnumInfo, 6) -var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 214) +var file_openshell_proto_enumTypes = make([]protoimpl.EnumInfo, 7) +var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 217) var file_openshell_proto_goTypes = []any{ (SandboxPhase)(0), // 0: openshell.v1.SandboxPhase - (ProviderCredentialRefreshStrategy)(0), // 1: openshell.v1.ProviderCredentialRefreshStrategy - (ProviderProfileCategory)(0), // 2: openshell.v1.ProviderProfileCategory - (PolicyStatus)(0), // 3: openshell.v1.PolicyStatus - (ServiceStatus)(0), // 4: openshell.v1.ServiceStatus - (WorkspaceRole)(0), // 5: openshell.v1.WorkspaceRole - (*IssueSandboxTokenRequest)(nil), // 6: openshell.v1.IssueSandboxTokenRequest - (*IssueSandboxTokenResponse)(nil), // 7: openshell.v1.IssueSandboxTokenResponse - (*RefreshSandboxTokenRequest)(nil), // 8: openshell.v1.RefreshSandboxTokenRequest - (*RefreshSandboxTokenResponse)(nil), // 9: openshell.v1.RefreshSandboxTokenResponse - (*HealthRequest)(nil), // 10: openshell.v1.HealthRequest - (*HealthResponse)(nil), // 11: openshell.v1.HealthResponse - (*GetCurrentUserRequest)(nil), // 12: openshell.v1.GetCurrentUserRequest - (*GetCurrentUserResponse)(nil), // 13: openshell.v1.GetCurrentUserResponse - (*GetGatewayInfoRequest)(nil), // 14: openshell.v1.GetGatewayInfoRequest - (*GetGatewayInfoResponse)(nil), // 15: openshell.v1.GetGatewayInfoResponse - (*ComputeDriverInfo)(nil), // 16: openshell.v1.ComputeDriverInfo - (*ComputeDriverCapabilities)(nil), // 17: openshell.v1.ComputeDriverCapabilities - (*Sandbox)(nil), // 18: openshell.v1.Sandbox - (*SandboxSpec)(nil), // 19: openshell.v1.SandboxSpec - (*ResourceRequirements)(nil), // 20: openshell.v1.ResourceRequirements - (*GpuResourceRequirements)(nil), // 21: openshell.v1.GpuResourceRequirements - (*SandboxTemplate)(nil), // 22: openshell.v1.SandboxTemplate - (*SandboxStatus)(nil), // 23: openshell.v1.SandboxStatus - (*SandboxCondition)(nil), // 24: openshell.v1.SandboxCondition - (*PlatformEvent)(nil), // 25: openshell.v1.PlatformEvent - (*CreateSandboxRequest)(nil), // 26: openshell.v1.CreateSandboxRequest - (*GetSandboxRequest)(nil), // 27: openshell.v1.GetSandboxRequest - (*ListSandboxesRequest)(nil), // 28: openshell.v1.ListSandboxesRequest - (*ListSandboxProvidersRequest)(nil), // 29: openshell.v1.ListSandboxProvidersRequest - (*AttachSandboxProviderRequest)(nil), // 30: openshell.v1.AttachSandboxProviderRequest - (*DetachSandboxProviderRequest)(nil), // 31: openshell.v1.DetachSandboxProviderRequest - (*DeleteSandboxRequest)(nil), // 32: openshell.v1.DeleteSandboxRequest - (*StopSandboxRequest)(nil), // 33: openshell.v1.StopSandboxRequest - (*StartSandboxRequest)(nil), // 34: openshell.v1.StartSandboxRequest - (*SandboxResponse)(nil), // 35: openshell.v1.SandboxResponse - (*ListSandboxesResponse)(nil), // 36: openshell.v1.ListSandboxesResponse - (*ListSandboxProvidersResponse)(nil), // 37: openshell.v1.ListSandboxProvidersResponse - (*AttachSandboxProviderResponse)(nil), // 38: openshell.v1.AttachSandboxProviderResponse - (*DetachSandboxProviderResponse)(nil), // 39: openshell.v1.DetachSandboxProviderResponse - (*DeleteSandboxResponse)(nil), // 40: openshell.v1.DeleteSandboxResponse - (*CreateSshSessionRequest)(nil), // 41: openshell.v1.CreateSshSessionRequest - (*CreateSshSessionResponse)(nil), // 42: openshell.v1.CreateSshSessionResponse - (*ExposeServiceRequest)(nil), // 43: openshell.v1.ExposeServiceRequest - (*GetServiceRequest)(nil), // 44: openshell.v1.GetServiceRequest - (*ListServicesRequest)(nil), // 45: openshell.v1.ListServicesRequest - (*ListServicesResponse)(nil), // 46: openshell.v1.ListServicesResponse - (*DeleteServiceRequest)(nil), // 47: openshell.v1.DeleteServiceRequest - (*DeleteServiceResponse)(nil), // 48: openshell.v1.DeleteServiceResponse - (*ServiceEndpoint)(nil), // 49: openshell.v1.ServiceEndpoint - (*ServiceEndpointResponse)(nil), // 50: openshell.v1.ServiceEndpointResponse - (*RevokeSshSessionRequest)(nil), // 51: openshell.v1.RevokeSshSessionRequest - (*RevokeSshSessionResponse)(nil), // 52: openshell.v1.RevokeSshSessionResponse - (*ExecSandboxRequest)(nil), // 53: openshell.v1.ExecSandboxRequest - (*ExecSandboxStdout)(nil), // 54: openshell.v1.ExecSandboxStdout - (*ExecSandboxStderr)(nil), // 55: openshell.v1.ExecSandboxStderr - (*ExecSandboxExit)(nil), // 56: openshell.v1.ExecSandboxExit - (*ExecSandboxEvent)(nil), // 57: openshell.v1.ExecSandboxEvent - (*TcpForwardInit)(nil), // 58: openshell.v1.TcpForwardInit - (*TcpForwardFrame)(nil), // 59: openshell.v1.TcpForwardFrame - (*ExecSandboxInput)(nil), // 60: openshell.v1.ExecSandboxInput - (*ExecSandboxWindowResize)(nil), // 61: openshell.v1.ExecSandboxWindowResize - (*SshSession)(nil), // 62: openshell.v1.SshSession - (*WatchSandboxRequest)(nil), // 63: openshell.v1.WatchSandboxRequest - (*SandboxStreamEvent)(nil), // 64: openshell.v1.SandboxStreamEvent - (*SandboxLogLine)(nil), // 65: openshell.v1.SandboxLogLine - (*SandboxStreamWarning)(nil), // 66: openshell.v1.SandboxStreamWarning - (*CreateProviderRequest)(nil), // 67: openshell.v1.CreateProviderRequest - (*GetProviderRequest)(nil), // 68: openshell.v1.GetProviderRequest - (*ListProvidersRequest)(nil), // 69: openshell.v1.ListProvidersRequest - (*UpdateProviderRequest)(nil), // 70: openshell.v1.UpdateProviderRequest - (*DeleteProviderRequest)(nil), // 71: openshell.v1.DeleteProviderRequest - (*ProviderResponse)(nil), // 72: openshell.v1.ProviderResponse - (*ListProvidersResponse)(nil), // 73: openshell.v1.ListProvidersResponse - (*ListProviderProfilesRequest)(nil), // 74: openshell.v1.ListProviderProfilesRequest - (*GetProviderProfileRequest)(nil), // 75: openshell.v1.GetProviderProfileRequest - (*ProviderProfileImportItem)(nil), // 76: openshell.v1.ProviderProfileImportItem - (*ProviderProfileDiagnostic)(nil), // 77: openshell.v1.ProviderProfileDiagnostic - (*ProviderCredentialTokenGrantAudienceOverride)(nil), // 78: openshell.v1.ProviderCredentialTokenGrantAudienceOverride - (*ProviderCredentialTokenGrant)(nil), // 79: openshell.v1.ProviderCredentialTokenGrant - (*ProviderProfileCredential)(nil), // 80: openshell.v1.ProviderProfileCredential - (*ProviderCredentialRefreshMaterial)(nil), // 81: openshell.v1.ProviderCredentialRefreshMaterial - (*ProviderCredentialRefreshOutput)(nil), // 82: openshell.v1.ProviderCredentialRefreshOutput - (*ProviderCredentialRefresh)(nil), // 83: openshell.v1.ProviderCredentialRefresh - (*ProviderCredentialRefreshStatus)(nil), // 84: openshell.v1.ProviderCredentialRefreshStatus - (*ProviderProfileDiscovery)(nil), // 85: openshell.v1.ProviderProfileDiscovery - (*StoredProviderCredentialRefreshState)(nil), // 86: openshell.v1.StoredProviderCredentialRefreshState - (*StoredRefreshMaterialDeletion)(nil), // 87: openshell.v1.StoredRefreshMaterialDeletion - (*GetProviderRefreshStatusRequest)(nil), // 88: openshell.v1.GetProviderRefreshStatusRequest - (*GetProviderRefreshStatusResponse)(nil), // 89: openshell.v1.GetProviderRefreshStatusResponse - (*ConfigureProviderRefreshRequest)(nil), // 90: openshell.v1.ConfigureProviderRefreshRequest - (*ConfigureProviderRefreshResponse)(nil), // 91: openshell.v1.ConfigureProviderRefreshResponse - (*RotateProviderCredentialRequest)(nil), // 92: openshell.v1.RotateProviderCredentialRequest - (*RotateProviderCredentialResponse)(nil), // 93: openshell.v1.RotateProviderCredentialResponse - (*DeleteProviderRefreshRequest)(nil), // 94: openshell.v1.DeleteProviderRefreshRequest - (*DeleteProviderRefreshResponse)(nil), // 95: openshell.v1.DeleteProviderRefreshResponse - (*ProviderProfile)(nil), // 96: openshell.v1.ProviderProfile - (*StoredProviderProfile)(nil), // 97: openshell.v1.StoredProviderProfile - (*ProviderProfileResponse)(nil), // 98: openshell.v1.ProviderProfileResponse - (*ListProviderProfilesResponse)(nil), // 99: openshell.v1.ListProviderProfilesResponse - (*ImportProviderProfilesRequest)(nil), // 100: openshell.v1.ImportProviderProfilesRequest - (*ImportProviderProfilesResponse)(nil), // 101: openshell.v1.ImportProviderProfilesResponse - (*UpdateProviderProfilesRequest)(nil), // 102: openshell.v1.UpdateProviderProfilesRequest - (*UpdateProviderProfilesResponse)(nil), // 103: openshell.v1.UpdateProviderProfilesResponse - (*LintProviderProfilesRequest)(nil), // 104: openshell.v1.LintProviderProfilesRequest - (*LintProviderProfilesResponse)(nil), // 105: openshell.v1.LintProviderProfilesResponse - (*DeleteProviderResponse)(nil), // 106: openshell.v1.DeleteProviderResponse - (*DeleteProviderProfileRequest)(nil), // 107: openshell.v1.DeleteProviderProfileRequest - (*DeleteProviderProfileResponse)(nil), // 108: openshell.v1.DeleteProviderProfileResponse - (*GetSandboxProviderEnvironmentRequest)(nil), // 109: openshell.v1.GetSandboxProviderEnvironmentRequest - (*StaticCredentialEndpointBinding)(nil), // 110: openshell.v1.StaticCredentialEndpointBinding - (*StaticCredentialBinding)(nil), // 111: openshell.v1.StaticCredentialBinding - (*GetSandboxProviderEnvironmentResponse)(nil), // 112: openshell.v1.GetSandboxProviderEnvironmentResponse - (*UpdateConfigRequest)(nil), // 113: openshell.v1.UpdateConfigRequest - (*PolicyMergeOperation)(nil), // 114: openshell.v1.PolicyMergeOperation - (*AddNetworkRule)(nil), // 115: openshell.v1.AddNetworkRule - (*RemoveNetworkEndpoint)(nil), // 116: openshell.v1.RemoveNetworkEndpoint - (*RemoveNetworkRule)(nil), // 117: openshell.v1.RemoveNetworkRule - (*AddDenyRules)(nil), // 118: openshell.v1.AddDenyRules - (*AddAllowRules)(nil), // 119: openshell.v1.AddAllowRules - (*RemoveNetworkBinary)(nil), // 120: openshell.v1.RemoveNetworkBinary - (*UpdateConfigResponse)(nil), // 121: openshell.v1.UpdateConfigResponse - (*GetSandboxPolicyStatusRequest)(nil), // 122: openshell.v1.GetSandboxPolicyStatusRequest - (*GetSandboxPolicyStatusResponse)(nil), // 123: openshell.v1.GetSandboxPolicyStatusResponse - (*ListSandboxPoliciesRequest)(nil), // 124: openshell.v1.ListSandboxPoliciesRequest - (*ListSandboxPoliciesResponse)(nil), // 125: openshell.v1.ListSandboxPoliciesResponse - (*ReportPolicyStatusRequest)(nil), // 126: openshell.v1.ReportPolicyStatusRequest - (*ReportPolicyStatusResponse)(nil), // 127: openshell.v1.ReportPolicyStatusResponse - (*SandboxPolicyRevision)(nil), // 128: openshell.v1.SandboxPolicyRevision - (*GetSandboxLogsRequest)(nil), // 129: openshell.v1.GetSandboxLogsRequest - (*PushSandboxLogsRequest)(nil), // 130: openshell.v1.PushSandboxLogsRequest - (*PushSandboxLogsResponse)(nil), // 131: openshell.v1.PushSandboxLogsResponse - (*GetSandboxLogsResponse)(nil), // 132: openshell.v1.GetSandboxLogsResponse - (*SupervisorMessage)(nil), // 133: openshell.v1.SupervisorMessage - (*GatewayMessage)(nil), // 134: openshell.v1.GatewayMessage - (*SupervisorHello)(nil), // 135: openshell.v1.SupervisorHello - (*SessionAccepted)(nil), // 136: openshell.v1.SessionAccepted - (*SessionRejected)(nil), // 137: openshell.v1.SessionRejected - (*SupervisorHeartbeat)(nil), // 138: openshell.v1.SupervisorHeartbeat - (*GatewayHeartbeat)(nil), // 139: openshell.v1.GatewayHeartbeat - (*ReportMainProcessExitRequest)(nil), // 140: openshell.v1.ReportMainProcessExitRequest - (*ReportMainProcessExitResponse)(nil), // 141: openshell.v1.ReportMainProcessExitResponse - (*RelayOpen)(nil), // 142: openshell.v1.RelayOpen - (*SshRelayTarget)(nil), // 143: openshell.v1.SshRelayTarget - (*TcpRelayTarget)(nil), // 144: openshell.v1.TcpRelayTarget - (*RelayInit)(nil), // 145: openshell.v1.RelayInit - (*RelayFrame)(nil), // 146: openshell.v1.RelayFrame - (*RelayOpenResult)(nil), // 147: openshell.v1.RelayOpenResult - (*RelayClose)(nil), // 148: openshell.v1.RelayClose - (*L7RequestSample)(nil), // 149: openshell.v1.L7RequestSample - (*DenialSummary)(nil), // 150: openshell.v1.DenialSummary - (*DenialGroupCount)(nil), // 151: openshell.v1.DenialGroupCount - (*NetworkActivitySummary)(nil), // 152: openshell.v1.NetworkActivitySummary - (*PolicyChunk)(nil), // 153: openshell.v1.PolicyChunk - (*DraftPolicyUpdate)(nil), // 154: openshell.v1.DraftPolicyUpdate - (*SubmitPolicyAnalysisRequest)(nil), // 155: openshell.v1.SubmitPolicyAnalysisRequest - (*SubmitPolicyAnalysisResponse)(nil), // 156: openshell.v1.SubmitPolicyAnalysisResponse - (*GetDraftPolicyRequest)(nil), // 157: openshell.v1.GetDraftPolicyRequest - (*GetDraftPolicyResponse)(nil), // 158: openshell.v1.GetDraftPolicyResponse - (*ApproveDraftChunkRequest)(nil), // 159: openshell.v1.ApproveDraftChunkRequest - (*ApproveDraftChunkResponse)(nil), // 160: openshell.v1.ApproveDraftChunkResponse - (*RejectDraftChunkRequest)(nil), // 161: openshell.v1.RejectDraftChunkRequest - (*RejectDraftChunkResponse)(nil), // 162: openshell.v1.RejectDraftChunkResponse - (*DraftChunkApproval)(nil), // 163: openshell.v1.DraftChunkApproval - (*ApproveAllDraftChunksRequest)(nil), // 164: openshell.v1.ApproveAllDraftChunksRequest - (*ApproveAllDraftChunksResponse)(nil), // 165: openshell.v1.ApproveAllDraftChunksResponse - (*EditDraftChunkRequest)(nil), // 166: openshell.v1.EditDraftChunkRequest - (*EditDraftChunkResponse)(nil), // 167: openshell.v1.EditDraftChunkResponse - (*UndoDraftChunkRequest)(nil), // 168: openshell.v1.UndoDraftChunkRequest - (*UndoDraftChunkResponse)(nil), // 169: openshell.v1.UndoDraftChunkResponse - (*ClearDraftChunksRequest)(nil), // 170: openshell.v1.ClearDraftChunksRequest - (*ClearDraftChunksResponse)(nil), // 171: openshell.v1.ClearDraftChunksResponse - (*GetDraftHistoryRequest)(nil), // 172: openshell.v1.GetDraftHistoryRequest - (*DraftHistoryEntry)(nil), // 173: openshell.v1.DraftHistoryEntry - (*GetDraftHistoryResponse)(nil), // 174: openshell.v1.GetDraftHistoryResponse - (*PolicyRevisionPayload)(nil), // 175: openshell.v1.PolicyRevisionPayload - (*DraftChunkPayload)(nil), // 176: openshell.v1.DraftChunkPayload - (*StoredPolicyRevision)(nil), // 177: openshell.v1.StoredPolicyRevision - (*StoredDraftChunk)(nil), // 178: openshell.v1.StoredDraftChunk - (*CreateWorkspaceRequest)(nil), // 179: openshell.v1.CreateWorkspaceRequest - (*CreateWorkspaceResponse)(nil), // 180: openshell.v1.CreateWorkspaceResponse - (*GetWorkspaceRequest)(nil), // 181: openshell.v1.GetWorkspaceRequest - (*GetWorkspaceResponse)(nil), // 182: openshell.v1.GetWorkspaceResponse - (*ListWorkspacesRequest)(nil), // 183: openshell.v1.ListWorkspacesRequest - (*ListWorkspacesResponse)(nil), // 184: openshell.v1.ListWorkspacesResponse - (*DeleteWorkspaceRequest)(nil), // 185: openshell.v1.DeleteWorkspaceRequest - (*DeleteWorkspaceResponse)(nil), // 186: openshell.v1.DeleteWorkspaceResponse - (*WorkspaceMember)(nil), // 187: openshell.v1.WorkspaceMember - (*AddWorkspaceMemberRequest)(nil), // 188: openshell.v1.AddWorkspaceMemberRequest - (*AddWorkspaceMemberResponse)(nil), // 189: openshell.v1.AddWorkspaceMemberResponse - (*RemoveWorkspaceMemberRequest)(nil), // 190: openshell.v1.RemoveWorkspaceMemberRequest - (*RemoveWorkspaceMemberResponse)(nil), // 191: openshell.v1.RemoveWorkspaceMemberResponse - (*ListWorkspaceMembersRequest)(nil), // 192: openshell.v1.ListWorkspaceMembersRequest - (*ListWorkspaceMembersResponse)(nil), // 193: openshell.v1.ListWorkspaceMembersResponse - (*ExtensionServiceCredential)(nil), // 194: openshell.v1.ExtensionServiceCredential - nil, // 195: openshell.v1.SandboxSpec.EnvironmentEntry - nil, // 196: openshell.v1.SandboxTemplate.LabelsEntry - nil, // 197: openshell.v1.SandboxTemplate.AnnotationsEntry - nil, // 198: openshell.v1.SandboxTemplate.EnvironmentEntry - nil, // 199: openshell.v1.PlatformEvent.MetadataEntry - nil, // 200: openshell.v1.CreateSandboxRequest.LabelsEntry - nil, // 201: openshell.v1.CreateSandboxRequest.AnnotationsEntry - nil, // 202: openshell.v1.ExecSandboxRequest.EnvironmentEntry - nil, // 203: openshell.v1.SandboxLogLine.FieldsEntry - nil, // 204: openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry - nil, // 205: openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry - nil, // 206: openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry - nil, // 207: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry - nil, // 208: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry - nil, // 209: openshell.v1.ProviderProfile.AnnotationsEntry - nil, // 210: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - nil, // 211: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry - nil, // 212: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - nil, // 213: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry - nil, // 214: openshell.v1.UpdateConfigRequest.AnnotationsEntry - nil, // 215: openshell.v1.UpdateConfigResponse.AnnotationsEntry - nil, // 216: openshell.v1.SandboxPolicyRevision.ProvenanceEntry - nil, // 217: openshell.v1.PolicyRevisionPayload.ProvenanceEntry - nil, // 218: openshell.v1.StoredPolicyRevision.ProvenanceEntry - nil, // 219: openshell.v1.CreateWorkspaceRequest.LabelsEntry - (*datamodelv1.ObjectMeta)(nil), // 220: openshell.datamodel.v1.ObjectMeta - (*sandboxv1.SandboxPolicy)(nil), // 221: openshell.sandbox.v1.SandboxPolicy - (*structpb.Struct)(nil), // 222: google.protobuf.Struct - (*datamodelv1.Provider)(nil), // 223: openshell.datamodel.v1.Provider - (*datamodelv1.CredentialHandle)(nil), // 224: openshell.datamodel.v1.CredentialHandle - (*sandboxv1.NetworkEndpoint)(nil), // 225: openshell.sandbox.v1.NetworkEndpoint - (*sandboxv1.NetworkBinary)(nil), // 226: openshell.sandbox.v1.NetworkBinary - (*sandboxv1.SettingValue)(nil), // 227: openshell.sandbox.v1.SettingValue - (*sandboxv1.NetworkPolicyRule)(nil), // 228: openshell.sandbox.v1.NetworkPolicyRule - (*sandboxv1.L7DenyRule)(nil), // 229: openshell.sandbox.v1.L7DenyRule - (*sandboxv1.L7Rule)(nil), // 230: openshell.sandbox.v1.L7Rule - (*datamodelv1.Workspace)(nil), // 231: openshell.datamodel.v1.Workspace - (*sandboxv1.GetSandboxConfigRequest)(nil), // 232: openshell.sandbox.v1.GetSandboxConfigRequest - (*sandboxv1.GetGatewayConfigRequest)(nil), // 233: openshell.sandbox.v1.GetGatewayConfigRequest - (*sandboxv1.GetSandboxConfigResponse)(nil), // 234: openshell.sandbox.v1.GetSandboxConfigResponse - (*sandboxv1.GetGatewayConfigResponse)(nil), // 235: openshell.sandbox.v1.GetGatewayConfigResponse + (ProviderCredentialTokenGrantType)(0), // 1: openshell.v1.ProviderCredentialTokenGrantType + (ProviderCredentialRefreshStrategy)(0), // 2: openshell.v1.ProviderCredentialRefreshStrategy + (ProviderProfileCategory)(0), // 3: openshell.v1.ProviderProfileCategory + (PolicyStatus)(0), // 4: openshell.v1.PolicyStatus + (ServiceStatus)(0), // 5: openshell.v1.ServiceStatus + (WorkspaceRole)(0), // 6: openshell.v1.WorkspaceRole + (*IssueSandboxTokenRequest)(nil), // 7: openshell.v1.IssueSandboxTokenRequest + (*IssueSandboxTokenResponse)(nil), // 8: openshell.v1.IssueSandboxTokenResponse + (*RefreshSandboxTokenRequest)(nil), // 9: openshell.v1.RefreshSandboxTokenRequest + (*RefreshSandboxTokenResponse)(nil), // 10: openshell.v1.RefreshSandboxTokenResponse + (*HealthRequest)(nil), // 11: openshell.v1.HealthRequest + (*HealthResponse)(nil), // 12: openshell.v1.HealthResponse + (*GetCurrentUserRequest)(nil), // 13: openshell.v1.GetCurrentUserRequest + (*GetCurrentUserResponse)(nil), // 14: openshell.v1.GetCurrentUserResponse + (*GetGatewayInfoRequest)(nil), // 15: openshell.v1.GetGatewayInfoRequest + (*GetGatewayInfoResponse)(nil), // 16: openshell.v1.GetGatewayInfoResponse + (*ComputeDriverInfo)(nil), // 17: openshell.v1.ComputeDriverInfo + (*ComputeDriverCapabilities)(nil), // 18: openshell.v1.ComputeDriverCapabilities + (*Sandbox)(nil), // 19: openshell.v1.Sandbox + (*SandboxSpec)(nil), // 20: openshell.v1.SandboxSpec + (*ResourceRequirements)(nil), // 21: openshell.v1.ResourceRequirements + (*GpuResourceRequirements)(nil), // 22: openshell.v1.GpuResourceRequirements + (*SandboxTemplate)(nil), // 23: openshell.v1.SandboxTemplate + (*SandboxStatus)(nil), // 24: openshell.v1.SandboxStatus + (*SandboxCondition)(nil), // 25: openshell.v1.SandboxCondition + (*PlatformEvent)(nil), // 26: openshell.v1.PlatformEvent + (*CreateSandboxRequest)(nil), // 27: openshell.v1.CreateSandboxRequest + (*GetSandboxRequest)(nil), // 28: openshell.v1.GetSandboxRequest + (*ListSandboxesRequest)(nil), // 29: openshell.v1.ListSandboxesRequest + (*ListSandboxProvidersRequest)(nil), // 30: openshell.v1.ListSandboxProvidersRequest + (*AttachSandboxProviderRequest)(nil), // 31: openshell.v1.AttachSandboxProviderRequest + (*DetachSandboxProviderRequest)(nil), // 32: openshell.v1.DetachSandboxProviderRequest + (*DeleteSandboxRequest)(nil), // 33: openshell.v1.DeleteSandboxRequest + (*StopSandboxRequest)(nil), // 34: openshell.v1.StopSandboxRequest + (*StartSandboxRequest)(nil), // 35: openshell.v1.StartSandboxRequest + (*SandboxResponse)(nil), // 36: openshell.v1.SandboxResponse + (*ListSandboxesResponse)(nil), // 37: openshell.v1.ListSandboxesResponse + (*ListSandboxProvidersResponse)(nil), // 38: openshell.v1.ListSandboxProvidersResponse + (*AttachSandboxProviderResponse)(nil), // 39: openshell.v1.AttachSandboxProviderResponse + (*DetachSandboxProviderResponse)(nil), // 40: openshell.v1.DetachSandboxProviderResponse + (*DeleteSandboxResponse)(nil), // 41: openshell.v1.DeleteSandboxResponse + (*CreateSshSessionRequest)(nil), // 42: openshell.v1.CreateSshSessionRequest + (*CreateSshSessionResponse)(nil), // 43: openshell.v1.CreateSshSessionResponse + (*ExposeServiceRequest)(nil), // 44: openshell.v1.ExposeServiceRequest + (*GetServiceRequest)(nil), // 45: openshell.v1.GetServiceRequest + (*ListServicesRequest)(nil), // 46: openshell.v1.ListServicesRequest + (*ListServicesResponse)(nil), // 47: openshell.v1.ListServicesResponse + (*DeleteServiceRequest)(nil), // 48: openshell.v1.DeleteServiceRequest + (*DeleteServiceResponse)(nil), // 49: openshell.v1.DeleteServiceResponse + (*ServiceEndpoint)(nil), // 50: openshell.v1.ServiceEndpoint + (*ServiceEndpointResponse)(nil), // 51: openshell.v1.ServiceEndpointResponse + (*RevokeSshSessionRequest)(nil), // 52: openshell.v1.RevokeSshSessionRequest + (*RevokeSshSessionResponse)(nil), // 53: openshell.v1.RevokeSshSessionResponse + (*ExecSandboxRequest)(nil), // 54: openshell.v1.ExecSandboxRequest + (*ExecSandboxStdout)(nil), // 55: openshell.v1.ExecSandboxStdout + (*ExecSandboxStderr)(nil), // 56: openshell.v1.ExecSandboxStderr + (*ExecSandboxExit)(nil), // 57: openshell.v1.ExecSandboxExit + (*ExecSandboxEvent)(nil), // 58: openshell.v1.ExecSandboxEvent + (*TcpForwardInit)(nil), // 59: openshell.v1.TcpForwardInit + (*TcpForwardFrame)(nil), // 60: openshell.v1.TcpForwardFrame + (*ExecSandboxInput)(nil), // 61: openshell.v1.ExecSandboxInput + (*ExecSandboxWindowResize)(nil), // 62: openshell.v1.ExecSandboxWindowResize + (*SshSession)(nil), // 63: openshell.v1.SshSession + (*WatchSandboxRequest)(nil), // 64: openshell.v1.WatchSandboxRequest + (*SandboxStreamEvent)(nil), // 65: openshell.v1.SandboxStreamEvent + (*SandboxLogLine)(nil), // 66: openshell.v1.SandboxLogLine + (*SandboxStreamWarning)(nil), // 67: openshell.v1.SandboxStreamWarning + (*CreateProviderRequest)(nil), // 68: openshell.v1.CreateProviderRequest + (*GetProviderRequest)(nil), // 69: openshell.v1.GetProviderRequest + (*ListProvidersRequest)(nil), // 70: openshell.v1.ListProvidersRequest + (*UpdateProviderRequest)(nil), // 71: openshell.v1.UpdateProviderRequest + (*DeleteProviderRequest)(nil), // 72: openshell.v1.DeleteProviderRequest + (*ProviderResponse)(nil), // 73: openshell.v1.ProviderResponse + (*ListProvidersResponse)(nil), // 74: openshell.v1.ListProvidersResponse + (*ListProviderProfilesRequest)(nil), // 75: openshell.v1.ListProviderProfilesRequest + (*GetProviderProfileRequest)(nil), // 76: openshell.v1.GetProviderProfileRequest + (*ProviderProfileImportItem)(nil), // 77: openshell.v1.ProviderProfileImportItem + (*ProviderProfileDiagnostic)(nil), // 78: openshell.v1.ProviderProfileDiagnostic + (*ProviderCredentialTokenGrantAudienceOverride)(nil), // 79: openshell.v1.ProviderCredentialTokenGrantAudienceOverride + (*ProviderCredentialTokenGrantSubjectToken)(nil), // 80: openshell.v1.ProviderCredentialTokenGrantSubjectToken + (*ProviderCredentialTokenGrant)(nil), // 81: openshell.v1.ProviderCredentialTokenGrant + (*ProviderProfileCredential)(nil), // 82: openshell.v1.ProviderProfileCredential + (*ProviderCredentialRefreshMaterial)(nil), // 83: openshell.v1.ProviderCredentialRefreshMaterial + (*ProviderCredentialRefreshOutput)(nil), // 84: openshell.v1.ProviderCredentialRefreshOutput + (*ProviderCredentialRefresh)(nil), // 85: openshell.v1.ProviderCredentialRefresh + (*ProviderCredentialRefreshStatus)(nil), // 86: openshell.v1.ProviderCredentialRefreshStatus + (*ProviderProfileDiscovery)(nil), // 87: openshell.v1.ProviderProfileDiscovery + (*StoredProviderCredentialRefreshState)(nil), // 88: openshell.v1.StoredProviderCredentialRefreshState + (*StoredRefreshMaterialDeletion)(nil), // 89: openshell.v1.StoredRefreshMaterialDeletion + (*GetProviderRefreshStatusRequest)(nil), // 90: openshell.v1.GetProviderRefreshStatusRequest + (*GetProviderRefreshStatusResponse)(nil), // 91: openshell.v1.GetProviderRefreshStatusResponse + (*ConfigureProviderRefreshRequest)(nil), // 92: openshell.v1.ConfigureProviderRefreshRequest + (*ConfigureProviderRefreshResponse)(nil), // 93: openshell.v1.ConfigureProviderRefreshResponse + (*RotateProviderCredentialRequest)(nil), // 94: openshell.v1.RotateProviderCredentialRequest + (*RotateProviderCredentialResponse)(nil), // 95: openshell.v1.RotateProviderCredentialResponse + (*DeleteProviderRefreshRequest)(nil), // 96: openshell.v1.DeleteProviderRefreshRequest + (*DeleteProviderRefreshResponse)(nil), // 97: openshell.v1.DeleteProviderRefreshResponse + (*ProviderProfile)(nil), // 98: openshell.v1.ProviderProfile + (*StoredProviderProfile)(nil), // 99: openshell.v1.StoredProviderProfile + (*ProviderProfileResponse)(nil), // 100: openshell.v1.ProviderProfileResponse + (*ListProviderProfilesResponse)(nil), // 101: openshell.v1.ListProviderProfilesResponse + (*ImportProviderProfilesRequest)(nil), // 102: openshell.v1.ImportProviderProfilesRequest + (*ImportProviderProfilesResponse)(nil), // 103: openshell.v1.ImportProviderProfilesResponse + (*UpdateProviderProfilesRequest)(nil), // 104: openshell.v1.UpdateProviderProfilesRequest + (*UpdateProviderProfilesResponse)(nil), // 105: openshell.v1.UpdateProviderProfilesResponse + (*LintProviderProfilesRequest)(nil), // 106: openshell.v1.LintProviderProfilesRequest + (*LintProviderProfilesResponse)(nil), // 107: openshell.v1.LintProviderProfilesResponse + (*DeleteProviderResponse)(nil), // 108: openshell.v1.DeleteProviderResponse + (*DeleteProviderProfileRequest)(nil), // 109: openshell.v1.DeleteProviderProfileRequest + (*DeleteProviderProfileResponse)(nil), // 110: openshell.v1.DeleteProviderProfileResponse + (*GetSandboxProviderEnvironmentRequest)(nil), // 111: openshell.v1.GetSandboxProviderEnvironmentRequest + (*StaticCredentialEndpointBinding)(nil), // 112: openshell.v1.StaticCredentialEndpointBinding + (*StaticCredentialBinding)(nil), // 113: openshell.v1.StaticCredentialBinding + (*GetSandboxProviderEnvironmentResponse)(nil), // 114: openshell.v1.GetSandboxProviderEnvironmentResponse + (*ExchangeProviderSubjectTokenRequest)(nil), // 115: openshell.v1.ExchangeProviderSubjectTokenRequest + (*ExchangeProviderSubjectTokenResponse)(nil), // 116: openshell.v1.ExchangeProviderSubjectTokenResponse + (*UpdateConfigRequest)(nil), // 117: openshell.v1.UpdateConfigRequest + (*PolicyMergeOperation)(nil), // 118: openshell.v1.PolicyMergeOperation + (*AddNetworkRule)(nil), // 119: openshell.v1.AddNetworkRule + (*RemoveNetworkEndpoint)(nil), // 120: openshell.v1.RemoveNetworkEndpoint + (*RemoveNetworkRule)(nil), // 121: openshell.v1.RemoveNetworkRule + (*AddDenyRules)(nil), // 122: openshell.v1.AddDenyRules + (*AddAllowRules)(nil), // 123: openshell.v1.AddAllowRules + (*RemoveNetworkBinary)(nil), // 124: openshell.v1.RemoveNetworkBinary + (*UpdateConfigResponse)(nil), // 125: openshell.v1.UpdateConfigResponse + (*GetSandboxPolicyStatusRequest)(nil), // 126: openshell.v1.GetSandboxPolicyStatusRequest + (*GetSandboxPolicyStatusResponse)(nil), // 127: openshell.v1.GetSandboxPolicyStatusResponse + (*ListSandboxPoliciesRequest)(nil), // 128: openshell.v1.ListSandboxPoliciesRequest + (*ListSandboxPoliciesResponse)(nil), // 129: openshell.v1.ListSandboxPoliciesResponse + (*ReportPolicyStatusRequest)(nil), // 130: openshell.v1.ReportPolicyStatusRequest + (*ReportPolicyStatusResponse)(nil), // 131: openshell.v1.ReportPolicyStatusResponse + (*SandboxPolicyRevision)(nil), // 132: openshell.v1.SandboxPolicyRevision + (*GetSandboxLogsRequest)(nil), // 133: openshell.v1.GetSandboxLogsRequest + (*PushSandboxLogsRequest)(nil), // 134: openshell.v1.PushSandboxLogsRequest + (*PushSandboxLogsResponse)(nil), // 135: openshell.v1.PushSandboxLogsResponse + (*GetSandboxLogsResponse)(nil), // 136: openshell.v1.GetSandboxLogsResponse + (*SupervisorMessage)(nil), // 137: openshell.v1.SupervisorMessage + (*GatewayMessage)(nil), // 138: openshell.v1.GatewayMessage + (*SupervisorHello)(nil), // 139: openshell.v1.SupervisorHello + (*SessionAccepted)(nil), // 140: openshell.v1.SessionAccepted + (*SessionRejected)(nil), // 141: openshell.v1.SessionRejected + (*SupervisorHeartbeat)(nil), // 142: openshell.v1.SupervisorHeartbeat + (*GatewayHeartbeat)(nil), // 143: openshell.v1.GatewayHeartbeat + (*ReportMainProcessExitRequest)(nil), // 144: openshell.v1.ReportMainProcessExitRequest + (*ReportMainProcessExitResponse)(nil), // 145: openshell.v1.ReportMainProcessExitResponse + (*RelayOpen)(nil), // 146: openshell.v1.RelayOpen + (*SshRelayTarget)(nil), // 147: openshell.v1.SshRelayTarget + (*TcpRelayTarget)(nil), // 148: openshell.v1.TcpRelayTarget + (*RelayInit)(nil), // 149: openshell.v1.RelayInit + (*RelayFrame)(nil), // 150: openshell.v1.RelayFrame + (*RelayOpenResult)(nil), // 151: openshell.v1.RelayOpenResult + (*RelayClose)(nil), // 152: openshell.v1.RelayClose + (*L7RequestSample)(nil), // 153: openshell.v1.L7RequestSample + (*DenialSummary)(nil), // 154: openshell.v1.DenialSummary + (*DenialGroupCount)(nil), // 155: openshell.v1.DenialGroupCount + (*NetworkActivitySummary)(nil), // 156: openshell.v1.NetworkActivitySummary + (*PolicyChunk)(nil), // 157: openshell.v1.PolicyChunk + (*DraftPolicyUpdate)(nil), // 158: openshell.v1.DraftPolicyUpdate + (*SubmitPolicyAnalysisRequest)(nil), // 159: openshell.v1.SubmitPolicyAnalysisRequest + (*SubmitPolicyAnalysisResponse)(nil), // 160: openshell.v1.SubmitPolicyAnalysisResponse + (*GetDraftPolicyRequest)(nil), // 161: openshell.v1.GetDraftPolicyRequest + (*GetDraftPolicyResponse)(nil), // 162: openshell.v1.GetDraftPolicyResponse + (*ApproveDraftChunkRequest)(nil), // 163: openshell.v1.ApproveDraftChunkRequest + (*ApproveDraftChunkResponse)(nil), // 164: openshell.v1.ApproveDraftChunkResponse + (*RejectDraftChunkRequest)(nil), // 165: openshell.v1.RejectDraftChunkRequest + (*RejectDraftChunkResponse)(nil), // 166: openshell.v1.RejectDraftChunkResponse + (*DraftChunkApproval)(nil), // 167: openshell.v1.DraftChunkApproval + (*ApproveAllDraftChunksRequest)(nil), // 168: openshell.v1.ApproveAllDraftChunksRequest + (*ApproveAllDraftChunksResponse)(nil), // 169: openshell.v1.ApproveAllDraftChunksResponse + (*EditDraftChunkRequest)(nil), // 170: openshell.v1.EditDraftChunkRequest + (*EditDraftChunkResponse)(nil), // 171: openshell.v1.EditDraftChunkResponse + (*UndoDraftChunkRequest)(nil), // 172: openshell.v1.UndoDraftChunkRequest + (*UndoDraftChunkResponse)(nil), // 173: openshell.v1.UndoDraftChunkResponse + (*ClearDraftChunksRequest)(nil), // 174: openshell.v1.ClearDraftChunksRequest + (*ClearDraftChunksResponse)(nil), // 175: openshell.v1.ClearDraftChunksResponse + (*GetDraftHistoryRequest)(nil), // 176: openshell.v1.GetDraftHistoryRequest + (*DraftHistoryEntry)(nil), // 177: openshell.v1.DraftHistoryEntry + (*GetDraftHistoryResponse)(nil), // 178: openshell.v1.GetDraftHistoryResponse + (*PolicyRevisionPayload)(nil), // 179: openshell.v1.PolicyRevisionPayload + (*DraftChunkPayload)(nil), // 180: openshell.v1.DraftChunkPayload + (*StoredPolicyRevision)(nil), // 181: openshell.v1.StoredPolicyRevision + (*StoredDraftChunk)(nil), // 182: openshell.v1.StoredDraftChunk + (*CreateWorkspaceRequest)(nil), // 183: openshell.v1.CreateWorkspaceRequest + (*CreateWorkspaceResponse)(nil), // 184: openshell.v1.CreateWorkspaceResponse + (*GetWorkspaceRequest)(nil), // 185: openshell.v1.GetWorkspaceRequest + (*GetWorkspaceResponse)(nil), // 186: openshell.v1.GetWorkspaceResponse + (*ListWorkspacesRequest)(nil), // 187: openshell.v1.ListWorkspacesRequest + (*ListWorkspacesResponse)(nil), // 188: openshell.v1.ListWorkspacesResponse + (*DeleteWorkspaceRequest)(nil), // 189: openshell.v1.DeleteWorkspaceRequest + (*DeleteWorkspaceResponse)(nil), // 190: openshell.v1.DeleteWorkspaceResponse + (*WorkspaceMember)(nil), // 191: openshell.v1.WorkspaceMember + (*AddWorkspaceMemberRequest)(nil), // 192: openshell.v1.AddWorkspaceMemberRequest + (*AddWorkspaceMemberResponse)(nil), // 193: openshell.v1.AddWorkspaceMemberResponse + (*RemoveWorkspaceMemberRequest)(nil), // 194: openshell.v1.RemoveWorkspaceMemberRequest + (*RemoveWorkspaceMemberResponse)(nil), // 195: openshell.v1.RemoveWorkspaceMemberResponse + (*ListWorkspaceMembersRequest)(nil), // 196: openshell.v1.ListWorkspaceMembersRequest + (*ListWorkspaceMembersResponse)(nil), // 197: openshell.v1.ListWorkspaceMembersResponse + (*ExtensionServiceCredential)(nil), // 198: openshell.v1.ExtensionServiceCredential + nil, // 199: openshell.v1.SandboxSpec.EnvironmentEntry + nil, // 200: openshell.v1.SandboxTemplate.LabelsEntry + nil, // 201: openshell.v1.SandboxTemplate.AnnotationsEntry + nil, // 202: openshell.v1.SandboxTemplate.EnvironmentEntry + nil, // 203: openshell.v1.PlatformEvent.MetadataEntry + nil, // 204: openshell.v1.CreateSandboxRequest.LabelsEntry + nil, // 205: openshell.v1.CreateSandboxRequest.AnnotationsEntry + nil, // 206: openshell.v1.ExecSandboxRequest.EnvironmentEntry + nil, // 207: openshell.v1.SandboxLogLine.FieldsEntry + nil, // 208: openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry + nil, // 209: openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry + nil, // 210: openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry + nil, // 211: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry + nil, // 212: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + nil, // 213: openshell.v1.ProviderProfile.AnnotationsEntry + nil, // 214: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + nil, // 215: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry + nil, // 216: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + nil, // 217: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry + nil, // 218: openshell.v1.UpdateConfigRequest.AnnotationsEntry + nil, // 219: openshell.v1.UpdateConfigResponse.AnnotationsEntry + nil, // 220: openshell.v1.SandboxPolicyRevision.ProvenanceEntry + nil, // 221: openshell.v1.PolicyRevisionPayload.ProvenanceEntry + nil, // 222: openshell.v1.StoredPolicyRevision.ProvenanceEntry + nil, // 223: openshell.v1.CreateWorkspaceRequest.LabelsEntry + (*datamodelv1.ObjectMeta)(nil), // 224: openshell.datamodel.v1.ObjectMeta + (*sandboxv1.SandboxPolicy)(nil), // 225: openshell.sandbox.v1.SandboxPolicy + (*structpb.Struct)(nil), // 226: google.protobuf.Struct + (*datamodelv1.Provider)(nil), // 227: openshell.datamodel.v1.Provider + (*datamodelv1.CredentialHandle)(nil), // 228: openshell.datamodel.v1.CredentialHandle + (*sandboxv1.NetworkEndpoint)(nil), // 229: openshell.sandbox.v1.NetworkEndpoint + (*sandboxv1.NetworkBinary)(nil), // 230: openshell.sandbox.v1.NetworkBinary + (*sandboxv1.SettingValue)(nil), // 231: openshell.sandbox.v1.SettingValue + (*sandboxv1.NetworkPolicyRule)(nil), // 232: openshell.sandbox.v1.NetworkPolicyRule + (*sandboxv1.L7DenyRule)(nil), // 233: openshell.sandbox.v1.L7DenyRule + (*sandboxv1.L7Rule)(nil), // 234: openshell.sandbox.v1.L7Rule + (*datamodelv1.Workspace)(nil), // 235: openshell.datamodel.v1.Workspace + (*sandboxv1.GetSandboxConfigRequest)(nil), // 236: openshell.sandbox.v1.GetSandboxConfigRequest + (*sandboxv1.GetGatewayConfigRequest)(nil), // 237: openshell.sandbox.v1.GetGatewayConfigRequest + (*sandboxv1.GetSandboxConfigResponse)(nil), // 238: openshell.sandbox.v1.GetSandboxConfigResponse + (*sandboxv1.GetGatewayConfigResponse)(nil), // 239: openshell.sandbox.v1.GetGatewayConfigResponse } var file_openshell_proto_depIdxs = []int32{ - 194, // 0: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential - 4, // 1: openshell.v1.HealthResponse.status:type_name -> openshell.v1.ServiceStatus - 4, // 2: openshell.v1.GetGatewayInfoResponse.status:type_name -> openshell.v1.ServiceStatus - 16, // 3: openshell.v1.GetGatewayInfoResponse.compute_drivers:type_name -> openshell.v1.ComputeDriverInfo - 17, // 4: openshell.v1.ComputeDriverInfo.capabilities:type_name -> openshell.v1.ComputeDriverCapabilities - 220, // 5: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 19, // 6: openshell.v1.Sandbox.spec:type_name -> openshell.v1.SandboxSpec - 23, // 7: openshell.v1.Sandbox.status:type_name -> openshell.v1.SandboxStatus - 195, // 8: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry - 22, // 9: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate - 221, // 10: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 20, // 11: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements - 21, // 12: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements - 196, // 13: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry - 197, // 14: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry - 198, // 15: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry - 222, // 16: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct - 222, // 17: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct - 24, // 18: openshell.v1.SandboxStatus.conditions:type_name -> openshell.v1.SandboxCondition + 198, // 0: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential + 5, // 1: openshell.v1.HealthResponse.status:type_name -> openshell.v1.ServiceStatus + 5, // 2: openshell.v1.GetGatewayInfoResponse.status:type_name -> openshell.v1.ServiceStatus + 17, // 3: openshell.v1.GetGatewayInfoResponse.compute_drivers:type_name -> openshell.v1.ComputeDriverInfo + 18, // 4: openshell.v1.ComputeDriverInfo.capabilities:type_name -> openshell.v1.ComputeDriverCapabilities + 224, // 5: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 20, // 6: openshell.v1.Sandbox.spec:type_name -> openshell.v1.SandboxSpec + 24, // 7: openshell.v1.Sandbox.status:type_name -> openshell.v1.SandboxStatus + 199, // 8: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry + 23, // 9: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate + 225, // 10: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 21, // 11: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements + 22, // 12: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements + 200, // 13: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry + 201, // 14: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry + 202, // 15: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry + 226, // 16: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct + 226, // 17: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct + 25, // 18: openshell.v1.SandboxStatus.conditions:type_name -> openshell.v1.SandboxCondition 0, // 19: openshell.v1.SandboxStatus.phase:type_name -> openshell.v1.SandboxPhase - 199, // 20: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry - 19, // 21: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec - 200, // 22: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry - 201, // 23: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry - 18, // 24: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox - 18, // 25: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox - 223, // 26: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider - 18, // 27: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox - 18, // 28: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox - 50, // 29: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse - 220, // 30: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 49, // 31: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint - 202, // 32: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry - 54, // 33: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout - 55, // 34: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr - 56, // 35: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit - 143, // 36: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget - 144, // 37: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget - 58, // 38: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit - 53, // 39: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest - 61, // 40: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize - 220, // 41: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 18, // 42: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox - 65, // 43: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine - 25, // 44: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent - 66, // 45: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning - 154, // 46: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate - 203, // 47: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry - 223, // 48: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 223, // 49: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 204, // 50: openshell.v1.UpdateProviderRequest.credential_expires_at_ms:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry - 223, // 51: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider - 223, // 52: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider - 96, // 53: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile - 78, // 54: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride - 83, // 55: openshell.v1.ProviderProfileCredential.refresh:type_name -> openshell.v1.ProviderCredentialRefresh - 79, // 56: openshell.v1.ProviderProfileCredential.token_grant:type_name -> openshell.v1.ProviderCredentialTokenGrant - 1, // 57: openshell.v1.ProviderCredentialRefresh.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 81, // 58: openshell.v1.ProviderCredentialRefresh.material:type_name -> openshell.v1.ProviderCredentialRefreshMaterial - 82, // 59: openshell.v1.ProviderCredentialRefresh.additional_outputs:type_name -> openshell.v1.ProviderCredentialRefreshOutput - 1, // 60: openshell.v1.ProviderCredentialRefreshStatus.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 220, // 61: openshell.v1.StoredProviderCredentialRefreshState.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 1, // 62: openshell.v1.StoredProviderCredentialRefreshState.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 205, // 63: openshell.v1.StoredProviderCredentialRefreshState.material:type_name -> openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry - 206, // 64: openshell.v1.StoredProviderCredentialRefreshState.additional_output_keys:type_name -> openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry - 207, // 65: openshell.v1.StoredProviderCredentialRefreshState.secret_material_handles:type_name -> openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry - 87, // 66: openshell.v1.StoredProviderCredentialRefreshState.pending_secret_deletions:type_name -> openshell.v1.StoredRefreshMaterialDeletion - 224, // 67: openshell.v1.StoredRefreshMaterialDeletion.handle:type_name -> openshell.datamodel.v1.CredentialHandle - 84, // 68: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 1, // 69: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 208, // 70: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry - 84, // 71: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 84, // 72: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 2, // 73: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory - 80, // 74: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential - 225, // 75: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint - 226, // 76: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary - 85, // 77: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery - 209, // 78: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry - 220, // 79: openshell.v1.StoredProviderProfile.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 96, // 80: openshell.v1.StoredProviderProfile.profile:type_name -> openshell.v1.ProviderProfile - 96, // 81: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile - 96, // 82: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile - 76, // 83: openshell.v1.ImportProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem - 77, // 84: openshell.v1.ImportProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 96, // 85: openshell.v1.ImportProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile - 76, // 86: openshell.v1.UpdateProviderProfilesRequest.profile:type_name -> openshell.v1.ProviderProfileImportItem - 77, // 87: openshell.v1.UpdateProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 96, // 88: openshell.v1.UpdateProviderProfilesResponse.profile:type_name -> openshell.v1.ProviderProfile - 76, // 89: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem - 77, // 90: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 110, // 91: openshell.v1.StaticCredentialBinding.endpoints:type_name -> openshell.v1.StaticCredentialEndpointBinding - 210, // 92: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - 211, // 93: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expires_at_ms:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry - 212, // 94: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - 213, // 95: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry - 221, // 96: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 227, // 97: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue - 114, // 98: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation - 214, // 99: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry - 115, // 100: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule - 116, // 101: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint - 117, // 102: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule - 118, // 103: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules - 119, // 104: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules - 120, // 105: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary - 228, // 106: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 229, // 107: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule - 230, // 108: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule - 215, // 109: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry - 128, // 110: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision - 128, // 111: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision - 3, // 112: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus - 3, // 113: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus - 221, // 114: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 216, // 115: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry - 65, // 116: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine - 65, // 117: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine - 135, // 118: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello - 138, // 119: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat - 147, // 120: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult - 148, // 121: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose - 136, // 122: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted - 137, // 123: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected - 139, // 124: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat - 142, // 125: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen - 148, // 126: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose - 143, // 127: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget - 144, // 128: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget - 145, // 129: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit - 149, // 130: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample - 151, // 131: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount - 228, // 132: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 221, // 133: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 221, // 134: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 150, // 135: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary - 153, // 136: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk - 152, // 137: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary - 153, // 138: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk - 163, // 139: openshell.v1.ApproveAllDraftChunksRequest.approvals:type_name -> openshell.v1.DraftChunkApproval - 228, // 140: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 173, // 141: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry - 221, // 142: openshell.v1.PolicyRevisionPayload.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 217, // 143: openshell.v1.PolicyRevisionPayload.provenance:type_name -> openshell.v1.PolicyRevisionPayload.ProvenanceEntry - 228, // 144: openshell.v1.DraftChunkPayload.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 221, // 145: openshell.v1.DraftChunkPayload.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 221, // 146: openshell.v1.DraftChunkPayload.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 218, // 147: openshell.v1.StoredPolicyRevision.provenance:type_name -> openshell.v1.StoredPolicyRevision.ProvenanceEntry - 221, // 148: openshell.v1.StoredDraftChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 221, // 149: openshell.v1.StoredDraftChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 219, // 150: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry - 231, // 151: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 231, // 152: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 231, // 153: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace - 220, // 154: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 5, // 155: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole - 5, // 156: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole - 187, // 157: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember - 187, // 158: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember - 224, // 159: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry.value:type_name -> openshell.datamodel.v1.CredentialHandle - 80, // 160: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential - 111, // 161: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding - 10, // 162: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest - 12, // 163: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest - 14, // 164: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest - 26, // 165: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest - 27, // 166: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest - 28, // 167: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest - 29, // 168: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest - 30, // 169: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest - 31, // 170: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest - 32, // 171: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest - 33, // 172: openshell.v1.OpenShell.StopSandbox:input_type -> openshell.v1.StopSandboxRequest - 34, // 173: openshell.v1.OpenShell.StartSandbox:input_type -> openshell.v1.StartSandboxRequest - 41, // 174: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest - 43, // 175: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest - 44, // 176: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest - 45, // 177: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest - 47, // 178: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest - 51, // 179: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest - 53, // 180: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest - 59, // 181: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame - 60, // 182: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput - 67, // 183: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest - 68, // 184: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest - 69, // 185: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest - 74, // 186: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest - 75, // 187: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest - 100, // 188: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest - 102, // 189: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest - 104, // 190: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest - 70, // 191: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest - 88, // 192: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest - 90, // 193: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest - 92, // 194: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest - 94, // 195: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest - 71, // 196: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest - 107, // 197: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest - 232, // 198: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest - 233, // 199: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest - 113, // 200: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest - 122, // 201: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest - 124, // 202: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest - 126, // 203: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest - 109, // 204: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest - 129, // 205: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest - 130, // 206: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest - 133, // 207: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage - 140, // 208: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest - 146, // 209: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame - 63, // 210: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest - 155, // 211: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest - 157, // 212: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest - 159, // 213: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest - 161, // 214: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest - 164, // 215: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest - 166, // 216: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest - 168, // 217: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest - 170, // 218: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest - 172, // 219: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest - 6, // 220: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest - 8, // 221: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest - 179, // 222: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest - 181, // 223: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest - 183, // 224: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest - 185, // 225: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest - 188, // 226: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest - 190, // 227: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest - 192, // 228: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest - 11, // 229: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse - 13, // 230: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse - 15, // 231: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse - 35, // 232: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse - 35, // 233: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse - 36, // 234: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse - 37, // 235: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse - 38, // 236: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse - 39, // 237: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse - 40, // 238: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse - 35, // 239: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse - 35, // 240: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse - 42, // 241: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse - 50, // 242: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse - 50, // 243: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse - 46, // 244: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse - 48, // 245: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse - 52, // 246: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse - 57, // 247: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent - 59, // 248: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame - 57, // 249: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent - 72, // 250: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse - 72, // 251: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse - 73, // 252: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse - 99, // 253: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse - 98, // 254: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse - 101, // 255: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse - 103, // 256: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse - 105, // 257: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse - 72, // 258: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse - 89, // 259: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse - 91, // 260: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse - 93, // 261: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse - 95, // 262: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse - 106, // 263: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse - 108, // 264: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse - 234, // 265: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse - 235, // 266: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse - 121, // 267: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse - 123, // 268: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse - 125, // 269: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse - 127, // 270: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse - 112, // 271: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse - 132, // 272: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse - 131, // 273: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse - 134, // 274: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage - 141, // 275: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse - 146, // 276: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame - 64, // 277: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent - 156, // 278: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse - 158, // 279: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse - 160, // 280: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse - 162, // 281: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse - 165, // 282: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse - 167, // 283: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse - 169, // 284: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse - 171, // 285: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse - 174, // 286: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse - 7, // 287: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse - 9, // 288: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse - 180, // 289: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse - 182, // 290: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse - 184, // 291: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse - 186, // 292: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse - 189, // 293: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse - 191, // 294: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse - 193, // 295: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse - 229, // [229:296] is the sub-list for method output_type - 162, // [162:229] is the sub-list for method input_type - 162, // [162:162] is the sub-list for extension type_name - 162, // [162:162] is the sub-list for extension extendee - 0, // [0:162] is the sub-list for field type_name + 203, // 20: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry + 20, // 21: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec + 204, // 22: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry + 205, // 23: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry + 19, // 24: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox + 19, // 25: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox + 227, // 26: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 19, // 27: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox + 19, // 28: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox + 51, // 29: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse + 224, // 30: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 50, // 31: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint + 206, // 32: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry + 55, // 33: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout + 56, // 34: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr + 57, // 35: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit + 147, // 36: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget + 148, // 37: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget + 59, // 38: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit + 54, // 39: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest + 62, // 40: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize + 224, // 41: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 19, // 42: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox + 66, // 43: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine + 26, // 44: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent + 67, // 45: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning + 158, // 46: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate + 207, // 47: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry + 227, // 48: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 227, // 49: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 208, // 50: openshell.v1.UpdateProviderRequest.credential_expires_at_ms:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry + 227, // 51: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider + 227, // 52: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 98, // 53: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile + 79, // 54: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride + 1, // 55: openshell.v1.ProviderCredentialTokenGrant.grant_type:type_name -> openshell.v1.ProviderCredentialTokenGrantType + 80, // 56: openshell.v1.ProviderCredentialTokenGrant.subject_token:type_name -> openshell.v1.ProviderCredentialTokenGrantSubjectToken + 85, // 57: openshell.v1.ProviderProfileCredential.refresh:type_name -> openshell.v1.ProviderCredentialRefresh + 81, // 58: openshell.v1.ProviderProfileCredential.token_grant:type_name -> openshell.v1.ProviderCredentialTokenGrant + 2, // 59: openshell.v1.ProviderCredentialRefresh.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 83, // 60: openshell.v1.ProviderCredentialRefresh.material:type_name -> openshell.v1.ProviderCredentialRefreshMaterial + 84, // 61: openshell.v1.ProviderCredentialRefresh.additional_outputs:type_name -> openshell.v1.ProviderCredentialRefreshOutput + 2, // 62: openshell.v1.ProviderCredentialRefreshStatus.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 224, // 63: openshell.v1.StoredProviderCredentialRefreshState.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 2, // 64: openshell.v1.StoredProviderCredentialRefreshState.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 209, // 65: openshell.v1.StoredProviderCredentialRefreshState.material:type_name -> openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry + 210, // 66: openshell.v1.StoredProviderCredentialRefreshState.additional_output_keys:type_name -> openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry + 211, // 67: openshell.v1.StoredProviderCredentialRefreshState.secret_material_handles:type_name -> openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry + 89, // 68: openshell.v1.StoredProviderCredentialRefreshState.pending_secret_deletions:type_name -> openshell.v1.StoredRefreshMaterialDeletion + 228, // 69: openshell.v1.StoredRefreshMaterialDeletion.handle:type_name -> openshell.datamodel.v1.CredentialHandle + 86, // 70: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 2, // 71: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 212, // 72: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + 86, // 73: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 86, // 74: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 3, // 75: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory + 82, // 76: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential + 229, // 77: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 230, // 78: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 87, // 79: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery + 213, // 80: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry + 224, // 81: openshell.v1.StoredProviderProfile.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 98, // 82: openshell.v1.StoredProviderProfile.profile:type_name -> openshell.v1.ProviderProfile + 98, // 83: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile + 98, // 84: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile + 77, // 85: openshell.v1.ImportProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem + 78, // 86: openshell.v1.ImportProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 98, // 87: openshell.v1.ImportProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile + 77, // 88: openshell.v1.UpdateProviderProfilesRequest.profile:type_name -> openshell.v1.ProviderProfileImportItem + 78, // 89: openshell.v1.UpdateProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 98, // 90: openshell.v1.UpdateProviderProfilesResponse.profile:type_name -> openshell.v1.ProviderProfile + 77, // 91: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem + 78, // 92: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 112, // 93: openshell.v1.StaticCredentialBinding.endpoints:type_name -> openshell.v1.StaticCredentialEndpointBinding + 214, // 94: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + 215, // 95: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expires_at_ms:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry + 216, // 96: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + 217, // 97: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry + 225, // 98: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 231, // 99: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue + 118, // 100: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation + 218, // 101: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry + 119, // 102: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule + 120, // 103: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint + 121, // 104: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule + 122, // 105: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules + 123, // 106: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules + 124, // 107: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary + 232, // 108: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 233, // 109: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 234, // 110: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule + 219, // 111: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry + 132, // 112: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision + 132, // 113: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision + 4, // 114: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus + 4, // 115: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus + 225, // 116: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 220, // 117: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry + 66, // 118: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine + 66, // 119: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine + 139, // 120: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello + 142, // 121: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat + 151, // 122: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult + 152, // 123: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose + 140, // 124: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted + 141, // 125: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected + 143, // 126: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat + 146, // 127: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen + 152, // 128: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose + 147, // 129: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget + 148, // 130: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget + 149, // 131: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit + 153, // 132: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample + 155, // 133: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount + 232, // 134: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 225, // 135: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 225, // 136: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 154, // 137: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary + 157, // 138: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk + 156, // 139: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary + 157, // 140: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk + 167, // 141: openshell.v1.ApproveAllDraftChunksRequest.approvals:type_name -> openshell.v1.DraftChunkApproval + 232, // 142: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 177, // 143: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry + 225, // 144: openshell.v1.PolicyRevisionPayload.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 221, // 145: openshell.v1.PolicyRevisionPayload.provenance:type_name -> openshell.v1.PolicyRevisionPayload.ProvenanceEntry + 232, // 146: openshell.v1.DraftChunkPayload.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 225, // 147: openshell.v1.DraftChunkPayload.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 225, // 148: openshell.v1.DraftChunkPayload.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 222, // 149: openshell.v1.StoredPolicyRevision.provenance:type_name -> openshell.v1.StoredPolicyRevision.ProvenanceEntry + 225, // 150: openshell.v1.StoredDraftChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 225, // 151: openshell.v1.StoredDraftChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 223, // 152: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry + 235, // 153: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 235, // 154: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 235, // 155: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace + 224, // 156: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 6, // 157: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole + 6, // 158: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole + 191, // 159: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember + 191, // 160: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember + 228, // 161: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry.value:type_name -> openshell.datamodel.v1.CredentialHandle + 82, // 162: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential + 113, // 163: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding + 11, // 164: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest + 13, // 165: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest + 15, // 166: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest + 27, // 167: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest + 28, // 168: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest + 29, // 169: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest + 30, // 170: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest + 31, // 171: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest + 32, // 172: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest + 33, // 173: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest + 34, // 174: openshell.v1.OpenShell.StopSandbox:input_type -> openshell.v1.StopSandboxRequest + 35, // 175: openshell.v1.OpenShell.StartSandbox:input_type -> openshell.v1.StartSandboxRequest + 42, // 176: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest + 44, // 177: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest + 45, // 178: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest + 46, // 179: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest + 48, // 180: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest + 52, // 181: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest + 54, // 182: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest + 60, // 183: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame + 61, // 184: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput + 68, // 185: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest + 69, // 186: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest + 70, // 187: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest + 75, // 188: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest + 76, // 189: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest + 102, // 190: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest + 104, // 191: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest + 106, // 192: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest + 71, // 193: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest + 90, // 194: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest + 92, // 195: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest + 94, // 196: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest + 96, // 197: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest + 72, // 198: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest + 109, // 199: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest + 236, // 200: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest + 237, // 201: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest + 117, // 202: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest + 126, // 203: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest + 128, // 204: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest + 130, // 205: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest + 111, // 206: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest + 115, // 207: openshell.v1.OpenShell.ExchangeProviderSubjectToken:input_type -> openshell.v1.ExchangeProviderSubjectTokenRequest + 133, // 208: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest + 134, // 209: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest + 137, // 210: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage + 144, // 211: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest + 150, // 212: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame + 64, // 213: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest + 159, // 214: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest + 161, // 215: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest + 163, // 216: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest + 165, // 217: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest + 168, // 218: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest + 170, // 219: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest + 172, // 220: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest + 174, // 221: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest + 176, // 222: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest + 7, // 223: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest + 9, // 224: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest + 183, // 225: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest + 185, // 226: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest + 187, // 227: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest + 189, // 228: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest + 192, // 229: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest + 194, // 230: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest + 196, // 231: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest + 12, // 232: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse + 14, // 233: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse + 16, // 234: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse + 36, // 235: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse + 36, // 236: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse + 37, // 237: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse + 38, // 238: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse + 39, // 239: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse + 40, // 240: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse + 41, // 241: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse + 36, // 242: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse + 36, // 243: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse + 43, // 244: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse + 51, // 245: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse + 51, // 246: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse + 47, // 247: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse + 49, // 248: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse + 53, // 249: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse + 58, // 250: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent + 60, // 251: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame + 58, // 252: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent + 73, // 253: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse + 73, // 254: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse + 74, // 255: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse + 101, // 256: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse + 100, // 257: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse + 103, // 258: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse + 105, // 259: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse + 107, // 260: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse + 73, // 261: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse + 91, // 262: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse + 93, // 263: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse + 95, // 264: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse + 97, // 265: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse + 108, // 266: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse + 110, // 267: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse + 238, // 268: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse + 239, // 269: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse + 125, // 270: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse + 127, // 271: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse + 129, // 272: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse + 131, // 273: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse + 114, // 274: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse + 116, // 275: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse + 136, // 276: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse + 135, // 277: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse + 138, // 278: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage + 145, // 279: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse + 150, // 280: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame + 65, // 281: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent + 160, // 282: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse + 162, // 283: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse + 164, // 284: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse + 166, // 285: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse + 169, // 286: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse + 171, // 287: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse + 173, // 288: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse + 175, // 289: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse + 178, // 290: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse + 8, // 291: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse + 10, // 292: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse + 184, // 293: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse + 186, // 294: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse + 188, // 295: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse + 190, // 296: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse + 193, // 297: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse + 195, // 298: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse + 197, // 299: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse + 232, // [232:300] is the sub-list for method output_type + 164, // [164:232] is the sub-list for method input_type + 164, // [164:164] is the sub-list for extension type_name + 164, // [164:164] is the sub-list for extension extendee + 0, // [0:164] is the sub-list for field type_name } func init() { file_openshell_proto_init() } @@ -15356,8 +15669,8 @@ func file_openshell_proto_init() { (*SandboxStreamEvent_Warning)(nil), (*SandboxStreamEvent_DraftPolicyUpdate)(nil), } - file_openshell_proto_msgTypes[84].OneofWrappers = []any{} - file_openshell_proto_msgTypes[108].OneofWrappers = []any{ + file_openshell_proto_msgTypes[85].OneofWrappers = []any{} + file_openshell_proto_msgTypes[111].OneofWrappers = []any{ (*PolicyMergeOperation_AddRule)(nil), (*PolicyMergeOperation_RemoveEndpoint)(nil), (*PolicyMergeOperation_RemoveRule)(nil), @@ -15365,36 +15678,36 @@ func file_openshell_proto_init() { (*PolicyMergeOperation_AddAllowRules)(nil), (*PolicyMergeOperation_RemoveBinary)(nil), } - file_openshell_proto_msgTypes[127].OneofWrappers = []any{ + file_openshell_proto_msgTypes[130].OneofWrappers = []any{ (*SupervisorMessage_Hello)(nil), (*SupervisorMessage_Heartbeat)(nil), (*SupervisorMessage_RelayOpenResult)(nil), (*SupervisorMessage_RelayClose)(nil), } - file_openshell_proto_msgTypes[128].OneofWrappers = []any{ + file_openshell_proto_msgTypes[131].OneofWrappers = []any{ (*GatewayMessage_SessionAccepted)(nil), (*GatewayMessage_SessionRejected)(nil), (*GatewayMessage_Heartbeat)(nil), (*GatewayMessage_RelayOpen)(nil), (*GatewayMessage_RelayClose)(nil), } - file_openshell_proto_msgTypes[136].OneofWrappers = []any{ + file_openshell_proto_msgTypes[139].OneofWrappers = []any{ (*RelayOpen_Ssh)(nil), (*RelayOpen_Tcp)(nil), } - file_openshell_proto_msgTypes[140].OneofWrappers = []any{ + file_openshell_proto_msgTypes[143].OneofWrappers = []any{ (*RelayFrame_Init)(nil), (*RelayFrame_Data)(nil), } - file_openshell_proto_msgTypes[171].OneofWrappers = []any{} - file_openshell_proto_msgTypes[172].OneofWrappers = []any{} + file_openshell_proto_msgTypes[174].OneofWrappers = []any{} + file_openshell_proto_msgTypes[175].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_openshell_proto_rawDesc), len(file_openshell_proto_rawDesc)), - NumEnums: 6, - NumMessages: 214, + NumEnums: 7, + NumMessages: 217, NumExtensions: 0, NumServices: 1, }, diff --git a/sdk/go/proto/openshellv1/openshell_grpc.pb.go b/sdk/go/proto/openshellv1/openshell_grpc.pb.go index 2ea9410c3e..663c09aed5 100644 --- a/sdk/go/proto/openshellv1/openshell_grpc.pb.go +++ b/sdk/go/proto/openshellv1/openshell_grpc.pb.go @@ -66,6 +66,7 @@ const ( OpenShell_ListSandboxPolicies_FullMethodName = "/openshell.v1.OpenShell/ListSandboxPolicies" OpenShell_ReportPolicyStatus_FullMethodName = "/openshell.v1.OpenShell/ReportPolicyStatus" OpenShell_GetSandboxProviderEnvironment_FullMethodName = "/openshell.v1.OpenShell/GetSandboxProviderEnvironment" + OpenShell_ExchangeProviderSubjectToken_FullMethodName = "/openshell.v1.OpenShell/ExchangeProviderSubjectToken" OpenShell_GetSandboxLogs_FullMethodName = "/openshell.v1.OpenShell/GetSandboxLogs" OpenShell_PushSandboxLogs_FullMethodName = "/openshell.v1.OpenShell/PushSandboxLogs" OpenShell_ConnectSupervisor_FullMethodName = "/openshell.v1.OpenShell/ConnectSupervisor" @@ -200,6 +201,9 @@ type OpenShellClient interface { ReportPolicyStatus(ctx context.Context, in *ReportPolicyStatusRequest, opts ...grpc.CallOption) (*ReportPolicyStatusResponse, error) // Get provider environment for a sandbox (called by sandbox supervisor at startup). GetSandboxProviderEnvironment(ctx context.Context, in *GetSandboxProviderEnvironmentRequest, opts ...grpc.CallOption) (*GetSandboxProviderEnvironmentResponse, error) + // Exchange a stored provider subject token for an intermediate token scoped + // to the calling supervisor's SPIFFE identity. + ExchangeProviderSubjectToken(ctx context.Context, in *ExchangeProviderSubjectTokenRequest, opts ...grpc.CallOption) (*ExchangeProviderSubjectTokenResponse, error) // Fetch recent sandbox logs (one-shot). GetSandboxLogs(ctx context.Context, in *GetSandboxLogsRequest, opts ...grpc.CallOption) (*GetSandboxLogsResponse, error) // Push sandbox supervisor logs to the server (client-streaming). @@ -733,6 +737,16 @@ func (c *openShellClient) GetSandboxProviderEnvironment(ctx context.Context, in return out, nil } +func (c *openShellClient) ExchangeProviderSubjectToken(ctx context.Context, in *ExchangeProviderSubjectTokenRequest, opts ...grpc.CallOption) (*ExchangeProviderSubjectTokenResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ExchangeProviderSubjectTokenResponse) + err := c.cc.Invoke(ctx, OpenShell_ExchangeProviderSubjectToken_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *openShellClient) GetSandboxLogs(ctx context.Context, in *GetSandboxLogsRequest, opts ...grpc.CallOption) (*GetSandboxLogsResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetSandboxLogsResponse) @@ -1099,6 +1113,9 @@ type OpenShellServer interface { ReportPolicyStatus(context.Context, *ReportPolicyStatusRequest) (*ReportPolicyStatusResponse, error) // Get provider environment for a sandbox (called by sandbox supervisor at startup). GetSandboxProviderEnvironment(context.Context, *GetSandboxProviderEnvironmentRequest) (*GetSandboxProviderEnvironmentResponse, error) + // Exchange a stored provider subject token for an intermediate token scoped + // to the calling supervisor's SPIFFE identity. + ExchangeProviderSubjectToken(context.Context, *ExchangeProviderSubjectTokenRequest) (*ExchangeProviderSubjectTokenResponse, error) // Fetch recent sandbox logs (one-shot). GetSandboxLogs(context.Context, *GetSandboxLogsRequest) (*GetSandboxLogsResponse, error) // Push sandbox supervisor logs to the server (client-streaming). @@ -1316,6 +1333,9 @@ func (UnimplementedOpenShellServer) ReportPolicyStatus(context.Context, *ReportP func (UnimplementedOpenShellServer) GetSandboxProviderEnvironment(context.Context, *GetSandboxProviderEnvironmentRequest) (*GetSandboxProviderEnvironmentResponse, error) { return nil, status.Error(codes.Unimplemented, "method GetSandboxProviderEnvironment not implemented") } +func (UnimplementedOpenShellServer) ExchangeProviderSubjectToken(context.Context, *ExchangeProviderSubjectTokenRequest) (*ExchangeProviderSubjectTokenResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ExchangeProviderSubjectToken not implemented") +} func (UnimplementedOpenShellServer) GetSandboxLogs(context.Context, *GetSandboxLogsRequest) (*GetSandboxLogsResponse, error) { return nil, status.Error(codes.Unimplemented, "method GetSandboxLogs not implemented") } @@ -2154,6 +2174,24 @@ func _OpenShell_GetSandboxProviderEnvironment_Handler(srv interface{}, ctx conte return interceptor(ctx, in, info, handler) } +func _OpenShell_ExchangeProviderSubjectToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ExchangeProviderSubjectTokenRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ExchangeProviderSubjectToken(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ExchangeProviderSubjectToken_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ExchangeProviderSubjectToken(ctx, req.(*ExchangeProviderSubjectTokenRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _OpenShell_GetSandboxLogs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(GetSandboxLogsRequest) if err := dec(in); err != nil { @@ -2713,6 +2751,10 @@ var OpenShell_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetSandboxProviderEnvironment", Handler: _OpenShell_GetSandboxProviderEnvironment_Handler, }, + { + MethodName: "ExchangeProviderSubjectToken", + Handler: _OpenShell_ExchangeProviderSubjectToken_Handler, + }, { MethodName: "GetSandboxLogs", Handler: _OpenShell_GetSandboxLogs_Handler, From 7fc61389810b4772b40f58fb78ba907346ab5ce7 Mon Sep 17 00:00:00 2001 From: krishicks Date: Mon, 24 Aug 2026 08:10:10 +0000 Subject: [PATCH 003/505] docs(agent): warn on missing workflow labels (#2815) Signed-off-by: Kris Hicks --- .agents/skills/build-from-issue/SKILL.md | 41 ++++++++++++------- .agents/skills/create-github-issue/SKILL.md | 2 +- .agents/skills/create-spike/SKILL.md | 4 +- .agents/skills/fix-security-issue/SKILL.md | 9 ++-- .agents/skills/review-security-issue/SKILL.md | 6 +-- .agents/skills/sync-agent-infra/SKILL.md | 11 ++--- .agents/skills/triage-issue/SKILL.md | 11 ++--- AGENTS.md | 8 ++-- CONTRIBUTING.md | 12 +++--- 9 files changed, 60 insertions(+), 44 deletions(-) diff --git a/.agents/skills/build-from-issue/SKILL.md b/.agents/skills/build-from-issue/SKILL.md index 06d1324b02..4eb34036b7 100644 --- a/.agents/skills/build-from-issue/SKILL.md +++ b/.agents/skills/build-from-issue/SKILL.md @@ -25,7 +25,9 @@ A direct request authorizes only what it says. A request to review or plan does The two request labels remain human-only queue controls. Under **no circumstances** should this skill or any agent apply them, ask to apply them, or suggest automating their application. -Do not refuse a direct user request merely because its request label is absent. If direct work begins on an issue that was not already in the label-driven workflow, do not introduce `agent:in-progress` or `agent:pr-opened` solely for that invocation. If a matching request label is present, preserve the existing label transitions so unattended agents can track the workflow. +In direct mode, issue lifecycle and `agent:*` workflow labels are advisory rather than gates. Inspect the labels and warn the user about each expected label that is missing or any lifecycle label that indicates the normal workflow is incomplete, then continue with the requested phase. Do not ask the user to fix the labels first. A direct request does not change the issue's disposition or make the labels accurate; it only authorizes the requested work. + +If direct work begins on an issue that was not already in the label-driven workflow, do not introduce `agent:in-progress` or `agent:pr-opened` solely for that invocation. If a matching request label is present, preserve the existing label transitions so unattended agents can track the workflow. ## Agent Comment Markers @@ -59,11 +61,11 @@ Fetch issue + comments ├─ topic:security present? │ → Route to review-security-issue or fix-security-issue; STOP │ - ├─ Triage incomplete, awaiting information, or awaiting human disposition? - │ → Report the blocking state and STOP + ├─ Direct mode + expected lifecycle or agent-workflow labels missing/incomplete? + │ → Warn which labels are missing or incomplete; continue with the requested phase │ - ├─ state:accepted and roadmap association both absent? - │ → Human has not accepted the issue; STOP + ├─ Queue mode + triage incomplete, awaiting information, or awaiting human disposition? + │ → Report the blocking state and STOP │ ├─ No plan comment and no direct planning request and agent:plan-requested absent? │ → No request for agent planning; STOP @@ -109,13 +111,15 @@ If the issue is closed, report that and stop. If `topic:security` is present, stop. General build agents must not plan or implement security issues. Route planning/review to `review-security-issue` and authorized remediation to `fix-security-issue`. -Stop before planning in any of these states: +In queue mode, stop before planning on `state:triage-needed` or `state:needs-info`, and stop on `state:validated` without roadmap placement. Require `state:accepted` or roadmap placement before queue work proceeds. If no plan exists, require `agent:plan-requested`; require `agent:implementation-requested` before queue-mode implementation. + +In direct mode, inspect the same expected workflow state but do not stop because a lifecycle or agent-workflow label is absent or incomplete. Before continuing, warn the user with the specific discrepancy, for example: + +> "Issue #42 is missing `state:accepted` or roadmap placement and `agent:implementation-requested`. Those labels are expected in the queued workflow, but your direct request authorizes implementation, so I am continuing without changing them." -- `state:triage-needed`: the issue has not been assessed; use `triage-issue`. -- `state:needs-info`: triage is waiting for evidence from the reporter. -- `state:validated` without roadmap placement: triage is complete, but a human has not yet decided whether OpenShell should invest in the work. +If `state:triage-needed`, `state:needs-info`, or `state:validated` is present, name that state in the warning and explain what it normally means. Continue unless the issue lacks information that is actually necessary to perform the requested work; in that case, report the concrete missing information rather than treating the label itself as the blocker. -Next, require a human acceptance signal: either `state:accepted` or placement on the roadmap. The label records acceptance without requiring scheduling; roadmap placement records acceptance and sequencing. If no plan exists, require either a direct user request for planning or the human-applied `agent:plan-requested` label before generating one. Never add or remove `state:accepted`, either human request label, or the `roadmap` label. +Never add or remove `state:accepted`, either human request label, or the `roadmap` label. ## Step 2: Fetch and Classify Comments @@ -140,7 +144,7 @@ Using the state machine above, determine what to do based on: 1. Whether a plan comment exists 2. Whether there are human comments newer than the last agent comment (plan or conversation) 3. Whether this is direct mode and which phase the user requested -4. Which disposition, roadmap, and agent-workflow labels are present (`state:accepted`, `agent:plan-requested`, `agent:plan-ready`, `agent:implementation-requested`, `agent:in-progress`, `agent:pr-opened`, and the `roadmap` label) +4. Which lifecycle and agent-workflow labels are present (`state:*`, `agent:plan-requested`, `agent:plan-ready`, `agent:implementation-requested`, `agent:in-progress`, and `agent:pr-opened`) and which discrepancies require a direct-mode warning Follow the appropriate branch below. @@ -681,7 +685,7 @@ If the `agent:in-progress` label is present, the skill was previously started bu User says: "Plan issue #42" 1. Fetch issue #42 — title: "Add pagination to dataset list endpoint" -2. Confirm `state:accepted` with no blocking triage state; the user's direct request authorizes planning even if `agent:plan-requested` is absent +2. Notice that `state:accepted` and `agent:plan-requested` are absent; warn that the issue does not match the queued workflow, then continue because the user directly requested planning 3. Fetch comments — no `🏗️ build-plan` marker found 4. Pass issue to `principal-engineer-reviewer` for analysis 5. Reviewer produces a plan: feat type, Medium complexity, 3 implementation steps, unit + integration tests needed @@ -713,7 +717,7 @@ User says: "Check issue #42" User says: "Build issue #42" -1. Fetch issue #42 — `state:accepted` is present; the user's direct request authorizes implementation +1. Fetch issue #42 — `state:accepted` is present but `agent:implementation-requested` is absent; warn about the missing queue label and continue because the user directly requested implementation 2. Plan exists (Revision 2), complexity: Medium, confidence: High 3. No conflicting branches or PRs 4. Create branch `feat/42-add-pagination/jmyers` @@ -727,6 +731,15 @@ User says: "Build issue #42" 12. No agent-workflow label transition is needed 13. Report PR URL and workflow run status to user +### Run directly on an issue outside the workflow state machine + +User says: "Build issue #42" + +1. Fetch issue #42 — it has `state:triage-needed`; neither `state:accepted` nor `agent:implementation-requested` is present +2. Warn that triage and acceptance are incomplete and name the missing implementation request label +3. Continue through planning and implementation because the user directly requested the work +4. Do not add, remove, or reinterpret lifecycle or agent-workflow labels + ### Run on issue with existing PR User says: "Build issue #42" @@ -739,7 +752,7 @@ User says: "Build issue #42" User says: "Build issue #99" -1. Fetch issue #99 — `state:accepted` is present; the user's direct request authorizes implementation +1. Fetch issue #99 — warn about any missing expected workflow labels, then continue because the user directly requested implementation 2. Plan exists: complexity High, confidence Low, has open questions 3. Warn user: "Issue #99 is rated High complexity / Low confidence. Proceeding but flagging for your awareness." 4. Continue with build diff --git a/.agents/skills/create-github-issue/SKILL.md b/.agents/skills/create-github-issue/SKILL.md index 609d55ad15..1ab92a6ede 100644 --- a/.agents/skills/create-github-issue/SKILL.md +++ b/.agents/skills/create-github-issue/SKILL.md @@ -123,7 +123,7 @@ EOF GitHub built-in issue types (`Bug`, `Feature`, `Task`) should come from the matching issue template when possible, or be set manually afterward. Do not try to emulate them through labels. -Creating an issue does not accept it or queue agent work. Agents never apply `state:accepted`, the `roadmap` label, add issues to the roadmap project, or apply `agent:plan-requested` or `agent:implementation-requested`. Community issues proceed through `triage-issue`; a human accepts technically validated work with `state:accepted` or roadmap placement. The request labels queue work for unattended agents; a user may instead direct an agent to a specific issue. +Creating an issue does not accept it or queue agent work. Agents never apply `state:accepted`, the `roadmap` label, add issues to the roadmap project, or apply `agent:plan-requested` or `agent:implementation-requested`. Community issues proceed through `triage-issue`; a human accepts technically validated work with `state:accepted` or roadmap placement. The request labels queue work for unattended agents. A user may instead direct an agent to a specific issue; the agent warns about missing expected workflow labels and continues with the requested phase without changing them. ## Useful Options diff --git a/.agents/skills/create-spike/SKILL.md b/.agents/skills/create-spike/SKILL.md index 63bcffb72b..01afee91e6 100644 --- a/.agents/skills/create-spike/SKILL.md +++ b/.agents/skills/create-spike/SKILL.md @@ -211,7 +211,7 @@ gh issue create \ - --- -*Created by spike investigation. `state:validated` means the issue is ready for human disposition; `state:needs-info` means specific evidence is still required. A human applies `state:accepted` or places the issue on the roadmap if OpenShell should pursue the work. To queue unattended agent planning, a human applies `agent:plan-requested`; a direct request to an agent does not require that label.* +*Created by spike investigation. `state:validated` means the issue is ready for human disposition; `state:needs-info` means specific evidence is still required. A human applies `state:accepted` or places the issue on the roadmap if OpenShell should pursue the work. To queue unattended agent planning, a human applies `agent:plan-requested`; on a direct request, the agent warns about missing expected workflow labels and continues without changing them.* EOF )" ``` @@ -235,7 +235,7 @@ After creating the issue, report: For `state:validated`: -> Review the issue and decide whether OpenShell should pursue it. If yes, apply `state:accepted`, associate it with a roadmap item, or do both. Either action records acceptance; roadmap placement additionally records sequencing. The work may remain human-owned. Apply `agent:plan-requested` to queue planning for an unattended agent, or directly ask an agent to use `build-from-issue`. If no, close it as not planned and record the rationale. +> Review the issue and decide whether OpenShell should pursue it. If yes, apply `state:accepted`, associate it with a roadmap item, or do both. Either action records acceptance; roadmap placement additionally records sequencing. The work may remain human-owned. Apply `agent:plan-requested` to queue planning for an unattended agent, or directly ask an agent to use `build-from-issue`; on a direct request, the agent warns about missing expected workflow labels and continues without changing them. If no, close it as not planned and record the rationale. For `state:needs-info`: diff --git a/.agents/skills/fix-security-issue/SKILL.md b/.agents/skills/fix-security-issue/SKILL.md index 4e6610c8a1..ea3a12bfbb 100644 --- a/.agents/skills/fix-security-issue/SKILL.md +++ b/.agents/skills/fix-security-issue/SKILL.md @@ -11,7 +11,7 @@ Implement a code fix for a security issue that has already been reviewed by the - The `gh` CLI must be authenticated (`gh auth status`) - You must be in a git repository with a GitHub remote -- The issue must have `topic:security`. In unattended scan mode it must also have `agent:implementation-requested`; a direct user request to fix a specific issue does not require that label. +- The issue must have `topic:security`. In unattended scan mode it must also have `agent:implementation-requested`; for a direct user request, warn if that workflow label is missing and continue without changing it. - The issue must have a prior security review comment (posted by `review-security-issue`) with a **Legitimate concern** determination and a remediation plan ## Agent Comment Marker @@ -30,7 +30,7 @@ The user may provide an issue number directly, or ask the agent to find issues t ### If an issue number is provided -Strip any leading `#` and proceed to Step 2 with that issue ID. The user's explicit fix request authorizes implementation; do not refuse solely because `agent:implementation-requested` is absent. +Strip any leading `#` and proceed to Step 2 with that issue ID. The user's explicit fix request authorizes implementation. If `agent:implementation-requested` is absent, warn that the expected workflow label is missing and continue without changing it. ### If no issue number is provided @@ -61,7 +61,7 @@ Check the issue's `labels` array from the response above: If `topic:security` is missing, report that this skill only handles security issues and stop. If queue mode selected an issue without `agent:implementation-requested`, report that it is not ready for unattended pickup and stop. -Never apply `agent:implementation-requested` yourself. Its absence does not block a direct user request to fix a specific issue. +Never apply `agent:implementation-requested` yourself. In direct mode, warn about its absence and continue; the missing label does not block the user's request to fix the specific issue. ### Validate the security review @@ -307,7 +307,8 @@ User says: "Fix security issue #55" 1. Fetch issue #55 metadata 2. Labels are `["topic:security"]` -- missing `agent:implementation-requested` 3. Confirm that a legitimate security review and remediation plan exist -4. Proceed because the user's direct request authorizes implementation +4. Warn that `agent:implementation-requested` is missing from the expected workflow state +5. Proceed because the user's direct request authorizes implementation; leave the labels unchanged ### Issue without a review diff --git a/.agents/skills/review-security-issue/SKILL.md b/.agents/skills/review-security-issue/SKILL.md index b84e8b597a..03dc2974be 100644 --- a/.agents/skills/review-security-issue/SKILL.md +++ b/.agents/skills/review-security-issue/SKILL.md @@ -11,7 +11,7 @@ Review an issue that outlines a security, vulnerability, or privacy concern. - The `gh` CLI must be authenticated (`gh auth status`) - You must be in a git repository with a GitHub remote -- The issue must have `topic:security`. In unattended queue mode it must also have `agent:plan-requested`; a direct user request to review a specific issue does not require that label. +- The issue must have `topic:security`. In unattended queue mode it must also have `agent:plan-requested`; for a direct user request, warn if that workflow label is missing and continue without changing it. ## Agent Comment Marker @@ -44,7 +44,7 @@ First, check the issue's labels from the metadata fetched in Step 1. - **If the issue has `agent:implementation-requested`**, the issue has already been reviewed and a human authorized remediation. There is no review to perform. Suggest using `fix-security-issue` and stop. - **If `topic:security` is missing**, report that this specialized skill only reviews security issues and stop. - **If this is queue mode and `agent:plan-requested` is missing**, report that the issue is not ready for unattended pickup and stop. -- **If the user directly requested review of this issue**, proceed even when `agent:plan-requested` is absent. Never add or offer to add the human-only request label. +- **If the user directly requested review of this issue**, warn that `agent:plan-requested` is missing, then proceed without it. Never add or offer to add the human-only request label. Next, fetch existing comments on the issue: @@ -145,7 +145,7 @@ After posting a legitimate-concern review with a remediation plan, replace `agen gh issue edit --remove-label "agent:plan-requested" --add-label "agent:plan-ready" ``` -This signals that an unattended agent produced a remediation plan that awaits human review. For an unlabeled direct invocation, leave the `agent:*` labels unchanged. A later direct request can authorize remediation without `agent:implementation-requested`; unattended remediation still requires that label. For a not-actionable determination, remove `agent:plan-requested` if present, do not add another `agent:*` label, and report that a human should close the issue or record the risk decision. +This signals that an unattended agent produced a remediation plan that awaits human review. For an unlabeled direct invocation, leave the `agent:*` labels unchanged. A later direct request can authorize remediation without `agent:implementation-requested`; warn that the expected label is missing and continue, while unattended remediation still requires that label. For a not-actionable determination, remove `agent:plan-requested` if present, do not add another `agent:*` label, and report that a human should close the issue or record the risk decision. ## Step 6: Address Follow-up Comments diff --git a/.agents/skills/sync-agent-infra/SKILL.md b/.agents/skills/sync-agent-infra/SKILL.md index bd01bfebde..5dfbef12c8 100644 --- a/.agents/skills/sync-agent-infra/SKILL.md +++ b/.agents/skills/sync-agent-infra/SKILL.md @@ -11,7 +11,7 @@ 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 | -| `docs/resources/issue-lifecycle.mdx` | Human-facing issue states, roadmap decisions, and direct-versus-queued agent ownership | +| `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 | | `.github/ISSUE_TEMPLATE/bug_report.yml` | Skill name references in diagnostic guidance | | `.github/ISSUE_TEMPLATE/feature_request.yml` | Skill name references in investigation guidance | @@ -88,7 +88,7 @@ The canonical workflow chains are defined in `AGENTS.md` under "## Workflow Chai ### Labels -The canonical label set is used by skills and templates. The key labels are: `state:triage-needed`, `state:needs-info`, `state:validated`, `state:accepted`, `agent:plan-requested`, `agent:plan-ready`, `agent:implementation-requested`, `agent:in-progress`, `agent:pr-opened`, `roadmap`, `topic:security`, `good first issue`, `help wanted`, `spike`, and the relevant `area:*`, `topic:*`, `integration:*`, and `test:*` labels. The `agent:*` request labels control unattended queue pickup; they are not prerequisites when a user directly asks an agent to work on a specific issue. +The canonical label set is used by skills and templates. The key labels are: `state:triage-needed`, `state:needs-info`, `state:validated`, `state:accepted`, `agent:plan-requested`, `agent:plan-ready`, `agent:implementation-requested`, `agent:in-progress`, `agent:pr-opened`, `roadmap`, `topic:security`, `good first issue`, `help wanted`, `spike`, and the relevant `area:*`, `topic:*`, `integration:*`, and `test:*` labels. Lifecycle and `agent:*` request labels gate unattended queue pickup. They do not prevent a direct user request: the agent warns about each missing or incomplete expected workflow label and continues with the requested phase without changing those labels. ## Step 2: Check Each File for Drift @@ -109,8 +109,9 @@ For each file in the table above, check for the following inconsistencies: ### Issue Lifecycle Documentation -1. **`docs/resources/issue-lifecycle.mdx`** — State, roadmap, and agent-workflow meanings must match `AGENTS.md` and `CONTRIBUTING.md`. -2. **Invocation modes** — The `agent:*` request labels must control unattended queue pickup without being presented as prerequisites for a direct user request to a specific agent. +1. **`CONTRIBUTING.md` issue lifecycle section** — State, roadmap, acceptance-signal, and agent-workflow meanings must match `AGENTS.md`. +2. **Invocation modes** — Lifecycle and `agent:*` request labels must gate unattended queue pickup without blocking a direct user request to a specific agent. +3. **Direct-mode warnings** — Guidance must require the agent to warn about each missing or incomplete expected workflow label, continue with the requested phase, and leave labels unchanged. ### `README.md` @@ -131,7 +132,7 @@ For each file in the table above, check for the following inconsistencies: 1. **`triage-issue`** — Skills referenced in gate check and diagnosis steps must exist. 2. **`openshell-cli`** — Companion skills table entries must exist. -3. **`build-from-issue`** — Label names must match the project's label taxonomy, and request labels must gate unattended queue pickup without blocking direct user requests. +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. diff --git a/.agents/skills/triage-issue/SKILL.md b/.agents/skills/triage-issue/SKILL.md index ebd353d6f8..11594e13a6 100644 --- a/.agents/skills/triage-issue/SKILL.md +++ b/.agents/skills/triage-issue/SKILL.md @@ -25,9 +25,9 @@ Triage establishes technical validity; it does not decide whether valid work bel OpenShell has no `priority:*` labels. Sequencing comes from association with an item on the OpenShell Roadmap, and that association is a maintainer decision. -`state:validated` means the factual assessment is complete and awaits human disposition. A human declines by closing the issue as not planned with a rationale, or accepts by applying `state:accepted`, placing the issue on the roadmap, or doing both as documented in `CONTRIBUTING.md`. Accepted work may remain human-owned. A maintainer can queue deeper agent investigation or planning with `agent:plan-requested`, or directly ask an agent to work on a specific issue. +`state:validated` means the factual assessment is complete and awaits human disposition. A human declines by closing the issue as not planned with a rationale, or accepts by applying `state:accepted`, placing the issue on the roadmap, or doing both as documented in `CONTRIBUTING.md`. Accepted work may remain human-owned. A maintainer can queue deeper agent investigation or planning with `agent:plan-requested`, or a user can directly ask an agent to work on a specific issue. -The optional `agent:*` workflow controls unattended queue pickup: `agent:plan-requested` queues planning, and `agent:implementation-requested` queues implementation after plan review. A direct user instruction separately authorizes the phase it requests and does not require either label. +The optional `agent:*` workflow controls unattended queue pickup: `agent:plan-requested` queues planning, and `agent:implementation-requested` queues implementation after plan review. A direct user instruction separately authorizes the phase it requests. The agent warns about missing or incomplete expected lifecycle and workflow labels, then continues without changing them. ## Agent Comment Marker @@ -209,14 +209,15 @@ Post a structured comment with the triage marker: > roadmap placement additionally records sequencing. > To queue investigation or planning for an unattended agent, also apply > `agent:plan-requested`. You can instead directly ask an agent to use -> `create-spike` or `build-from-issue` on this issue. If no, close it as not -> planned and record the rationale. +> `create-spike` or `build-from-issue` on this issue; the agent will warn about +> missing expected workflow labels and continue without changing them. If no, +> close it as not planned and record the rationale. + ``` For other outcomes, replace the impact and decision sections with the exact information request, objective resolution, or safe routing guidance. Keep exactly one intake/triage state among `state:triage-needed`, `state:needs-info`, and `state:validated`. Remove `state:triage-needed` after every completed assessment. Never apply `state:accepted`, any `agent:*` label, or the `roadmap` label during triage. Never close a validated issue. - ## Relationship to Other Skills ``` diff --git a/AGENTS.md b/AGENTS.md index bd532c1971..dda3e75098 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,11 +17,11 @@ Agent skills live in `.agents/skills/`. Your harness can discover and load them These pipelines connect skills into end-to-end workflows. Individual skill files don't describe these relationships. - **Community inflow:** `triage-issue` → human disposition and roadmap placement → `create-spike` when needed → `build-from-issue` - - Triage establishes facts and marks technically valid issues `state:validated`. A human signals that the project should pursue the work by applying `state:accepted` or placing the issue on the roadmap. The `agent:*` labels support unattended agents that scan for queued work: a human queues a plan with `agent:plan-requested`, the agent returns `agent:plan-ready`, and a human queues implementation with `agent:implementation-requested`. A direct user request to an agent authorizes the requested phase without those labels. + - Triage establishes facts and marks technically valid issues `state:validated`. A human signals that the project should pursue the work by applying `state:accepted` or placing the issue on the roadmap. The `agent:*` labels support unattended agents that scan for queued work: a human queues a plan with `agent:plan-requested`, the agent returns `agent:plan-ready`, and a human queues implementation with `agent:implementation-requested`. A direct user request to an agent authorizes the requested phase even when the expected lifecycle or workflow labels are missing or incomplete; the agent warns about the discrepancies and continues without changing the labels. - **Internal development:** `create-spike` → human disposition and roadmap placement → `build-from-issue` - - Spike explores feasibility and marks its issue `state:validated` when sufficient evidence exists. A human accepts it with `state:accepted` or roadmap placement, or declines it, and optionally queues it through the `agent:*` workflow or directs an agent to it. + - Spike explores feasibility and marks its issue `state:validated` when sufficient evidence exists. A human accepts it with `state:accepted` or roadmap placement, or declines it, and optionally queues it through the `agent:*` workflow or directs an agent to it. A direct request proceeds after warning about missing or incomplete expected labels. - **Security:** `review-security-issue` → `fix-security-issue` - - General build agents must not process `topic:security` issues. For unattended processing, a human queues specialized review with `agent:plan-requested`; review produces a severity assessment and remediation plan; a human queues remediation with `agent:implementation-requested`. Direct requests to the specialized skills do not require those labels. + - General build agents must not process `topic:security` issues. For unattended processing, a human queues specialized review with `agent:plan-requested`; review produces a severity assessment and remediation plan; a human queues remediation with `agent:implementation-requested`. On direct requests to the specialized skills, missing workflow labels produce a warning rather than blocking the requested phase. - **Policy iteration:** `openshell-cli` → `generate-sandbox-policy` - CLI manages the sandbox lifecycle; policy generation authors the YAML constraints. @@ -79,7 +79,7 @@ These pipelines connect skills into end-to-end workflows. Individual skill files - **Bug reports and feature requests** must include a User Story, Problem Statement, Impact / Why This Matters, and Acceptance Criteria. The impact should explain the consequences of the current behavior, the current workaround, and why that workaround is insufficient. Bug reports additionally require reproduction steps and environment details and may include concise, redacted logs. - **Feature requests** must also include a Proposed Design and Alternatives Considered. The design should define the user-facing workflow and externally observable behavior while leaving internal implementation choices open. Agent investigation is optional. - **New features** must start as GitHub issues using the feature request template. Open an RFC only after an issue exists; maintainers decide when one is needed and assign RFC numbers from the issue. -- **Issue triage** establishes technical validity and impact evidence. Agents never decide acceptance, apply `state:accepted`, place issues on the roadmap, or apply `agent:plan-requested` or `agent:implementation-requested`. Humans accept or decline validated work; `state:accepted` or roadmap placement records acceptance, and roadmap association additionally carries sequencing. The request labels queue work for unattended agents; an explicit user instruction can instead authorize an agent to plan or implement a specific issue. OpenShell has no `priority:*` labels. +- **Issue triage** establishes technical validity and impact evidence. Agents never decide acceptance, apply `state:accepted`, place issues on the roadmap, or apply `agent:plan-requested` or `agent:implementation-requested`. Humans accept or decline validated work; `state:accepted` or roadmap placement records acceptance, and roadmap association additionally carries sequencing. Lifecycle and request labels gate unattended queue pickup. An explicit user instruction authorizes an agent to plan or implement the specified issue even when expected labels are missing or incomplete; the agent warns the user and continues without changing those labels. OpenShell has no `priority:*` labels. - **PRs** must follow the PR template structure: Summary, Related Issue, Changes, Testing, Checklist. Contributors should use their agent to investigate the current code and behavior for accepted issue-backed work, verify any diagnostics already on the issue, understand the change they submit, and report the resulting implementation and verification—not paste an earlier issue-filing diagnostic. - **PRs for features, user-visible behavior, public APIs, architecture, or multi-PR efforts** must link an accepted issue. Small docs fixes, mechanical maintenance, and obvious localized bug fixes may state why no issue is required. - **PRs from unvouched external contributors** are automatically closed. See the Vouch System section above. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4befd7b4a2..9d43e1f4f6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,7 +34,7 @@ We use a vouch system. This exists because AI makes it trivial to generate plaus Issues labeled [`good first issue`](https://github.com/NVIDIA/OpenShell/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) are scoped, well-documented, and friendly to new contributors. Start there. If you need guidance, comment on the issue. -An open issue is not necessarily accepted or ready to be worked on. Human contributors should look for `state:accepted`, roadmap placement, `good first issue`, or `help wanted`, or ask a maintainer before starting. Unattended agents additionally require the appropriate human-applied `agent:*` request label; an agent directly asked to work on a specific issue does not. +An open issue is not necessarily accepted or ready to be worked on. Human contributors should look for `state:accepted`, roadmap placement, `good first issue`, or `help wanted`, or ask a maintainer before starting. Unattended agents require the expected lifecycle state and the appropriate human-applied `agent:*` request label. An agent directly asked to work on a specific issue warns about missing or incomplete expected labels and continues with the requested phase without changing them. ## Before You Open an Issue @@ -141,7 +141,7 @@ Agents investigate issues, collect evidence, and report technical findings. Huma | Directly request agent implementation | User | | Queue approved implementation with `agent:implementation-requested` | Maintainer | -Agents do not apply `state:accepted`, place issues on the roadmap, or apply `agent:plan-requested` or `agent:implementation-requested`. +Agents do not apply `state:accepted`, place issues on the roadmap, or apply `agent:plan-requested` or `agent:implementation-requested`. A direct request may authorize work outside the recorded workflow, but it does not alter the issue's disposition or make the labels accurate. #### Issue State @@ -199,7 +199,7 @@ Roadmap placement does not assign an owner. A roadmap issue still needs a human A human contributor may implement an accepted issue without any `agent:*` label. Before starting, check for an assignee, linked pull request, active branch, or comment that shows someone else is already working on it. -Maintainers use the `agent:*` workflow to queue work for always-on or unattended agents that scan issues. Keep exactly one agent-workflow label on the issue at a time. When a user directly asks an agent to plan or implement a specific issue, that instruction authorizes the requested phase and the corresponding request label is not required. +Maintainers use the `agent:*` workflow to queue work for always-on or unattended agents that scan issues. Keep exactly one agent-workflow label on the issue at a time. When a user directly asks an agent to plan or implement a specific issue, that instruction authorizes the requested phase even if the issue does not match the normal lifecycle or agent-workflow state. The agent warns about each missing or incomplete expected label and continues without changing the labels. | Agent workflow | Applied by | Meaning | |---|---|---| @@ -249,7 +249,7 @@ Maintainers use the specialized security review and remediation workflow for an 3. A maintainer reviews the plan and applies `agent:implementation-requested`. 4. The remediation agent implements the approved plan. -A user may instead directly request review or remediation from the specialized skill. The direct request replaces the corresponding queue label, but a request for review still does not authorize remediation. General implementation agents do not process issues labeled `topic:security`. +A user may instead directly request review or remediation from the specialized skill. If the corresponding queue label is missing, the agent warns and continues without changing it, but a request for review still does not authorize remediation. General implementation agents do not process issues labeled `topic:security`. #### When an Issue Is Ready for Work @@ -258,9 +258,9 @@ A user may instead directly request review or remediation from the specialized s | A human contributor | The issue has `state:accepted`, roadmap placement, an invitation to contribute, or maintainer confirmation, and has no conflicting owner or implementation. | | An unattended agent scanning for planning work | The issue has `state:accepted` or roadmap placement, plus the human-applied `agent:plan-requested` label. | | An unattended agent scanning for implementation work | The issue has `state:accepted` or roadmap placement, plus an approved plan and the human-applied `agent:implementation-requested` label. | -| An agent directly instructed by a user | The issue has `state:accepted` or roadmap placement, no conflicting owner or implementation, and the instruction explicitly requests the phase the agent will perform. | +| An agent directly instructed by a user | The instruction explicitly requests the phase the agent will perform and the issue has no conflicting owner or implementation. Missing or incomplete workflow labels produce a warning, not a stop. | -Issues with `state:triage-needed`, `state:needs-info`, or `state:validated` are not ready for implementation unless a maintainer has separately placed them on the roadmap. Either `state:accepted` or roadmap placement records the required human acceptance decision. +For unattended agents, `state:needs-info` blocks work until the requested evidence arrives, and `state:triage-needed` or `state:validated` blocks work unless a maintainer has separately placed the issue on the roadmap or applied `state:accepted`. For a directly instructed agent, these labels require a warning but do not themselves block the requested work. If information actually needed to do the work is unavailable, the agent reports that concrete blocker rather than treating the label as the blocker. #### Stale Issues From 905e99aa2a960c17d5672b11cf64f10ff7d18b41 Mon Sep 17 00:00:00 2001 From: Simon Scatton <44714756+SDAChess@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:16:00 +0000 Subject: [PATCH 004/505] feat(build): add Nix-native Linux toolchains (#2875) * feat(nix): add glibc 2.28 development shell * feat(nix): add musl development shell * feat(build): use mold in musl development shell * feat(flake): add nix remote cache * feat(build): use mold in default development shell --- Cargo.lock | 9 +- flake.lock | 12 +-- flake.nix | 58 ++++++++---- nix/devShells/glibc-2-28.nix | 40 +++++++++ nix/devShells/musl.nix | 27 ++++++ nix/pkgs/aws-lc-static.nix | 14 +++ nix/pkgs/z3-static.nix | 12 +++ nix/toolchains/linux-gnu-2.28/default.nix | 103 ++++++++++++++++++++++ nix/toolchains/linux-gnu-2.28/libc.nix | 64 ++++++++++++++ 9 files changed, 312 insertions(+), 27 deletions(-) create mode 100644 nix/devShells/glibc-2-28.nix create mode 100644 nix/devShells/musl.nix create mode 100644 nix/pkgs/aws-lc-static.nix create mode 100644 nix/pkgs/z3-static.nix create mode 100644 nix/toolchains/linux-gnu-2.28/default.nix create mode 100644 nix/toolchains/linux-gnu-2.28/libc.nix diff --git a/Cargo.lock b/Cargo.lock index 809b0192b9..6200c73dd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -299,9 +299,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -310,14 +310,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] diff --git a/flake.lock b/flake.lock index 48aa4dfd8b..746d9b48a4 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1785318670, - "narHash": "sha256-dN6Ou5x/+23FZLEpYP3IffO+NyJFzUlGumt1uu3MMaY=", + "lastModified": 1787135253, + "narHash": "sha256-RD2kNWCG+Bjo6h+JVjWVNntZs2GtRoeY2xHjts/FNkA=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "0954f7ee2f6bb3dc7d4e3d0d8bcb8fd4bde4cfc5", + "rev": "ffb3c9b700e759be2ef13237c9d8f953b32a1e46", "type": "github" }, "original": { @@ -49,11 +49,11 @@ ] }, "locked": { - "lastModified": 1785476452, - "narHash": "sha256-/CXwCFPS41rb/JI2VitKCgVK6V5E6/sfw5ke3B9zyVQ=", + "lastModified": 1787281715, + "narHash": "sha256-5yIL5XL31hCZBPWDdUOvUy4cYAl27XZrke7JELhHlnI=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "6ef009bf4c4873cdc1a621826722bdea7c03e62c", + "rev": "89abdfd661ea493cde2f73d7b1332cb34150aa43", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index a95a3472fa..ec21dd3b8b 100644 --- a/flake.nix +++ b/flake.nix @@ -4,6 +4,13 @@ { description = "OpenShell development environment"; + nixConfig = { + extra-substituters = [ "https://openshell.cachix.org" ]; + extra-trusted-public-keys = [ + "openshell.cachix.org-1:OAr5MunsfH5PZvUsfD08OtGx5RtcwdNZGJdU5FqLm5w=" + ]; + }; + inputs = { flake-utils.url = "github:numtide/flake-utils"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; @@ -33,34 +40,51 @@ inherit system; overlays = [ (import rust-overlay) ]; }; + commonDevShellPackages = with pkgs; [ + # Assemble Debian artifacts on macOS and Linux. + dpkg + # Required to find packages. + pkg-config + # Coverage. + lcov + ]; treefmtEval = treefmt-nix.lib.evalModule pkgs { projectRootFile = "flake.nix"; programs.nixfmt.enable = true; }; rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; + z3-static = pkgs.callPackage ./nix/pkgs/z3-static.nix { }; + aws-lc-static = pkgs.callPackage ./nix/pkgs/aws-lc-static.nix { }; testGuest = import ./nix/test-guest { inherit pkgs; }; in { apps.test-guest = testGuest.app; apps.test-guest-cache = testGuest.cacheApp; - devShells.default = pkgs.mkShell { - packages = with pkgs; [ - rustToolchain - # Assemble Debian artifacts on macOS and Linux. - dpkg - # Required to find packages - pkg-config - # Required for bindgen generation. - llvmPackages.libclang - # system dependency for openshell-prover - z3 - # Coverage - lcov - ]; - - env = { - LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; + devShells = { + default = + (pkgs.mkShell.override { + stdenv = + if pkgs.stdenv.hostPlatform.isLinux then + pkgs.stdenvAdapters.useMoldLinker pkgs.stdenv + else + pkgs.stdenv; + }) + { + packages = [ + rustToolchain + z3-static + aws-lc-static + ] + ++ commonDevShellPackages; + }; + } + // pkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { + glibc-2-28 = import ./nix/devShells/glibc-2-28.nix { + inherit pkgs rust-overlay commonDevShellPackages; + }; + musl = import ./nix/devShells/musl.nix { + inherit pkgs rust-overlay commonDevShellPackages; }; }; diff --git a/nix/devShells/glibc-2-28.nix b/nix/devShells/glibc-2-28.nix new file mode 100644 index 0000000000..1bba2ef637 --- /dev/null +++ b/nix/devShells/glibc-2-28.nix @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +{ + pkgs, + rust-overlay, + commonDevShellPackages, +}: + +let + toolchain = import ../toolchains/linux-gnu-2.28 { inherit pkgs; }; + z3-static = pkgs.callPackage ../pkgs/z3-static.nix { + stdenv = toolchain.stdenv; + }; + aws-lc-static = pkgs.callPackage ../pkgs/aws-lc-static.nix { + stdenv = toolchain.stdenv; + }; + rustScope = { + stdenv = toolchain.stdenv; + gccForLibs.lib = toolchain.sharedRuntime; + pkgsTargetTarget = pkgs.pkgsTargetTarget // { + stdenv = toolchain.stdenv; + }; + }; + rust-bin = rust-overlay.lib.mkRustBin { } ( + pkgs + // rustScope + // { + callPackage = pkgs.newScope rustScope; + } + ); +in +(pkgs.mkShell.override { stdenv = toolchain.stdenv; }) { + packages = [ + (rust-bin.fromRustupToolchainFile ../../rust-toolchain.toml) + z3-static + aws-lc-static + ] + ++ commonDevShellPackages; +} diff --git a/nix/devShells/musl.nix b/nix/devShells/musl.nix new file mode 100644 index 0000000000..ba7c06bf22 --- /dev/null +++ b/nix/devShells/musl.nix @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +{ + pkgs, + rust-overlay, + commonDevShellPackages, +}: + +let + muslPkgs = pkgs.pkgsMusl; + stdenv = pkgs.stdenvAdapters.useMoldLinker muslPkgs.stdenv; + rust-bin = rust-overlay.lib.mkRustBin { } muslPkgs; + rustToolchain = (rust-bin.fromRustupToolchainFile ../../rust-toolchain.toml).override { + enableLibsecret = false; + }; +in +(muslPkgs.mkShell.override { inherit stdenv; }) { + packages = [ + rustToolchain + (muslPkgs.callPackage ../pkgs/z3-static.nix { }) + (muslPkgs.callPackage ../pkgs/aws-lc-static.nix { + rust-bindgen = pkgs.rust-bindgen; + }) + ] + ++ commonDevShellPackages; +} diff --git a/nix/pkgs/aws-lc-static.nix b/nix/pkgs/aws-lc-static.nix new file mode 100644 index 0000000000..45fd5ad174 --- /dev/null +++ b/nix/pkgs/aws-lc-static.nix @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +{ + aws-lc, + rust-bindgen, + stdenv, +}: + +aws-lc.override { + inherit stdenv rust-bindgen; + useSharedLibraries = false; + withRustBindings = true; +} diff --git a/nix/pkgs/z3-static.nix b/nix/pkgs/z3-static.nix new file mode 100644 index 0000000000..f58c36920c --- /dev/null +++ b/nix/pkgs/z3-static.nix @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +{ z3, stdenv }: + +(z3.override { + inherit stdenv; + pythonBindings = false; +}).overrideAttrs + (old: { + cmakeFlags = old.cmakeFlags ++ [ "-DZ3_BUILD_LIBZ3_SHARED=OFF" ]; + }) diff --git a/nix/toolchains/linux-gnu-2.28/default.nix b/nix/toolchains/linux-gnu-2.28/default.nix new file mode 100644 index 0000000000..3e911f1ced --- /dev/null +++ b/nix/toolchains/linux-gnu-2.28/default.nix @@ -0,0 +1,103 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +{ pkgs }: + +let + glibc = pkgs.callPackage ./libc.nix { }; + mkStdenv = + { + libraryPaths ? [ ], + ldflags ? null, + }: + let + runtime = pkgs.buildEnv { + name = "gcc-static-runtime"; + paths = libraryPaths ++ map pkgs.lib.getDev libraryPaths; + pathsToLink = [ + "/include" + "/include-cxx" + "/lib" + ]; + postBuild = '' + mkdir -p $out/lib + find $out/lib -type l ! \( -name '*.a' -o -name 'crt*.o' \) -delete + printf 'GROUP ( libgcc.a libgcc_eh.a )\n' > $out/lib/libgcc_s.a + ''; + passthru.isGNU = true; + }; + in + pkgs.stdenvAdapters.useMoldLinker ( + pkgs.overrideCC pkgs.stdenv ( + pkgs.wrapCCWith { + cc = pkgs.gccNGPackages.gcc-unwrapped.overrideAttrs (old: { + configureFlags = old.configureFlags ++ [ "--disable-fixincludes" ]; + }); + bintools = pkgs.wrapBintoolsWith { + bintools = pkgs.binutils-unwrapped; + libc = glibc; + }; + extraPackages = [ runtime ]; + libcxx = runtime; + nixSupport = { + cc-cflags = [ + "-isystem${pkgs.linuxHeaders}/include" + "-static-libgcc" + "-B${runtime}/lib" + ]; + } + // pkgs.lib.optionalAttrs (ldflags != null) { cc-ldflags = ldflags; }; + } + ) + ); + libgcc = + (pkgs.gccNGPackages.libgcc.override { + stdenv = mkStdenv { }; + }).overrideAttrs + (old: { + makeFlags = old.makeFlags ++ [ "SHLIB_LC=-lc" ]; + }); + libssp = + (pkgs.gccNGPackages.libssp.override { + stdenv = mkStdenv { libraryPaths = [ libgcc ]; }; + }).overrideAttrs + { + dontDisableStatic = true; + }; + libstdcxxStdenv = mkStdenv { + libraryPaths = [ + libgcc + libssp + ]; + }; + libstdcxx = + (pkgs.gccNGPackages.libstdcxx.override { + stdenv = libstdcxxStdenv; + inherit libgcc; + libbacktrace = pkgs.libbacktrace.override { + stdenv = libstdcxxStdenv; + }; + }).overrideAttrs + { + dontDisableStatic = true; + }; + sharedRuntime = pkgs.buildEnv { + name = "gcc-shared-runtime"; + paths = [ + libgcc + libstdcxx + ]; + pathsToLink = [ "/lib" ]; + }; + stdenv = mkStdenv { + libraryPaths = [ + libgcc + libssp + libstdcxx + ]; + ldflags = [ "-lssp" ]; + }; +in +{ + inherit sharedRuntime stdenv; +} diff --git a/nix/toolchains/linux-gnu-2.28/libc.nix b/nix/toolchains/linux-gnu-2.28/libc.nix new file mode 100644 index 0000000000..8f1c9864bd --- /dev/null +++ b/nix/toolchains/linux-gnu-2.28/libc.nix @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +{ + stdenv, + fetchurl, + linuxHeaders, + bison, + gawk, + python3, +}: + +stdenv.mkDerivation { + pname = "glibc"; + version = "2.28"; + enableParallelBuilding = true; + hardeningDisable = [ + "fortify" + "pic" + ]; + + src = fetchurl { + url = "https://ftp.gnu.org/gnu/glibc/glibc-2.28.tar.gz"; + hash = "sha256-8xjW4/H07Qt00oMqxPSR0PuSjkUcntpZTL8cO+569Hw="; + }; + + postPatch = '' + substituteInPlace sysdeps/gnu/Makefile \ + --replace-fail \ + '$(object-suffixes) $(object-suffixes:=.d)' \ + '$(object-suffixes)' + ''; + + nativeBuildInputs = [ + bison + gawk + python3 + ]; + + configureFlags = [ + "--with-headers=${linuxHeaders}/include" + "--disable-werror" + ]; + + preConfigure = '' + mkdir build + cd build + configureScript=../configure + ''; + + postConfigure = '' + export NIX_DONT_SET_RPATH=1 + ''; + + postFixup = '' + if grep -q "$out/lib64/" "$out/bin/ldd"; then + substituteInPlace "$out/bin/ldd" \ + --replace-fail "$out/lib64/" "$out/lib/" + fi + ''; + + env.NIX_NO_SELF_RPATH = true; + passthru.threadModel = "posix"; +} From e457974a525598e8d7d09fc0a55cbabef8265288 Mon Sep 17 00:00:00 2001 From: krishicks Date: Mon, 24 Aug 2026 18:39:21 +0000 Subject: [PATCH 005/505] feat(docker): export driver traces over OTLP (#2851) Mirror the VM and Podman driver tracing setup for Docker. Export Docker driver spans through OTLP/gRPC as the distinct openshell-driver-docker service, preserve gateway trace context, record lifecycle and asynchronous provisioning operations, and report gRPC failures. Docker currently runs in-process when selected as a built-in gateway driver. Add the same temporary server-boundary shim used by Podman so traces retain the shape they will have when Docker moves to a separate process. Generalize the gateway provider selection for both in-process drivers and share the OTLP collector fixture across Docker, Podman, and VM tracing tests. Signed-off-by: Kris Hicks --- AGENTS.md | 1 + Cargo.lock | 19 +- architecture/gateway.md | 4 +- crates/openshell-driver-docker/Cargo.toml | 9 +- crates/openshell-driver-docker/README.md | 6 + crates/openshell-driver-docker/src/lib.rs | 471 ++++++++++++++++-- .../src/otel_tracing.rs | 92 ++++ crates/openshell-driver-docker/src/tests.rs | 456 +++++++++++++++++ crates/openshell-driver-podman/Cargo.toml | 2 +- crates/openshell-driver-podman/src/grpc.rs | 2 +- .../src/otel_tracing.rs | 84 +--- crates/openshell-driver-vm/Cargo.toml | 2 +- .../openshell-driver-vm/src/otel_tracing.rs | 92 +--- crates/openshell-otel-test-support/Cargo.toml | 20 + crates/openshell-otel-test-support/src/lib.rs | 130 +++++ crates/openshell-otel/src/lib.rs | 39 +- crates/openshell-server/src/cli.rs | 2 +- crates/openshell-server/src/compute/mod.rs | 11 +- crates/openshell-server/src/otel_tracing.rs | 22 +- crates/openshell-server/src/tracing_setup.rs | 194 +++++--- docs/reference/gateway-config.mdx | 2 +- 21 files changed, 1368 insertions(+), 292 deletions(-) create mode 100644 crates/openshell-driver-docker/src/otel_tracing.rs create mode 100644 crates/openshell-otel-test-support/Cargo.toml create mode 100644 crates/openshell-otel-test-support/src/lib.rs diff --git a/AGENTS.md b/AGENTS.md index dda3e75098..8c88c94814 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,7 @@ These pipelines connect skills into end-to-end workflows. Individual skill files | `crates/openshell-gateway-interceptors/` | Gateway interceptors | Intercepts and transforms configured gRPC requests at the gateway routing boundary | | `crates/openshell-ocsf/` | OCSF logging | OCSF v1.7.0 event types, builders, shorthand/JSONL formatters, tracing layers | | `crates/openshell-otel/` | OpenTelemetry support | Shared OTLP trace provider, resource, and tracing-layer construction | +| `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-sdk/` | Shared client SDK | Async Rust gateway client (gRPC transport, TLS, OIDC refresh, edge tunnel); consumed by CLI, TUI, and `@openshell/sdk` | diff --git a/Cargo.lock b/Cargo.lock index 6200c73dd9..3ae582a12a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3948,6 +3948,10 @@ dependencies = [ "futures", "miette", "openshell-core", + "openshell-otel", + "openshell-otel-test-support", + "opentelemetry", + "opentelemetry_sdk", "prost-types", "serde", "serde_json", @@ -3959,6 +3963,7 @@ dependencies = [ "toml", "tonic", "tracing", + "tracing-opentelemetry", "tracing-subscriber", "url", ] @@ -4023,8 +4028,8 @@ dependencies = [ "nix 0.29.0", "openshell-core", "openshell-otel", + "openshell-otel-test-support", "opentelemetry", - "opentelemetry-proto", "opentelemetry_sdk", "prost-types", "rustix 1.1.4", @@ -4079,10 +4084,10 @@ dependencies = [ "oci-client", "openshell-core", "openshell-otel", + "openshell-otel-test-support", "openshell-policy", "openshell-vfio", "opentelemetry", - "opentelemetry-proto", "opentelemetry_sdk", "polling", "prost", @@ -4171,6 +4176,16 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "openshell-otel-test-support" +version = "0.0.0" +dependencies = [ + "opentelemetry-proto", + "tokio", + "tokio-stream", + "tonic", +] + [[package]] name = "openshell-policy" version = "0.0.0" diff --git a/architecture/gateway.md b/architecture/gateway.md index 32bca6a1f6..f7c80d1ed8 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -707,7 +707,9 @@ inbound request to provide a parent. gRPC status is recorded when response trailers arrive. The gateway forwards OTLP configuration and W3C trace context to managed -external drivers. Each driver exports under its own service name. +external drivers. Built-in drivers use dedicated in-process providers that +preserve the same RPC trace boundary. Each driver exports to the configured +collector under its own service name. Two invariants shape the failure behavior. Telemetry is diagnostic, so no OTLP failure stops the gateway from serving: a malformed endpoint is logged at diff --git a/crates/openshell-driver-docker/Cargo.toml b/crates/openshell-driver-docker/Cargo.toml index 065b3ff4b6..1c9e675f77 100644 --- a/crates/openshell-driver-docker/Cargo.toml +++ b/crates/openshell-driver-docker/Cargo.toml @@ -16,12 +16,16 @@ path = "src/main.rs" [dependencies] openshell-core = { path = "../openshell-core", default-features = false, features = ["driver-extraction"] } +openshell-otel = { path = "../openshell-otel" } +opentelemetry = { workspace = true } +tracing-opentelemetry = { workspace = true } tokio = { workspace = true } tonic = { workspace = true, features = ["transport"] } futures = { workspace = true } tokio-stream = { workspace = true } tracing = { workspace = true } +tracing-subscriber = { workspace = true } bytes = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -31,13 +35,16 @@ url = { workspace = true } clap = { workspace = true } miette = { workspace = true } toml = { workspace = true } -tracing-subscriber = { workspace = true } [dev-dependencies] +openshell-otel-test-support = { path = "../openshell-otel-test-support" } +opentelemetry = { workspace = true } +opentelemetry_sdk = { workspace = true, features = ["testing"] } prost-types = { workspace = true } tar = "0.4" temp-env = "0.3" tempfile = "3" +tracing-subscriber = { workspace = true } [lints] workspace = true diff --git a/crates/openshell-driver-docker/README.md b/crates/openshell-driver-docker/README.md index ac0d211582..17ae1cfa03 100644 --- a/crates/openshell-driver-docker/README.md +++ b/crates/openshell-driver-docker/README.md @@ -2,6 +2,12 @@ Docker-backed compute driver for local OpenShell gateways. +When the gateway configures `[openshell.gateway.otlp]`, Docker compute-driver +spans export to the same OTLP/gRPC collector with the service name +`openshell-driver-docker`. The in-process driver preserves the gateway trace +context and emits the compute-driver RPC boundary that a standalone driver +would expose. + The driver manages sandbox containers through the local Docker daemon with the `bollard` client. It is intended for developer environments where Docker is already available and running Kubernetes would be unnecessary. diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 33acf1a2c6..b1859f54cc 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -5,6 +5,8 @@ #![allow(clippy::result_large_err)] +pub mod otel_tracing; + use bollard::Docker; use bollard::errors::Error as BollardError; use bollard::models::{ @@ -55,17 +57,21 @@ use openshell_core::proto_struct::{ deserialize_optional_non_empty_string_list, struct_to_json_value, }; use openshell_core::{Config, Error, Result as CoreResult}; +use opentelemetry::trace::TraceContextExt as _; use std::collections::{HashMap, HashSet}; +use std::future::Future; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::path::{Path, PathBuf}; use std::pin::Pin; use std::sync::Arc; +use std::task::{Context, Poll}; use std::time::Duration; use tokio::sync::{Mutex, broadcast, mpsc}; use tokio::task::JoinHandle; use tokio_stream::wrappers::ReceiverStream; use tonic::{Request, Response, Status}; -use tracing::{debug, info, warn}; +use tracing::{Instrument as _, debug, info, warn}; +use tracing_opentelemetry::OpenTelemetrySpanExt as _; use url::Url; const WATCH_BUFFER: usize = 128; @@ -82,6 +88,28 @@ const HOST_OPENSHELL_INTERNAL: &str = "host.openshell.internal"; const HOST_DOCKER_INTERNAL: &str = "host.docker.internal"; const DOCKER_NETWORK_DRIVER: &str = "bridge"; +fn provisioning_span( + parent: &opentelemetry::Context, + sandbox: &DriverSandbox, + image_ref: &str, +) -> tracing::Span { + let span = tracing::info_span!( + parent: None, + "docker.provision", + otel.name = "docker.provision", + otel.status_code = tracing::field::Empty, + sandbox.id = %sandbox.id, + sandbox.name = %sandbox.name, + image.ref = %image_ref, + ); + let parent_span_context = parent.span().span_context().clone(); + if parent_span_context.is_valid() { + let parent = opentelemetry::Context::new().with_remote_span_context(parent_span_context); + let _ = span.set_parent(parent); + } + span +} + /// Gateway-local configuration for the Docker compute driver. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(default, deny_unknown_fields)] @@ -387,6 +415,119 @@ fn default_true() -> bool { type WatchStream = Pin> + Send + 'static>>; +struct TracedWatchStream { + inner: WatchStream, + span: tracing::Span, + finished: bool, +} + +impl Stream for TracedWatchStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let span = self.span.clone(); + let _entered = span.enter(); + let result = self.inner.as_mut().poll_next(cx); + if !self.finished { + match &result { + Poll::Ready(Some(Err(status))) => { + openshell_otel::mark_error(&self.span); + self.span + .record("rpc.grpc.status_code", status.code() as i32); + self.finished = true; + } + Poll::Ready(None) => { + self.span + .record("rpc.grpc.status_code", tonic::Code::Ok as i32); + self.finished = true; + } + Poll::Pending | Poll::Ready(Some(Ok(_))) => {} + } + } + result + } +} + +impl Drop for TracedWatchStream { + fn drop(&mut self) { + if !self.finished { + openshell_otel::mark_error(&self.span); + self.span + .record("rpc.grpc.status_code", tonic::Code::Cancelled as i32); + } + } +} + +/// Compute-driver service wrapper that preserves the standalone RPC trace +/// boundary while Docker runs in the gateway process. +#[derive(Clone)] +pub struct ComputeDriverService { + driver: DockerComputeDriver, + trace_in_process_rpc: bool, +} + +impl ComputeDriverService { + #[must_use] + pub fn new(driver: DockerComputeDriver) -> Self { + Self { + driver, + trace_in_process_rpc: false, + } + } + + #[must_use] + pub fn new_in_process(driver: DockerComputeDriver) -> Self { + Self { + driver, + trace_in_process_rpc: true, + } + } + + fn in_process_rpc_span( + &self, + operation: &'static str, + method: &'static str, + ) -> Option { + self.trace_in_process_rpc.then(|| { + tracing::info_span!( + target: "openshell_driver_docker::otel_tracing", + "driver_rpc", + otel.name = operation, + otel.kind = "server", + otel.status_code = tracing::field::Empty, + rpc.system = "grpc", + rpc.service = "openshell.compute.v1.ComputeDriver", + rpc.method = method, + rpc.grpc.status_code = tracing::field::Empty, + ) + }) + } + + async fn trace_rpc( + &self, + operation: &'static str, + method: &'static str, + future: impl Future>, + ) -> Result { + use tracing::Instrument as _; + + let Some(span) = self.in_process_rpc_span(operation, method) else { + return future.await; + }; + let result = future.instrument(span.clone()).await; + match &result { + Ok(_) => { + span.record("rpc.grpc.status_code", tonic::Code::Ok as i32); + } + Err(status) => { + openshell_otel::mark_error(&span); + span.record("rpc.grpc.status_code", status.code() as i32); + } + } + result + } +} + impl DockerComputeDriver { pub async fn new(config: &Config, docker_config: &DockerComputeConfig) -> CoreResult { let socket_path = docker_config @@ -728,7 +869,7 @@ impl DockerComputeDriver { &sandbox.id, "Scheduled", format!("Docker sandbox accepted for image \"{image}\""), - HashMap::from([("image_ref".to_string(), image)]), + HashMap::from([("image_ref".to_string(), image.clone())]), ); self.publish_sandbox_snapshot(pending_sandbox_snapshot( sandbox, @@ -740,9 +881,14 @@ impl DockerComputeDriver { let driver = self.clone(); let sandbox_for_task = sandbox.clone(); let sandbox_id = sandbox.id.clone(); - let task = tokio::spawn(async move { - driver.provision_sandbox(sandbox_for_task).await; - }); + let parent = tracing::Span::current().context(); + let provisioning_span = provisioning_span(&parent, sandbox, &image); + let task = tokio::spawn( + async move { + driver.provision_sandbox(sandbox_for_task).await; + } + .instrument(provisioning_span), + ); let mut pending = self.pending.lock().await; if let Some(record) = pending.get_mut(&sandbox_id) { @@ -765,20 +911,42 @@ impl DockerComputeDriver { } } + #[tracing::instrument( + name = "docker.provision_sandbox", + skip(self, sandbox), + fields( + otel.name = "docker.provision_sandbox", + otel.status_code = tracing::field::Empty, + sandbox.id = %sandbox.id, + sandbox.name = %sandbox.name, + ) + )] async fn provision_sandbox_inner( &self, sandbox: &DriverSandbox, ) -> Result<(), DockerProvisioningFailure> { + let span_status = openshell_otel::ErrorStatusGuard::current(); let validated = Self::validated_sandbox(sandbox, &self.config).map_err(|status| { DockerProvisioningFailure::new("ContainerCreateFailed", status.message()) })?; let template = validated.template; - let image = self - .ensure_image_available(&sandbox.id, &template.image) - .await - .map_err(|status| { - DockerProvisioningFailure::new("ImagePullFailed", status.message()) - })?; + let image = async { + openshell_otel::record_error_result( + self.ensure_image_available(&sandbox.id, &template.image) + .await + .map_err(|status| { + DockerProvisioningFailure::new("ImagePullFailed", status.message()) + }), + ) + } + .instrument(tracing::info_span!( + "docker.prepare_image", + otel.name = "docker.prepare_image", + otel.status_code = tracing::field::Empty, + sandbox.id = %sandbox.id, + image.ref = %template.image, + )) + .await?; let token_file_created = write_sandbox_token_file(sandbox, &self.config) .await .map_err(|status| { @@ -812,25 +980,37 @@ impl DockerComputeDriver { } DockerProvisioningFailure::new("ContainerCreateFailed", status.message()) })?; - self.docker - .create_container( - Some( - CreateContainerOptionsBuilder::default() - .name(container_name.as_str()) - .build(), - ), - create_body, + async { + openshell_otel::record_error_result( + self.docker + .create_container( + Some( + CreateContainerOptionsBuilder::default() + .name(container_name.as_str()) + .build(), + ), + create_body, + ) + .await + .map_err(|err| { + if token_file_created { + cleanup_sandbox_token_file(sandbox, &self.config); + } + DockerProvisioningFailure::from_status( + "ContainerCreateFailed", + create_status_from_docker_error("create docker sandbox container", err), + ) + }), ) - .await - .map_err(|err| { - if token_file_created { - cleanup_sandbox_token_file(sandbox, &self.config); - } - DockerProvisioningFailure::from_status( - "ContainerCreateFailed", - create_status_from_docker_error("create docker sandbox container", err), - ) - })?; + } + .instrument(tracing::info_span!( + "docker.create_container", + otel.name = "docker.create_container", + otel.status_code = tracing::field::Empty, + sandbox.id = %sandbox.id, + container.name = %container_name, + )) + .await?; self.publish_docker_progress( &sandbox.id, "Created", @@ -838,7 +1018,20 @@ impl DockerComputeDriver { HashMap::from([("container_name".to_string(), container_name.clone())]), ); - if let Err(err) = self.docker.start_container(&container_name, None).await { + let start_result = async { + openshell_otel::record_error_result( + self.docker.start_container(&container_name, None).await, + ) + } + .instrument(tracing::info_span!( + "docker.start_container", + otel.name = "docker.start_container", + otel.status_code = tracing::field::Empty, + sandbox.id = %sandbox.id, + container.name = %container_name, + )) + .await; + if let Err(err) = start_result { let cleanup = self .docker .remove_container( @@ -879,7 +1072,7 @@ impl DockerComputeDriver { ); } - Ok(()) + span_status.finish(Ok(())) } async fn delete_sandbox_inner( @@ -993,17 +1186,29 @@ impl DockerComputeDriver { /// Returns `Ok(true)` when a container existed and was started (or was /// already running), `Ok(false)` when no managed container is found for /// the sandbox, and `Err(...)` for any Docker failure. + #[tracing::instrument( + name = "docker.start_sandbox", + skip(self), + fields( + otel.name = "docker.start_sandbox", + otel.status_code = tracing::field::Empty, + sandbox.id = %sandbox_id, + sandbox.name = %sandbox_name, + ) + )] pub async fn start_sandbox( &self, sandbox_id: &str, sandbox_name: &str, ) -> Result { + let span_status = openshell_otel::ErrorStatusGuard::current(); + require_sandbox_identifier(sandbox_id, sandbox_name)?; self.lifecycle_event_fences.begin_start(sandbox_id); let result = self .start_sandbox_with_lifecycle_fence(sandbox_id, sandbox_name) .await; self.lifecycle_event_fences.finish_start(sandbox_id); - result + span_status.finish(result) } async fn start_sandbox_with_lifecycle_fence( @@ -1504,6 +1709,168 @@ impl DockerComputeDriver { } } +#[tonic::async_trait] +impl ComputeDriver for ComputeDriverService { + type WatchSandboxesStream = WatchStream; + + async fn get_capabilities( + &self, + request: Request, + ) -> Result, Status> { + self.trace_rpc( + "driver.get_capabilities", + "get_capabilities", + ComputeDriver::get_capabilities(&self.driver, request), + ) + .await + } + + async fn get_gateway_listener_requirements( + &self, + request: Request, + ) -> Result, Status> { + self.trace_rpc( + "driver.get_gateway_listener_requirements", + "get_gateway_listener_requirements", + ComputeDriver::get_gateway_listener_requirements(&self.driver, request), + ) + .await + } + + async fn validate_sandbox_create( + &self, + request: Request, + ) -> Result, Status> { + self.trace_rpc( + "driver.validate_sandbox_create", + "validate_sandbox_create", + ComputeDriver::validate_sandbox_create(&self.driver, request), + ) + .await + } + + async fn get_sandbox( + &self, + request: Request, + ) -> Result, Status> { + self.trace_rpc( + "driver.get_sandbox", + "get_sandbox", + ComputeDriver::get_sandbox(&self.driver, request), + ) + .await + } + + async fn list_sandboxes( + &self, + request: Request, + ) -> Result, Status> { + self.trace_rpc( + "driver.list_sandboxes", + "list_sandboxes", + ComputeDriver::list_sandboxes(&self.driver, request), + ) + .await + } + + async fn create_sandbox( + &self, + request: Request, + ) -> Result, Status> { + self.trace_rpc( + "driver.create_sandbox", + "create_sandbox", + ComputeDriver::create_sandbox(&self.driver, request), + ) + .await + } + + async fn stop_sandbox( + &self, + request: Request, + ) -> Result, Status> { + self.trace_rpc( + "driver.stop_sandbox", + "stop_sandbox", + ComputeDriver::stop_sandbox(&self.driver, request), + ) + .await + } + + async fn start_sandbox( + &self, + request: Request, + ) -> Result, Status> { + self.trace_rpc( + "driver.start_sandbox", + "start_sandbox", + ComputeDriver::start_sandbox(&self.driver, request), + ) + .await + } + + async fn delete_sandbox( + &self, + request: Request, + ) -> Result, Status> { + self.trace_rpc( + "driver.delete_sandbox", + "delete_sandbox", + ComputeDriver::delete_sandbox(&self.driver, request), + ) + .await + } + + async fn watch_sandboxes( + &self, + request: Request, + ) -> Result, Status> { + use tracing::Instrument as _; + + let create_stream = ComputeDriver::watch_sandboxes(&self.driver, request); + let Some(span) = self.in_process_rpc_span("driver.watch_sandboxes", "watch_sandboxes") + else { + return create_stream.await; + }; + match create_stream.instrument(span.clone()).await { + Ok(response) => Ok(Response::new(Box::pin(TracedWatchStream { + inner: response.into_inner(), + span, + finished: false, + }))), + Err(status) => { + openshell_otel::mark_error(&span); + span.record("rpc.grpc.status_code", status.code() as i32); + Err(status) + } + } + } + + async fn ensure_workspace( + &self, + request: Request, + ) -> Result, Status> { + self.trace_rpc( + "driver.ensure_workspace", + "ensure_workspace", + ComputeDriver::ensure_workspace(&self.driver, request), + ) + .await + } + + async fn delete_workspace( + &self, + request: Request, + ) -> Result, Status> { + self.trace_rpc( + "driver.delete_workspace", + "delete_workspace", + ComputeDriver::delete_workspace(&self.driver, request), + ) + .await + } +} + #[tonic::async_trait] impl ComputeDriver for DockerComputeDriver { type WatchSandboxesStream = WatchStream; @@ -1590,22 +1957,44 @@ impl ComputeDriver for DockerComputeDriver { })) } + #[tracing::instrument( + name = "docker.schedule_sandbox", + skip(self, request), + fields( + otel.name = "docker.schedule_sandbox", + otel.status_code = tracing::field::Empty, + sandbox.id = %request.get_ref().sandbox.as_ref().map_or("", |sandbox| sandbox.id.as_str()), + sandbox.name = %request.get_ref().sandbox.as_ref().map_or("", |sandbox| sandbox.name.as_str()), + ) + )] async fn create_sandbox( &self, request: Request, ) -> Result, Status> { + let span_status = openshell_otel::ErrorStatusGuard::current(); let sandbox = request .into_inner() .sandbox .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; self.create_sandbox_inner(&sandbox).await?; - Ok(Response::new(CreateSandboxResponse {})) + span_status.finish(Ok(Response::new(CreateSandboxResponse {}))) } + #[tracing::instrument( + name = "docker.stop_sandbox", + skip(self, request), + fields( + otel.name = "docker.stop_sandbox", + otel.status_code = tracing::field::Empty, + sandbox.id = %request.get_ref().sandbox_id, + sandbox.name = %request.get_ref().sandbox_name, + ) + )] async fn stop_sandbox( &self, request: Request, ) -> Result, Status> { + let span_status = openshell_otel::ErrorStatusGuard::current(); let request = request.into_inner(); require_sandbox_identifier(&request.sandbox_id, &request.sandbox_name)?; @@ -1613,7 +2002,7 @@ impl ComputeDriver for DockerComputeDriver { .await?; self.publish_container_snapshot(&request.sandbox_id, &request.sandbox_name) .await?; - Ok(Response::new(StopSandboxResponse {})) + span_status.finish(Ok(Response::new(StopSandboxResponse {}))) } async fn start_sandbox( @@ -1621,7 +2010,6 @@ impl ComputeDriver for DockerComputeDriver { request: Request, ) -> Result, Status> { let request = request.into_inner(); - require_sandbox_identifier(&request.sandbox_id, &request.sandbox_name)?; if !Self::start_sandbox(self, &request.sandbox_id, &request.sandbox_name).await? { return Err(Status::not_found("sandbox not found")); } @@ -1630,10 +2018,21 @@ impl ComputeDriver for DockerComputeDriver { Ok(Response::new(StartSandboxResponse {})) } + #[tracing::instrument( + name = "docker.delete_sandbox", + skip(self, request), + fields( + otel.name = "docker.delete_sandbox", + otel.status_code = tracing::field::Empty, + sandbox.id = %request.get_ref().sandbox_id, + sandbox.name = %request.get_ref().sandbox_name, + ) + )] async fn delete_sandbox( &self, request: Request, ) -> Result, Status> { + let span_status = openshell_otel::ErrorStatusGuard::current(); let request = request.into_inner(); require_sandbox_identifier(&request.sandbox_id, &request.sandbox_name)?; @@ -1652,7 +2051,7 @@ impl ComputeDriver for DockerComputeDriver { }); } - Ok(Response::new(DeleteSandboxResponse { deleted })) + span_status.finish(Ok(Response::new(DeleteSandboxResponse { deleted }))) } async fn watch_sandboxes( diff --git a/crates/openshell-driver-docker/src/otel_tracing.rs b/crates/openshell-driver-docker/src/otel_tracing.rs new file mode 100644 index 0000000000..9c388b5f04 --- /dev/null +++ b/crates/openshell-driver-docker/src/otel_tracing.rs @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OpenTelemetry trace exporting for the Docker compute driver. + +use openshell_otel::{OtlpTraceConfig, SdkTracerProvider, ServiceName, SetupError}; +use tracing::Subscriber; +use tracing_subscriber::registry::LookupSpan; + +const SERVICE_NAME: &str = "openshell-driver-docker"; +const INSTRUMENTATION_SCOPE: &str = "openshell-driver-docker"; +pub const IN_PROCESS_TARGET_PREFIX: &str = "openshell_driver_docker"; + +#[must_use] +pub fn provider_for(endpoint: Option<&str>) -> (Option, Option) { + openshell_otel::provider_for(endpoint.map(|endpoint| OtlpTraceConfig { + endpoint, + service_name: ServiceName::Fixed(SERVICE_NAME), + service_version: Some(openshell_core::VERSION), + resource_attributes: Vec::new(), + })) +} + +pub fn layer(provider: &SdkTracerProvider) -> openshell_otel::OtlpLayer +where + S: Subscriber + for<'span> LookupSpan<'span>, +{ + openshell_otel::layer(provider, INSTRUMENTATION_SCOPE) +} + +pub fn in_process_layer(provider: &SdkTracerProvider) -> openshell_otel::TargetOtlpLayer +where + S: Subscriber + for<'span> LookupSpan<'span>, +{ + openshell_otel::layer_for_target_prefix( + provider, + INSTRUMENTATION_SCOPE, + IN_PROCESS_TARGET_PREFIX, + ) +} + +#[cfg(test)] +pub(crate) async fn test_lock() -> tokio::sync::MutexGuard<'static, ()> { + static LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + static INITIALIZED: std::sync::LazyLock<()> = std::sync::LazyLock::new(|| { + tracing::subscriber::set_global_default(tracing_subscriber::registry()) + .expect("test tracing subscriber installs once"); + }); + + let guard = LOCK.lock().await; + std::sync::LazyLock::force(&INITIALIZED); + guard +} + +#[cfg(test)] +mod tests { + use openshell_otel_test_support::OtlpTestServer; + use tracing_subscriber::layer::SubscriberExt as _; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn tracing_docker_driver_spans_reach_otlp_collector_with_distinct_service_name() { + let _tracing_lock = super::test_lock().await; + let collector = OtlpTestServer::start().await; + + let (provider, error) = super::provider_for(Some(collector.endpoint())); + assert!(error.is_none()); + let provider = provider.expect("provider"); + let subscriber = tracing_subscriber::registry().with(super::layer(&provider)); + tracing::subscriber::with_default(subscriber, || { + let span = tracing::info_span!("docker.schedule_sandbox", sandbox.id = "sb-otlp"); + drop(span.enter()); + drop(span); + }); + provider.force_flush().unwrap(); + collector.wait_for_export().await; + provider.shutdown().unwrap(); + let received = collector.shutdown().await; + + assert!( + received + .spans + .iter() + .any(|span| span.name == "docker.schedule_sandbox") + ); + assert!( + received + .service_names + .iter() + .any(|name| name == "openshell-driver-docker") + ); + } +} diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index f828120f63..b52cb87836 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -166,6 +166,462 @@ fn test_driver_with_config(config: DockerDriverRuntimeConfig) -> DockerComputeDr } } +#[tokio::test] +async fn tracing_in_process_service_preserves_the_driver_rpc_server_boundary() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::{Instrument as _, instrument::WithSubscriber as _}; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = otel_tracing::test_lock().await; + let gateway_exporter = InMemorySpanExporterBuilder::new().build(); + let gateway_provider = SdkTracerProvider::builder() + .with_simple_exporter(gateway_exporter.clone()) + .build(); + let driver_exporter = InMemorySpanExporterBuilder::new().build(); + let driver_provider = SdkTracerProvider::builder() + .with_simple_exporter(driver_exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry() + .with(openshell_otel::layer_excluding_target_prefix( + &gateway_provider, + "gateway-test", + Some(otel_tracing::IN_PROCESS_TARGET_PREFIX), + )) + .with(otel_tracing::in_process_layer(&driver_provider)); + let service = ComputeDriverService::new_in_process(test_driver_with_config(runtime_config())); + + async { + let gateway_span = tracing::info_span!( + target: "openshell_server::compute", + "driver", + otel.name = "driver.get_capabilities", + otel.kind = "client" + ); + ComputeDriver::get_capabilities(&service, Request::new(GetCapabilitiesRequest {})) + .instrument(gateway_span) + .await?; + + let unrelated = tracing::info_span!( + target: "openshell_driver_kubernetes::compute", + "kubernetes.operation" + ); + drop(unrelated.enter()); + drop(unrelated); + Ok::<_, Status>(()) + } + .with_subscriber(subscriber) + .await + .expect("capabilities should succeed"); + async { + let gateway_span = tracing::info_span!( + target: "openshell_server::compute", + "driver", + otel.name = "driver.validate_sandbox_create", + otel.kind = "client" + ); + ComputeDriver::validate_sandbox_create( + &service, + Request::new(ValidateSandboxCreateRequest { sandbox: None }), + ) + .instrument(gateway_span) + .await + } + .with_subscriber( + tracing_subscriber::registry() + .with(openshell_otel::layer_excluding_target_prefix( + &gateway_provider, + "gateway-test", + Some(otel_tracing::IN_PROCESS_TARGET_PREFIX), + )) + .with(otel_tracing::in_process_layer(&driver_provider)), + ) + .await + .expect_err("missing sandbox should fail"); + gateway_provider.force_flush().unwrap(); + driver_provider.force_flush().unwrap(); + + let gateway_spans = gateway_exporter.get_finished_spans().unwrap(); + let driver_spans = driver_exporter.get_finished_spans().unwrap(); + let client = gateway_spans + .iter() + .find(|span| span.name == "driver.get_capabilities") + .unwrap(); + let server = driver_spans + .iter() + .find(|span| span.name == "driver.get_capabilities") + .expect("in-process server span"); + assert_eq!( + server.span_context.trace_id(), + client.span_context.trace_id() + ); + assert_eq!(server.parent_span_id, client.span_context.span_id()); + assert_eq!(server.span_kind, opentelemetry::trace::SpanKind::Server); + assert!(server.attributes.iter().any(|attribute| { + attribute.key.as_str() == "rpc.grpc.status_code" + && attribute.value.to_string() == (tonic::Code::Ok as i32).to_string() + })); + assert!( + gateway_spans + .iter() + .any(|span| span.name == "kubernetes.operation"), + "unrelated driver targets must remain gateway spans" + ); + assert!( + driver_spans + .iter() + .all(|span| span.name != "kubernetes.operation"), + "the Docker provider must not claim unrelated driver spans" + ); + let failed = driver_spans + .iter() + .find(|span| span.name == "driver.validate_sandbox_create") + .expect("failed in-process server span"); + assert!(matches!( + failed.status, + opentelemetry::trace::Status::Error { .. } + )); + assert!(failed.attributes.iter().any(|attribute| { + attribute.key.as_str() == "rpc.grpc.status_code" + && attribute.value.to_string() == (tonic::Code::InvalidArgument as i32).to_string() + })); + gateway_provider.shutdown().unwrap(); + driver_provider.shutdown().unwrap(); +} + +#[tokio::test] +async fn tracing_lifecycle_rpc_failures_export_docker_operation_spans() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = otel_tracing::test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry().with(otel_tracing::layer(&provider)); + let driver = test_driver_with_config(runtime_config()); + + async { + ComputeDriver::create_sandbox( + &driver, + Request::new(CreateSandboxRequest { sandbox: None }), + ) + .await + .expect_err("missing sandbox should fail"); + ComputeDriver::start_sandbox(&driver, Request::new(StartSandboxRequest::default())) + .await + .expect_err("missing start identifier should fail"); + ComputeDriver::stop_sandbox(&driver, Request::new(StopSandboxRequest::default())) + .await + .expect_err("missing stop identifier should fail"); + ComputeDriver::delete_sandbox(&driver, Request::new(DeleteSandboxRequest::default())) + .await + .expect_err("missing delete identifier should fail"); + } + .with_subscriber(subscriber) + .await; + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + for name in [ + "docker.schedule_sandbox", + "docker.start_sandbox", + "docker.stop_sandbox", + "docker.delete_sandbox", + ] { + let span = spans + .iter() + .find(|span| span.name == name) + .unwrap_or_else(|| panic!("{name} should be exported")); + assert!( + matches!(span.status, opentelemetry::trace::Status::Error { .. }), + "{name} should record the failed operation" + ); + } + provider.shutdown().unwrap(); +} + +#[tokio::test] +async fn tracing_direct_start_exports_a_docker_start_span() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = otel_tracing::test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry().with(otel_tracing::layer(&provider)); + let driver = test_driver_with_config(runtime_config()); + + DockerComputeDriver::start_sandbox(&driver, "", "") + .with_subscriber(subscriber) + .await + .expect_err("missing identifier should fail"); + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|span| span.name == "docker.start_sandbox") + .expect("direct startup operation should export docker.start_sandbox"); + assert!(matches!( + span.status, + opentelemetry::trace::Status::Error { .. } + )); + provider.shutdown().unwrap(); +} + +#[tokio::test] +async fn tracing_image_preparation_failure_exports_nested_failed_spans() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = otel_tracing::test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry().with(otel_tracing::layer(&provider)); + let mut config = runtime_config(); + config.image_pull_policy = "unsupported".to_string(); + let driver = test_driver_with_config(config); + + driver + .provision_sandbox_inner(&test_sandbox()) + .with_subscriber(subscriber) + .await + .expect_err("unsupported image pull policy should fail provisioning"); + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let provision = spans + .iter() + .find(|span| span.name == "docker.provision_sandbox") + .expect("provisioning span should be exported"); + assert!(matches!( + provision.status, + opentelemetry::trace::Status::Error { .. } + )); + let prepare_image = spans + .iter() + .find(|span| span.name == "docker.prepare_image") + .expect("image preparation span should be exported"); + assert_eq!( + prepare_image.parent_span_id, + provision.span_context.span_id() + ); + assert!(matches!( + prepare_image.status, + opentelemetry::trace::Status::Error { .. } + )); + provider.shutdown().unwrap(); +} + +#[tokio::test] +async fn background_provisioning_does_not_extend_the_scheduling_span_lifetime() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::Instrument as _; + use tracing_opentelemetry::OpenTelemetrySpanExt as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = otel_tracing::test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry().with(otel_tracing::layer(&provider)); + let dispatch = tracing::Dispatch::new(subscriber); + let _dispatch = tracing::dispatcher::set_default(&dispatch); + + let scheduling = tracing::info_span!("docker.schedule_sandbox"); + let entered = scheduling.enter(); + let sandbox = test_sandbox(); + let provisioning = provisioning_span(&scheduling.context(), &sandbox, "test-image"); + let task = tokio::spawn(futures::future::pending::<()>().instrument(provisioning)); + drop(entered); + drop(scheduling); + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + assert!( + spans + .iter() + .any(|span| span.name == "docker.schedule_sandbox"), + "the scheduling span should finish while background provisioning is pending" + ); + assert!( + spans.iter().all(|span| span.name != "docker.provision"), + "the provisioning span should remain open with the background task" + ); + + task.abort(); + task.await + .expect_err("the pending task should be cancelled"); + provider.shutdown().unwrap(); +} + +#[tokio::test] +async fn tracing_in_process_stream_span_lives_until_stream_failure() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = otel_tracing::test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry().with(otel_tracing::in_process_layer(&provider)); + + async { + let span = tracing::info_span!( + target: "openshell_driver_docker::otel_tracing", + "driver_rpc", + otel.name = "driver.watch_sandboxes", + otel.kind = "server", + otel.status_code = tracing::field::Empty, + rpc.grpc.status_code = tracing::field::Empty, + ); + let inner: WatchStream = Box::pin(futures::stream::iter([Err(Status::internal( + "watch failed", + ))])); + let mut stream = TracedWatchStream { + inner, + span, + finished: false, + }; + + provider.force_flush().unwrap(); + assert!( + exporter.get_finished_spans().unwrap().is_empty(), + "server span must remain open while the response stream is alive" + ); + stream + .next() + .await + .expect("stream item") + .expect_err("stream should fail"); + drop(stream); + } + .with_subscriber(subscriber) + .await; + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|span| span.name == "driver.watch_sandboxes") + .expect("watch server span should be exported when the stream ends"); + assert!(matches!( + span.status, + opentelemetry::trace::Status::Error { .. } + )); + provider.shutdown().unwrap(); +} + +#[tokio::test] +async fn tracing_in_process_stream_records_ok_when_stream_completes() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = otel_tracing::test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry().with(otel_tracing::in_process_layer(&provider)); + + async { + let span = tracing::info_span!( + target: "openshell_driver_docker::otel_tracing", + "driver_rpc", + otel.name = "driver.watch_sandboxes", + otel.kind = "server", + otel.status_code = tracing::field::Empty, + rpc.grpc.status_code = tracing::field::Empty, + ); + let inner: WatchStream = Box::pin(futures::stream::empty()); + let mut stream = TracedWatchStream { + inner, + span, + finished: false, + }; + + assert!(stream.next().await.is_none()); + drop(stream); + } + .with_subscriber(subscriber) + .await; + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|span| span.name == "driver.watch_sandboxes") + .expect("watch server span should be exported when the stream completes"); + assert!(span.attributes.iter().any(|attribute| { + attribute.key.as_str() == "rpc.grpc.status_code" + && attribute.value.to_string() == (tonic::Code::Ok as i32).to_string() + })); + provider.shutdown().unwrap(); +} + +#[tokio::test] +async fn tracing_in_process_stream_records_cancelled_when_dropped() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = otel_tracing::test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry().with(otel_tracing::in_process_layer(&provider)); + + async { + let span = tracing::info_span!( + target: "openshell_driver_docker::otel_tracing", + "driver_rpc", + otel.name = "driver.watch_sandboxes", + otel.kind = "server", + otel.status_code = tracing::field::Empty, + rpc.grpc.status_code = tracing::field::Empty, + ); + let inner: WatchStream = Box::pin(futures::stream::pending()); + let stream = TracedWatchStream { + inner, + span, + finished: false, + }; + + drop(stream); + } + .with_subscriber(subscriber) + .await; + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|span| span.name == "driver.watch_sandboxes") + .expect("watch server span should be exported when the stream is cancelled"); + assert!(matches!( + span.status, + opentelemetry::trace::Status::Error { .. } + )); + assert!(span.attributes.iter().any(|attribute| { + attribute.key.as_str() == "rpc.grpc.status_code" + && attribute.value.to_string() == (tonic::Code::Cancelled as i32).to_string() + })); + provider.shutdown().unwrap(); +} + #[tokio::test] async fn gateway_listener_requirements_report_managed_bridge_address() { let config = runtime_config(); diff --git a/crates/openshell-driver-podman/Cargo.toml b/crates/openshell-driver-podman/Cargo.toml index 7442979711..8b3e014e8c 100644 --- a/crates/openshell-driver-podman/Cargo.toml +++ b/crates/openshell-driver-podman/Cargo.toml @@ -43,7 +43,7 @@ miette = { workspace = true } url = { workspace = true } [dev-dependencies] -opentelemetry-proto = { version = "0.32", default-features = false, features = ["gen-tonic", "trace"] } +openshell-otel-test-support = { path = "../openshell-otel-test-support" } opentelemetry_sdk = { workspace = true, features = ["testing"] } prost-types = { workspace = true } temp-env = "0.3" diff --git a/crates/openshell-driver-podman/src/grpc.rs b/crates/openshell-driver-podman/src/grpc.rs index 7a7275a8a6..2cb59f086f 100644 --- a/crates/openshell-driver-podman/src/grpc.rs +++ b/crates/openshell-driver-podman/src/grpc.rs @@ -432,7 +432,7 @@ mod tests { .with(openshell_otel::layer_excluding_target_prefix( &gateway_provider, "gateway-test", - crate::otel_tracing::IN_PROCESS_TARGET_PREFIX, + Some(crate::otel_tracing::IN_PROCESS_TARGET_PREFIX), )) .with(crate::otel_tracing::in_process_layer(&driver_provider)); let service = ComputeDriverService::new_in_process(PodmanComputeDriver::for_tests( diff --git a/crates/openshell-driver-podman/src/otel_tracing.rs b/crates/openshell-driver-podman/src/otel_tracing.rs index 9d8f494276..51d42a88fe 100644 --- a/crates/openshell-driver-podman/src/otel_tracing.rs +++ b/crates/openshell-driver-podman/src/otel_tracing.rs @@ -132,59 +132,9 @@ pub(crate) async fn test_lock() -> tokio::sync::MutexGuard<'static, ()> { #[cfg(test)] mod tests { - use std::sync::{Arc, Mutex}; - - use opentelemetry_proto::tonic::collector::trace::v1::{ - ExportTraceServiceRequest, ExportTraceServiceResponse, - trace_service_server::{TraceService, TraceServiceServer}, - }; - use opentelemetry_proto::tonic::trace::v1::Span; + use openshell_otel_test_support::OtlpTestServer; use tracing_subscriber::layer::SubscriberExt as _; - #[derive(Default)] - struct Received { - spans: Vec, - service_names: Vec, - } - - #[derive(Clone)] - struct Collector { - received: Arc>, - exported: Arc, - } - - #[tonic::async_trait] - impl TraceService for Collector { - async fn export( - &self, - request: tonic::Request, - ) -> Result, tonic::Status> { - let mut received = self.received.lock().unwrap(); - for resource_span in request.into_inner().resource_spans { - if let Some(resource) = resource_span.resource { - received.service_names.extend( - resource - .attributes - .into_iter() - .filter(|attribute| attribute.key == "service.name") - .filter_map(|attribute| attribute.value) - .filter_map(|value| value.value) - .filter_map(|value| match value { - opentelemetry_proto::tonic::common::v1::any_value::Value::StringValue(value) => Some(value), - _ => None, - }), - ); - } - for scope_span in resource_span.scope_spans { - received.spans.extend(scope_span.spans); - } - } - drop(received); - self.exported.notify_one(); - Ok(tonic::Response::new(ExportTraceServiceResponse::default())) - } - } - #[test] fn compute_driver_rpc_names_are_explicitly_mapped_and_schema_bounded() { for (rpc, operation, method) in [ @@ -243,28 +193,9 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn podman_driver_spans_reach_otlp_collector_with_distinct_service_name() { let _tracing_lock = super::test_lock().await; - let received = Arc::new(Mutex::new(Received::default())); - let exported = Arc::new(tokio::sync::Notify::new()); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - let collector = Collector { - received: Arc::clone(&received), - exported: Arc::clone(&exported), - }; - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); - let server = tokio::spawn(async move { - tonic::transport::Server::builder() - .add_service(TraceServiceServer::new(collector)) - .serve_with_incoming_shutdown( - tokio_stream::wrappers::TcpListenerStream::new(listener), - async { - let _ = shutdown_rx.await; - }, - ) - .await - }); + let collector = OtlpTestServer::start().await; - let (provider, error) = super::provider_for(Some(&format!("http://{address}"))); + let (provider, error) = super::provider_for(Some(collector.endpoint())); assert!(error.is_none()); let provider = provider.expect("provider"); let subscriber = tracing_subscriber::registry().with(super::layer(&provider)); @@ -273,16 +204,11 @@ mod tests { drop(span.enter()); drop(span); }); - let export_completed = exported.notified(); provider.force_flush().unwrap(); - tokio::time::timeout(std::time::Duration::from_secs(5), export_completed) - .await - .expect("OTLP export should complete"); + collector.wait_for_export().await; provider.shutdown().unwrap(); - shutdown_tx.send(()).unwrap(); - server.await.unwrap().unwrap(); + let received = collector.shutdown().await; - let received = received.lock().unwrap(); assert!( received .spans diff --git a/crates/openshell-driver-vm/Cargo.toml b/crates/openshell-driver-vm/Cargo.toml index 0ee2740c77..ebcb9d2bc2 100644 --- a/crates/openshell-driver-vm/Cargo.toml +++ b/crates/openshell-driver-vm/Cargo.toml @@ -61,10 +61,10 @@ default = ["telemetry"] telemetry = ["openshell-core/telemetry"] [dev-dependencies] +openshell-otel-test-support = { path = "../openshell-otel-test-support" } temp-env = "0.3" tempfile = "3" opentelemetry_sdk = { workspace = true, features = ["testing"] } -opentelemetry-proto = { version = "0.32", default-features = false, features = ["gen-tonic", "trace"] } # smol-rs/polling drives the BSD/macOS parent-death detection in # procguard via kqueue's EVFILT_PROC / NOTE_EXIT filter. We could use diff --git a/crates/openshell-driver-vm/src/otel_tracing.rs b/crates/openshell-driver-vm/src/otel_tracing.rs index 7ae75357db..e956e8310d 100644 --- a/crates/openshell-driver-vm/src/otel_tracing.rs +++ b/crates/openshell-driver-vm/src/otel_tracing.rs @@ -109,60 +109,9 @@ where #[cfg(test)] mod tests { - use std::sync::{Arc, Mutex}; - - use opentelemetry_proto::tonic::collector::trace::v1::{ - ExportTraceServiceRequest, ExportTraceServiceResponse, - trace_service_server::{TraceService, TraceServiceServer}, - }; - use opentelemetry_proto::tonic::trace::v1::Span; + use openshell_otel_test_support::OtlpTestServer; use tracing_subscriber::layer::SubscriberExt as _; - #[derive(Default)] - struct Received { - spans: Vec, - service_names: Vec, - } - - #[derive(Clone)] - struct Collector { - received: Arc>, - exported: Arc, - } - - #[tonic::async_trait] - impl TraceService for Collector { - async fn export( - &self, - request: tonic::Request, - ) -> Result, tonic::Status> { - { - let mut received = self.received.lock().unwrap(); - for resource_span in request.into_inner().resource_spans { - if let Some(resource) = resource_span.resource { - received.service_names.extend( - resource - .attributes - .into_iter() - .filter(|attribute| attribute.key == "service.name") - .filter_map(|attribute| attribute.value) - .filter_map(|value| value.value) - .filter_map(|value| match value { - opentelemetry_proto::tonic::common::v1::any_value::Value::StringValue(value) => Some(value), - _ => None, - }), - ); - } - for scope_span in resource_span.scope_spans { - received.spans.extend(scope_span.spans); - } - } - } - self.exported.notify_one(); - Ok(tonic::Response::new(ExportTraceServiceResponse::default())) - } - } - #[test] fn compute_driver_rpc_names_are_explicitly_mapped_and_schema_bounded() { for (rpc, operation, method) in [ @@ -212,28 +161,9 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn vm_driver_spans_reach_otlp_collector_with_distinct_service_name() { - let received = Arc::new(Mutex::new(Received::default())); - let exported = Arc::new(tokio::sync::Notify::new()); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - let collector = Collector { - received: Arc::clone(&received), - exported: Arc::clone(&exported), - }; - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); - let server = tokio::spawn(async move { - tonic::transport::Server::builder() - .add_service(TraceServiceServer::new(collector)) - .serve_with_incoming_shutdown( - tokio_stream::wrappers::TcpListenerStream::new(listener), - async { - let _ = shutdown_rx.await; - }, - ) - .await - }); + let collector = OtlpTestServer::start().await; - let (provider, error) = super::provider_for(Some(&format!("http://{address}"))); + let (provider, error) = super::provider_for(Some(collector.endpoint())); assert!(error.is_none(), "valid OTLP endpoint should configure"); let provider = provider.expect("provider"); let subscriber = tracing_subscriber::registry().with(super::layer(&provider)); @@ -242,23 +172,11 @@ mod tests { drop(span.enter()); drop(span); }); - let export_completed = exported.notified(); provider.force_flush().unwrap(); - tokio::time::timeout(std::time::Duration::from_secs(5), export_completed) - .await - .expect("OTLP export should complete"); + collector.wait_for_export().await; provider.shutdown().unwrap(); + let received = collector.shutdown().await; - shutdown_tx - .send(()) - .expect("collector server should be running"); - tokio::time::timeout(std::time::Duration::from_secs(5), server) - .await - .expect("collector server shutdown should not deadlock") - .expect("collector server task should not panic") - .expect("collector server should shut down cleanly"); - - let received = received.lock().unwrap(); received .spans .iter() diff --git a/crates/openshell-otel-test-support/Cargo.toml b/crates/openshell-otel-test-support/Cargo.toml new file mode 100644 index 0000000000..dac6ecbe7b --- /dev/null +++ b/crates/openshell-otel-test-support/Cargo.toml @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-otel-test-support" +description = "Shared OTLP collector fixture for OpenShell tracing tests" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +opentelemetry-proto = { version = "0.32", default-features = false, features = ["gen-tonic", "trace"] } +tokio = { workspace = true } +tokio-stream = { workspace = true } +tonic = { workspace = true } + +[lints] +workspace = true diff --git a/crates/openshell-otel-test-support/src/lib.rs b/crates/openshell-otel-test-support/src/lib.rs new file mode 100644 index 0000000000..3f40d4b74a --- /dev/null +++ b/crates/openshell-otel-test-support/src/lib.rs @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared OTLP collector fixture for `OpenShell` tracing tests. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use opentelemetry_proto::tonic::collector::trace::v1::{ + ExportTraceServiceRequest, ExportTraceServiceResponse, + trace_service_server::{TraceService, TraceServiceServer}, +}; +use opentelemetry_proto::tonic::trace::v1::Span; + +#[derive(Clone, Debug, Default)] +pub struct ReceivedTraces { + pub spans: Vec, + pub service_names: Vec, +} + +#[derive(Clone)] +struct Collector { + received: Arc>, + exported: Arc, +} + +#[tonic::async_trait] +impl TraceService for Collector { + async fn export( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + { + let mut received = self + .received + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for resource_span in request.into_inner().resource_spans { + if let Some(resource) = resource_span.resource { + received.service_names.extend( + resource + .attributes + .into_iter() + .filter(|attribute| attribute.key == "service.name") + .filter_map(|attribute| attribute.value) + .filter_map(|value| value.value) + .filter_map(|value| match value { + opentelemetry_proto::tonic::common::v1::any_value::Value::StringValue(value) => Some(value), + _ => None, + }), + ); + } + for scope_span in resource_span.scope_spans { + received.spans.extend(scope_span.spans); + } + } + } + self.exported.notify_one(); + Ok(tonic::Response::new(ExportTraceServiceResponse::default())) + } +} + +/// Loopback OTLP/gRPC server that captures exported spans and resources. +pub struct OtlpTestServer { + endpoint: String, + received: Arc>, + exported: Arc, + shutdown: tokio::sync::oneshot::Sender<()>, + task: tokio::task::JoinHandle>, +} + +impl OtlpTestServer { + pub async fn start() -> Self { + let received = Arc::new(Mutex::new(ReceivedTraces::default())); + let exported = Arc::new(tokio::sync::Notify::new()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("OTLP test collector should bind a loopback listener"); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let collector = Collector { + received: Arc::clone(&received), + exported: Arc::clone(&exported), + }; + let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(TraceServiceServer::new(collector)) + .serve_with_incoming_shutdown( + tokio_stream::wrappers::TcpListenerStream::new(listener), + async { + let _ = shutdown_rx.await; + }, + ) + .await + }); + Self { + endpoint, + received, + exported, + shutdown, + task, + } + } + + #[must_use] + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + pub async fn wait_for_export(&self) { + tokio::time::timeout(Duration::from_secs(5), self.exported.notified()) + .await + .expect("OTLP export should complete"); + } + + pub async fn shutdown(self) -> ReceivedTraces { + self.shutdown + .send(()) + .expect("OTLP test collector should still be running"); + tokio::time::timeout(Duration::from_secs(5), self.task) + .await + .expect("OTLP test collector shutdown should not deadlock") + .expect("OTLP test collector task should not panic") + .expect("OTLP test collector should shut down cleanly"); + self.received + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } +} diff --git a/crates/openshell-otel/src/lib.rs b/crates/openshell-otel/src/lib.rs index 3865670b0b..81d9f7bf01 100644 --- a/crates/openshell-otel/src/lib.rs +++ b/crates/openshell-otel/src/lib.rs @@ -199,15 +199,18 @@ pub type TargetOtlpLayer = #[derive(Debug, Clone, Copy)] pub struct TargetPrefixFilter { - prefix: &'static str, + prefix: Option<&'static str>, include: bool, } impl Filter for TargetPrefixFilter { fn enabled(&self, metadata: &tracing::Metadata<'_>, _ctx: &Context<'_, S>) -> bool { + let matches_prefix = self + .prefix + .is_some_and(|prefix| metadata.target().starts_with(prefix)); metadata.is_span() && !metadata.target().starts_with("opentelemetry") - && (metadata.target().starts_with(self.prefix) == self.include) + && (matches_prefix == self.include) } } @@ -235,16 +238,18 @@ where tracing_opentelemetry::layer() .with_tracer(provider.tracer(instrumentation_scope)) .with_filter(TargetPrefixFilter { - prefix: target_prefix, + prefix: Some(target_prefix), include: true, }) } /// Build a tracing layer that excludes spans from `target_prefix`. +/// +/// `None` excludes no application spans. pub fn layer_excluding_target_prefix( provider: &SdkTracerProvider, instrumentation_scope: &'static str, - target_prefix: &'static str, + target_prefix: Option<&'static str>, ) -> TargetOtlpLayer where S: Subscriber + for<'span> LookupSpan<'span>, @@ -332,7 +337,7 @@ mod tests { .with(layer_excluding_target_prefix( &gateway_provider, "gateway", - "driver_target", + Some("driver_target"), )) .with(layer_for_target_prefix( &driver_provider, @@ -374,6 +379,30 @@ mod tests { ); } + #[test] + fn excluding_no_target_prefix_exports_all_application_spans() { + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = test_lock(); + let exporter = opentelemetry_sdk::trace::InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry() + .with(layer_excluding_target_prefix(&provider, "gateway", None)); + + tracing::subscriber::with_default(subscriber, || { + drop(tracing::info_span!(target: "\0driver_target", "nul-prefixed.span")); + drop(tracing::info_span!(target: "gateway_target", "gateway.span")); + }); + + provider.force_flush().unwrap(); + let spans = exporter.get_finished_spans().unwrap(); + assert_eq!(spans.len(), 2); + assert!(spans.iter().any(|span| span.name == "nul-prefixed.span")); + assert!(spans.iter().any(|span| span.name == "gateway.span")); + } + #[test] fn resource_uses_fixed_service_identity_and_custom_attributes() { let resource = resource_for(&OtlpTraceConfig { diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 4a1df2a63b..d014623dce 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -500,7 +500,7 @@ async fn run_from_args( .unwrap_or_else(|_| EnvFilter::new(&prepared.config.log_level)), &tracing_log_bus, otlp_config, - crate::tracing_setup::podman_export_enabled(&compute_driver), + &compute_driver, ); let has_client_ca = prepared diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 30a1303bd5..7ca5f339d5 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -50,7 +50,7 @@ use openshell_core::proto::{ }; use openshell_core::{ObjectLabels, ObjectWorkspace}; #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] -use openshell_driver_docker::DockerComputeDriver; +use openshell_driver_docker::{ComputeDriverService as DockerDriverService, DockerComputeDriver}; #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] use openshell_driver_kubernetes::{ ComputeDriverService as KubernetesDriverService, KubernetesComputeDriver, @@ -724,11 +724,10 @@ impl ComputeRuntime { tracing_log_bus: TracingLogBus, supervisor_sessions: Arc, ) -> Result { - let driver: SharedComputeDriver = Arc::new( - DockerComputeDriver::new(&config, &docker_config) - .await - .map_err(|err| ComputeError::Message(err.to_string()))?, - ); + let driver = DockerComputeDriver::new(&config, &docker_config) + .await + .map_err(|err| ComputeError::Message(err.to_string()))?; + let driver: SharedComputeDriver = Arc::new(DockerDriverService::new_in_process(driver)); Self::from_driver( ComputeDriverKind::Docker.as_str().to_string(), driver, diff --git a/crates/openshell-server/src/otel_tracing.rs b/crates/openshell-server/src/otel_tracing.rs index d8fb17a624..6a88b2f564 100644 --- a/crates/openshell-server/src/otel_tracing.rs +++ b/crates/openshell-server/src/otel_tracing.rs @@ -29,12 +29,6 @@ use opentelemetry_sdk::trace::SdkTracerProvider; use tracing::Subscriber; use tracing_subscriber::registry::LookupSpan; -#[cfg(feature = "in-tree-compute-drivers")] -const COMPUTE_DRIVER_TARGET_PREFIX: &str = - openshell_driver_podman::otel_tracing::IN_PROCESS_TARGET_PREFIX; -#[cfg(not(feature = "in-tree-compute-drivers"))] -const COMPUTE_DRIVER_TARGET_PREFIX: &str = "\0"; - use crate::config_file::OtlpConfig; /// `service.name` reported when the config file does not override it. @@ -92,18 +86,19 @@ pub fn provider_for(cfg: Option<&OtlpConfig>) -> (Option, Opt openshell_otel::provider_for(cfg.map(trace_config)) } -/// Build the `tracing` layer that forwards spans to `provider`. -/// -/// Events stay on the gateway's logging layers. Spans emitted by the -/// OpenTelemetry crates are excluded to prevent recursive export traffic. -pub fn layer(provider: &SdkTracerProvider) -> openshell_otel::TargetOtlpLayer +/// Build the gateway layer while routing one selected in-process driver to +/// its own tracer provider. +pub fn layer_excluding_driver( + provider: &SdkTracerProvider, + driver_target_prefix: Option<&'static str>, +) -> openshell_otel::TargetOtlpLayer where S: Subscriber + for<'span> LookupSpan<'span>, { openshell_otel::layer_excluding_target_prefix( provider, INSTRUMENTATION_SCOPE, - COMPUTE_DRIVER_TARGET_PREFIX, + driver_target_prefix, ) } @@ -137,7 +132,8 @@ pub mod test_exporter { let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder() .with_simple_exporter(exporter.clone()) .build(); - let subscriber = tracing_subscriber::registry().with(super::layer(&provider)); + let subscriber = + tracing_subscriber::registry().with(super::layer_excluding_driver(&provider, None)); let dispatch = tracing::Dispatch::new(subscriber); TracingTestGuard { _default: tracing::dispatcher::set_default(&dispatch), diff --git a/crates/openshell-server/src/tracing_setup.rs b/crates/openshell-server/src/tracing_setup.rs index aff5c05ecf..bbd98f16c6 100644 --- a/crates/openshell-server/src/tracing_setup.rs +++ b/crates/openshell-server/src/tracing_setup.rs @@ -18,7 +18,7 @@ use crate::tracing_bus::TracingLogBus; pub struct TracingHandle { tracer_provider: Option, - podman_tracer_provider: Option, + driver_tracer_provider: Option, } impl TracingHandle { @@ -28,78 +28,109 @@ impl TracingHandle { { tracing::warn!(error = %err, "OTLP tracer provider shutdown failed"); } - if let Some(provider) = &self.podman_tracer_provider + if let Some(provider) = &self.driver_tracer_provider && let Err(err) = provider.shutdown() { - tracing::warn!(error = %err, "Podman OTLP tracer provider shutdown failed"); + tracing::warn!(error = %err, "compute-driver OTLP tracer provider shutdown failed"); } } } -#[must_use] -pub fn podman_export_enabled(driver: &ConfiguredComputeDriver) -> bool { - matches!( - driver, - ConfiguredComputeDriver::Registered(registration) if registration.name == "podman" - ) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InProcessDriverTracing { + #[cfg(feature = "in-tree-compute-drivers")] + Docker, + #[cfg(feature = "in-tree-compute-drivers")] + Podman, } -pub fn install( - env_filter: EnvFilter, - tracing_log_bus: &TracingLogBus, - otlp_config: Option<&OtlpConfig>, - enable_podman_export: bool, -) -> (TracingHandle, Option) { - let (tracer_provider, setup_error) = crate::otel_tracing::provider_for(otlp_config); +impl InProcessDriverTracing { #[cfg(feature = "in-tree-compute-drivers")] - let podman_endpoint = enable_podman_export - .then_some(otlp_config) - .flatten() - .map(|config| config.endpoint.as_str()); + fn target_prefix(self) -> &'static str { + match self { + Self::Docker => openshell_driver_docker::otel_tracing::IN_PROCESS_TARGET_PREFIX, + Self::Podman => openshell_driver_podman::otel_tracing::IN_PROCESS_TARGET_PREFIX, + } + } +} + +fn in_process_driver_tracing(driver: &ConfiguredComputeDriver) -> Option { #[cfg(feature = "in-tree-compute-drivers")] - let (podman_tracer_provider, podman_setup_error) = - openshell_driver_podman::otel_tracing::provider_for(podman_endpoint); + match driver { + ConfiguredComputeDriver::Registered(registration) if registration.name == "docker" => { + Some(InProcessDriverTracing::Docker) + } + ConfiguredComputeDriver::Registered(registration) if registration.name == "podman" => { + Some(InProcessDriverTracing::Podman) + } + _ => None, + } #[cfg(not(feature = "in-tree-compute-drivers"))] - let (podman_tracer_provider, podman_setup_error): ( - Option, - Option, - ) = { - let _ = enable_podman_export; - (None, None) - }; + { + let _ = driver; + None + } +} - tracing_subscriber::registry() - .with(env_filter) - .with(tracing_subscriber::fmt::layer()) - .with(tracing_log_bus.layer()) - .with(tracer_provider.as_ref().map(crate::otel_tracing::layer)) - .with(podman_in_process_layer(&podman_tracer_provider)) - .init(); +fn in_process_driver_target_prefix(driver: Option) -> Option<&'static str> { + #[cfg(feature = "in-tree-compute-drivers")] + { + driver.map(InProcessDriverTracing::target_prefix) + } + #[cfg(not(feature = "in-tree-compute-drivers"))] + { + let _ = driver; + None + } +} - ( - TracingHandle { - tracer_provider, - podman_tracer_provider, - }, - setup_error.or(podman_setup_error), - ) +#[cfg(feature = "in-tree-compute-drivers")] +fn in_process_driver_provider( + driver: Option, + endpoint: Option<&str>, +) -> (Option, Option) { + match driver { + Some(InProcessDriverTracing::Docker) => { + openshell_driver_docker::otel_tracing::provider_for(endpoint) + } + Some(InProcessDriverTracing::Podman) => { + openshell_driver_podman::otel_tracing::provider_for(endpoint) + } + None => (None, None), + } +} + +#[cfg(not(feature = "in-tree-compute-drivers"))] +fn in_process_driver_provider( + _driver: Option, + _endpoint: Option<&str>, +) -> (Option, Option) { + (None, None) } #[cfg(feature = "in-tree-compute-drivers")] -fn podman_in_process_layer( +fn in_process_driver_layer( provider: &Option, + driver: Option, ) -> Option> where S: tracing::Subscriber + for<'span> tracing_subscriber::registry::LookupSpan<'span>, { - provider - .as_ref() - .map(openshell_driver_podman::otel_tracing::in_process_layer) + provider.as_ref().map(|provider| match driver { + Some(InProcessDriverTracing::Docker) => { + openshell_driver_docker::otel_tracing::in_process_layer(provider) + } + Some(InProcessDriverTracing::Podman) => { + openshell_driver_podman::otel_tracing::in_process_layer(provider) + } + None => unreachable!("a driver provider requires a selected driver"), + }) } #[cfg(not(feature = "in-tree-compute-drivers"))] -fn podman_in_process_layer( +fn in_process_driver_layer( _provider: &Option, + _driver: Option, ) -> Option> where S: tracing::Subscriber + for<'span> tracing_subscriber::registry::LookupSpan<'span>, @@ -107,13 +138,54 @@ where None } +pub fn install( + env_filter: EnvFilter, + tracing_log_bus: &TracingLogBus, + otlp_config: Option<&OtlpConfig>, + driver: &ConfiguredComputeDriver, +) -> (TracingHandle, Option) { + let (tracer_provider, setup_error) = crate::otel_tracing::provider_for(otlp_config); + let selected_driver = in_process_driver_tracing(driver); + let driver_endpoint = selected_driver + .is_some() + .then_some(otlp_config) + .flatten() + .map(|config| config.endpoint.as_str()); + let (driver_tracer_provider, driver_setup_error) = + in_process_driver_provider(selected_driver, driver_endpoint); + + tracing_subscriber::registry() + .with(env_filter) + .with(tracing_subscriber::fmt::layer()) + .with(tracing_log_bus.layer()) + .with(tracer_provider.as_ref().map(|provider| { + crate::otel_tracing::layer_excluding_driver( + provider, + in_process_driver_target_prefix(selected_driver), + ) + })) + .with(in_process_driver_layer( + &driver_tracer_provider, + selected_driver, + )) + .init(); + + ( + TracingHandle { + tracer_provider, + driver_tracer_provider, + }, + setup_error.or(driver_setup_error), + ) +} + #[cfg(test)] mod tests { use super::*; - #[cfg(not(target_os = "windows"))] + #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] #[test] - fn podman_export_is_enabled_only_when_podman_is_selected() { + fn in_process_driver_tracing_selects_docker_and_podman() { let registry = crate::install_default_compute_drivers(); let registered = |name| { ConfiguredComputeDriver::Registered( @@ -123,12 +195,20 @@ mod tests { .clone(), ) }; - - assert!(podman_export_enabled(®istered("podman"))); - assert!(!podman_export_enabled(®istered("docker"))); - assert!(!podman_export_enabled(®istered("kubernetes"))); - assert!(!podman_export_enabled(&ConfiguredComputeDriver::Remote { - name: "custom".to_string(), - })); + assert_eq!( + in_process_driver_tracing(®istered("podman")), + Some(InProcessDriverTracing::Podman) + ); + assert_eq!( + in_process_driver_tracing(®istered("docker")), + Some(InProcessDriverTracing::Docker) + ); + assert_eq!(in_process_driver_tracing(®istered("kubernetes")), None); + assert_eq!( + in_process_driver_tracing(&ConfiguredComputeDriver::Remote { + name: "custom".to_string(), + }), + None + ); } } diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 59fc35c485..74ad988c0a 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -232,7 +232,7 @@ The OpenTelemetry SDK logs export failures after startup. Spans in a failed batc Only OpenTelemetry traces are exported. Inbound gRPC and HTTP requests produce server spans named for the RPC or HTTP method. Store and compute-driver operations appear as child spans. Internal reconciliation, credential-refresh, and driver-watch loops create operation roots for their store work because no inbound request supplies a parent. The gateway continues valid W3C `traceparent` context and starts a new trace when none is supplied. Request spans carry `method`, `path`, and the `request_id` that also appears in gateway logs. Health endpoint spans use DEBUG level and are not exported by the default INFO filter. -The gateway forwards the OTLP configuration to managed external drivers. Each driver exports under its own service name. +The gateway forwards the OTLP configuration and W3C trace context to managed external drivers. Built-in Docker and Podman drivers also export their spans to the same collector through dedicated in-process providers. Driver spans retain the gateway trace context and use a distinct service name such as `openshell-driver-docker` or `openshell-driver-podman`. ### Tuning From d7e137f12eff2f3318809b0413521fe4931b9682 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Mon, 24 Aug 2026 20:25:48 +0000 Subject: [PATCH 006/505] fix(proxy): normalize trailing-dot CONNECT hosts before policy evaluation (#2248) * fix(proxy): normalize trailing-dot CONNECT hosts before policy evaluation Signed-off-by: Artem Lytvyn * fix(supervisor-network): keep trailing dot on upstream proxy CONNECT host and add regression test Signed-off-by: Artem Lytvyn * fix(supervisor-network): drop redundant UpstreamProxyArgs import in tests Signed-off-by: Artem Lytvyn --------- Signed-off-by: Artem Lytvyn --- .../openshell-supervisor-network/src/proxy.rs | 99 ++++++++++++++++--- 1 file changed, 87 insertions(+), 12 deletions(-) diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index ced9476ddf..bc3ad8b3db 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -1784,8 +1784,9 @@ async fn handle_tcp_connection( .await; } - let (host, port) = parse_target(target)?; - let host_lc = host.to_ascii_lowercase(); + let (raw_host, port) = parse_target(target)?; + let host = normalize_host(&raw_host); + let (host_lc, raw_host_lc) = (host.to_ascii_lowercase(), raw_host.to_ascii_lowercase()); if host_lc == INFERENCE_LOCAL_HOST && port == INFERENCE_LOCAL_PORT { respond(&mut client, b"HTTP/1.1 200 Connection Established\r\n\r\n").await?; @@ -1976,7 +1977,7 @@ async fn handle_tcp_connection( // Defense-in-depth: resolve DNS and reject connections to internal IPs. let dns_connect_start = std::time::Instant::now(); let connector = match validate_destination(DestinationRequest { - host: &host, + host: &raw_host, port, sandbox_entrypoint_pid, plan: destination_plan, @@ -2104,7 +2105,7 @@ async fn handle_tcp_connection( } let upstream_result = tokio::select! { - result = dial_upstream(&upstream_proxy, &host_lc, port, connector.addrs()) => Some(result), + result = dial_upstream(&upstream_proxy, &host_lc, &raw_host_lc, port, connector.addrs()) => Some(result), () = connect_generation_guard.wait_until_stale() => None, }; let Some(upstream_result) = upstream_result else { @@ -3633,9 +3634,11 @@ fn implicit_allowed_ips_for_ip_host(host: &str) -> Vec { } fn normalize_host_lookup_key(host: &str) -> &str { - host.strip_prefix('[') + let h = host + .strip_prefix('[') .and_then(|trimmed| trimmed.strip_suffix(']')) - .unwrap_or(host) + .unwrap_or(host); + h.strip_suffix('.').unwrap_or(h) } /// Returns `true` if `host` is one of the well-known driver-injected aliases @@ -3876,15 +3879,18 @@ async fn resolve_socket_addrs( return Ok(addrs); } - let lookup_host = normalize_host_lookup_key(host); - let addrs: Vec = tokio::net::lookup_host((lookup_host, port)) + let dns_host = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host); + let addrs: Vec = tokio::net::lookup_host((dns_host, port)) .await - .map_err(|e| format!("DNS resolution failed for {lookup_host}:{port}: {e}"))? + .map_err(|e| format!("DNS resolution failed for {dns_host}:{port}: {e}"))? .collect(); if addrs.is_empty() { return Err(format!( - "DNS resolution returned no addresses for {lookup_host}:{port}" + "DNS resolution returned no addresses for {dns_host}:{port}" )); } @@ -4015,6 +4021,7 @@ fn validate_declared_endpoint_resolved_addrs( async fn dial_upstream( upstream_proxy: &Option, host_lc: &str, + raw_host_lc: &str, port: u16, addrs: &[SocketAddr], ) -> std::io::Result { @@ -4024,7 +4031,7 @@ async fn dial_upstream( if cfg.connect_by_hostname() { upstream_proxy::connect_via( endpoint, - host_lc, + raw_host_lc, port, upstream_proxy::ConnectTarget::Hostname, ) @@ -4851,6 +4858,9 @@ async fn handle_forward_proxy( respond(client, b"HTTP/1.1 400 Bad Request\r\n\r\n").await?; return Ok(()); }; + + let raw_host = host; + let host = normalize_host(&raw_host); let host_lc = host.to_ascii_lowercase(); if host_lc == POLICY_LOCAL_HOST { @@ -5613,7 +5623,7 @@ async fn handle_forward_proxy( .expect("destination plan hydrated"); let connector = match validate_destination(DestinationRequest { - host: &host, + host: &raw_host, port, sandbox_entrypoint_pid, plan: destination_plan, @@ -6158,6 +6168,10 @@ fn parse_target(target: &str) -> Result<(String, u16)> { Ok((host.to_string(), port)) } +fn normalize_host(raw_host: &str) -> &str { + raw_host.strip_suffix('.').unwrap_or(raw_host) +} + async fn respond(client: &mut TcpStream, bytes: &[u8]) -> Result<()> { client.write_all(bytes).await.into_diagnostic()?; Ok(()) @@ -6849,6 +6863,49 @@ network_policies: } } + #[tokio::test] + async fn dial_upstream_preserves_trailing_dot_in_hostname_connect() { + // Fake upstream proxy: capture the request line, then 200. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let mut buf = [0_u8; 1024_usize]; + let n = sock.read(&mut buf).await.unwrap(); + sock.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n") + .await + .unwrap(); + String::from_utf8_lossy(&buf[..n]).into_owned() + }); + + // Operator config: proxy set + connect-by-hostname opt-in. + let cfg = UpstreamProxyConfig::from_args(&upstream_proxy::UpstreamProxyArgs { + https_proxy: Some(format!("http://{proxy_addr}")), + proxy_connect_by_hostname: true, + ..Default::default() + }) + .unwrap(); + + // host_lc = normalized (undotted), raw_host_lc = absolute (dotted). + let stream = dial_upstream( + &cfg, + "api.example.com", + "api.example.com.", + 443, + &[], // addrs unused in the hostname branch + ) + .await + .unwrap(); + + drop(stream); + + let request = handle.await.unwrap(); + assert!( + request.starts_with("CONNECT api.example.com.:443 HTTP/1.1\r\n"), + "proxy must receive the absolute FQDN: {request}" + ); + } + #[test] fn middleware_failure_response_uses_platform_text_without_policy_guidance() { let response = build_middleware_failure_response("api-policy"); @@ -10626,6 +10683,16 @@ network_policies: assert_eq!(port, 443); } + #[test] + fn test_normalize_host_strips_single_trailing_dot() { + assert_eq!(normalize_host("api.example.com."), "api.example.com"); + } + + #[test] + fn test_normalize_host_remains_the_same() { + assert_eq!(normalize_host("api.example.com"), "api.example.com"); + } + #[test] fn test_parse_target_preserves_case() { let (host, port) = parse_target("EXAMPLE.COM:443").unwrap(); @@ -10785,6 +10852,14 @@ network_policies: // -- parse_proxy_uri: hostname parser regression tests -- + #[test] + fn test_parse_proxy_uri_trailing_dot_host() { + let (_, host, port, _) = parse_proxy_uri("http://api.example.com.:80/path").unwrap(); + let host = normalize_host(&host); + assert_eq!(host, "api.example.com"); + assert_eq!(port, 80_u16); + } + #[test] fn test_parse_proxy_uri_nul_byte_in_host() { let (_, host, port, _) = parse_proxy_uri("http://evil.com\0.safe.com:80/path").unwrap(); From 0a1f246587919536b4eb6d3c91e8df9792b6ee58 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Mon, 24 Aug 2026 21:10:25 +0000 Subject: [PATCH 007/505] feat(sandbox,podman): trust corporate CA for https:// proxies and intercepted TLS (#2512) * feat(sandbox,podman): trust corporate CA for https:// proxies and intercepted TLS The corporate proxy chaining only accepted plain http:// proxy URLs, so operators whose forward proxy terminates TLS with a private corporate CA had no way to reach it, and TLS-intercepting proxies (mitmproxy, squid ssl-bump) that re-sign tunneled server certificates broke every upstream handshake after CONNECT. The supervisor now accepts https:// proxy URLs: it wraps the connection to the proxy in TLS before the CONNECT handshake, verifying the proxy certificate against the built-in Mozilla roots, the system CA bundle, and an optional operator corporate CA bundle. The upstream dial returns a Plain/Tls stream enum consumed generically by the relay paths. The corporate CA is delivered as a driver-supplied command-line argument (--upstream-proxy-ca-bundle), never an environment variable, matching the hardened proxy-config model where a sandbox image cannot influence the operator's egress boundary. It is folded into the sandbox combined trust bundle (write_ca_files) and the L7 upstream verification store (build_upstream_client_config) at startup, so intercepted upstream handshakes succeed and sandbox workloads trust the re-signed certificates. Configuration is fail-closed: a CA bundle set without a proxy, or an unreadable or certificate-free file, is fatal rather than silently weakening the trust boundary. The shared parse_upstream_proxy_url validator accepts https:// (recording the scheme so the driver and supervisor agree), keeping the explicit-port requirement. The Podman driver gains a proxy_ca_bundle operator setting (TOML, --sandbox-proxy-ca-bundle, OPENSHELL_SANDBOX_PROXY_CA_BUNDLE) that bind-mounts the host PEM read-only into the sandbox (a CA certificate is not secret) and points --upstream-proxy-ca-bundle at it, with a create-time readability check. The standalone dev gateway task passes OPENSHELL_SANDBOX_PROXY_CA_BUNDLE through to the generated podman config, so a local gateway can be pointed at a TLS-intercepting proxy without hand-editing the regenerated TOML. Refs #1792 Signed-off-by: Philippe Martin * fix(sandbox): reject CA bundles with valid PEM framing but invalid X.509 DER The proxy CA bundle validation counted PEM blocks that base64-decoded successfully, but did not verify the decoded bytes were accepted as trust anchors by RootCertStore. A bundle with syntactically valid PEM framing but invalid DER would pass the startup check while contributing zero usable anchors, causing opaque TLS failures at runtime instead of a fail-closed startup error. Validate decoded certificates through RootCertStore::add_parsable_certificates and reject the bundle unless at least one is accepted. Signed-off-by: Philippe Martin * fix(sandbox,podman): allow proxy auth without insecure acknowledgement for https:// proxies For an https:// proxy the Proxy-Authorization credential travels inside the verified TLS session, so the proxy_auth_allow_insecure acknowledgement is unnecessary. Previously both http:// and https:// proxies required it, producing a misleading cleartext-risk diagnostic for a path that is already encrypted. Skip the requirement when the proxy URL uses https://; the acknowledgement is still tolerated if set. Updated in both the supervisor and Podman driver validation paths, with docs and tests. Signed-off-by: Philippe Martin * fix: format Signed-off-by: Philippe Martin * fix(kubernetes): use truly unsupported scheme in proxy validation test https:// is now a supported proxy scheme after a13c4dce, so the unsupported-scheme test must use a genuinely unsupported scheme. Signed-off-by: Philippe Martin * fix(e2e): sign the https proxy fixture listener cert with a CA The corporate-proxy E2E fixture served a single `openssl req -x509` certificate as its TLS listener identity. OpenSSL marks that certificate `basicConstraints: critical, CA:TRUE`, and rustls refuses a CA certificate presented as an end-entity certificate (CaUsedAsEndEntity). The supervisor's TLS handshake with the proxy therefore failed, the upstream dial errored, and the workload's CONNECT was dropped without a response, so podman_corporate_proxy_trusts_ca_bundle_for_https_proxy failed on the approved destination while policy denial still worked. Generate a corporate CA and a separate listener leaf signed by it, serve the leaf chain, and publish only the CA as the bundle the supervisor trusts. This is what an intercepting proxy actually presents, and it exercises the corporate-CA trust path rather than pinning the listener certificate itself. Refs #1792 Signed-off-by: Philippe Martin --------- Signed-off-by: Philippe Martin --- architecture/sandbox.md | 38 +- crates/openshell-core/src/driver_utils.rs | 133 ++++- .../openshell-driver-kubernetes/src/config.rs | 2 +- crates/openshell-driver-podman/README.md | 9 +- crates/openshell-driver-podman/src/config.rs | 135 ++++- .../openshell-driver-podman/src/container.rs | 80 +++ crates/openshell-driver-podman/src/driver.rs | 42 ++ crates/openshell-driver-podman/src/main.rs | 8 + crates/openshell-sandbox/src/main.rs | 7 + .../openshell-supervisor-network/src/run.rs | 22 +- .../src/upstream_proxy.rs | 518 ++++++++++++++++-- docs/reference/gateway-config.mdx | 38 +- e2e/rust/tests/podman_corporate_proxy.rs | 306 ++++++++++- tasks/scripts/gateway-podman.sh | 3 + 14 files changed, 1225 insertions(+), 116 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 4f48c31951..786ed5194d 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -238,9 +238,12 @@ loopback always dial directly; add driver-injected host aliases (e.g. proxy cannot reach the container host. `NO_PROXY` matching is port-aware and resolution-aware: an entry with a `:port` qualifier only bypasses that port, and IP/CIDR entries also match hostnames through their validated resolved -addresses, with the direct dial limited to the addresses the entry contains. Only `http://` proxy URLs in explicit -`http://host:port` form are supported — the scheme and port are both -required, and a path, query, or fragment is rejected. Local DNS resolution +addresses, with the direct dial limited to the addresses the entry contains. `http://` and `https://` proxy URLs in explicit +`scheme://host:port` form are supported — the scheme and port are both +required, and a path, query, or fragment is rejected. For an `https://` proxy +the supervisor wraps the connection to the proxy in TLS before the CONNECT +handshake, verifying the proxy certificate against the built-in and system +roots plus the optional operator CA bundle (see below). Local DNS resolution and SSRF validation still run before the proxied dial, and the CONNECT target sent to the corporate proxy is a validated resolved address, so the proxy performs no DNS resolution of its own and the tunnel stays bound to @@ -257,16 +260,31 @@ enhancement and out of scope.) The workload child's proxy variables are unaffected — they are always rewritten to point at the local policy proxy. 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, -a malformed credential, or an auth file or `NO_PROXY` list set while no proxy -URL is configured — is fatal to supervisor startup instead of being treated -as unset, so a misconfiguration can never silently degrade to direct dialing -or unauthenticated proxy access. Only an omitted argument means "no proxy". -The driver validates the same rules at sandbox-create time through -validators shared with the supervisor +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 +bundle set while no proxy URL is configured — is fatal to supervisor startup +instead of being treated as unset, so a misconfiguration can never silently +degrade to direct dialing or unauthenticated proxy access. Only an omitted +argument means "no proxy". The driver validates the same rules at +sandbox-create time through validators shared with the supervisor (`openshell_core::driver_utils::parse_upstream_proxy_url` and `parse_upstream_proxy_credential`). +An optional operator CA bundle (`--upstream-proxy-ca-bundle`, a PEM path the +driver bind-mounts read-only into the sandbox) extends the trust boundary for +corporate proxies. A CA certificate is not secret, so unlike the auth file it +travels as a plain read-only bind mount rather than a driver secret. It is +trusted in two places: the TLS handshake with an `https://` proxy, and — +because a TLS-intercepting proxy (mitmproxy, squid `ssl-bump`) re-signs +tunneled server certificates with the same CA — the sandbox combined trust +bundle (`write_ca_files`) and the L7 upstream re-encryption store +(`build_upstream_client_config`). Folding it into both means intercepted +upstream handshakes succeed and sandbox workload processes trust the re-signed +certificates; trusting it only for the proxy-listener handshake would leave +every intercepted upstream connection failing. The bundle is valid with either +an `http://` or `https://` proxy (an intercepting proxy can be reached over +plain HTTP) and is fail-closed: an unreadable or certificate-free file is fatal. + Proxy credentials are never embedded in the URL: an inline `user:pass@` is rejected because it would be stored in `gateway.toml` and exposed in container metadata. Operators supply credentials via `proxy_auth_file`; the driver diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index b66502df99..b37b5b6821 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -7,11 +7,6 @@ use std::path::{Path, PathBuf}; use crate::proto::compute::v1::DriverSandbox; -pub use crate::container_paths::{ - SANDBOX_TOKEN_MOUNT_PATH, SUPERVISOR_CONTAINER_BINARY, SUPERVISOR_CONTAINER_DIR, - TLS_CA_MOUNT_PATH, TLS_CERT_MOUNT_PATH, TLS_KEY_MOUNT_PATH, UPSTREAM_PROXY_AUTH_MOUNT_PATH, -}; - // --------------------------------------------------------------------------- // Sandbox container/pod label keys (openshell.ai/ namespace) // --------------------------------------------------------------------------- @@ -54,6 +49,60 @@ pub fn openshell_sandbox_label_selector() -> String { /// path used when building the `openshell-sandbox` image layer. pub const SUPERVISOR_IMAGE_BINARY_PATH: &str = "/openshell-sandbox"; +/// Directory inside sandbox containers where the supervisor binary is mounted. +/// +/// Compute drivers that side-load the supervisor into a shared volume mount +/// the binary here so the sandbox container can execute it from a fixed path. +pub const SUPERVISOR_CONTAINER_DIR: &str = "/opt/openshell/bin"; + +/// Full path to the supervisor binary inside sandbox containers. +/// +/// Equals `SUPERVISOR_CONTAINER_DIR + "/openshell-sandbox"`. Use this when +/// the full executable path is needed (Docker entrypoint, Podman entrypoint, +/// VM rootfs injection). Use `SUPERVISOR_CONTAINER_DIR` when only the +/// directory mount-point is needed (Kubernetes emptyDir volume mount). +pub const SUPERVISOR_CONTAINER_BINARY: &str = "/opt/openshell/bin/openshell-sandbox"; + +// --------------------------------------------------------------------------- +// In-container mount paths for guest TLS materials and the sandbox token. +// +// All container-based drivers (Docker, Podman, Kubernetes) mount the gateway's +// mTLS client credentials at these fixed paths inside every sandbox container. +// The supervisor reads these paths on startup to establish its gRPC-over-mTLS +// connection back to the gateway. The paths must remain stable across driver +// versions since the supervisor binary is built and packaged separately. +// --------------------------------------------------------------------------- + +/// Container-side mount path for the guest mTLS CA certificate. +pub const TLS_CA_MOUNT_PATH: &str = "/etc/openshell/tls/client/ca.crt"; + +/// Container-side mount path for the guest mTLS client certificate. +pub const TLS_CERT_MOUNT_PATH: &str = "/etc/openshell/tls/client/tls.crt"; + +/// Container-side mount path for the guest mTLS client private key. +pub const TLS_KEY_MOUNT_PATH: &str = "/etc/openshell/tls/client/tls.key"; + +/// Container-side mount path for the per-sandbox JWT token. +pub const SANDBOX_TOKEN_MOUNT_PATH: &str = "/etc/openshell/auth/sandbox.jwt"; + +/// Container-side mount path for the corporate upstream-proxy credentials. +/// +/// The file holds the `user:pass` userinfo used to build the +/// `Proxy-Authorization` header. It is delivered through a root-only secret +/// mount so the credential never appears in container environment/metadata. +pub const UPSTREAM_PROXY_AUTH_MOUNT_PATH: &str = "/etc/openshell/auth/upstream-proxy"; + +/// Container-side mount path for the corporate proxy CA bundle. +/// +/// Drivers with a `proxy_ca_bundle` operator setting bind-mount the host PEM +/// file here (read-only) and pass the path on the supervisor's argv via +/// `--upstream-proxy-ca-bundle`. The supervisor trusts it for the TLS +/// handshake with an `https://` corporate egress proxy and for server +/// certificates re-signed by a TLS-intercepting proxy. Unlike the proxy +/// credential, a CA certificate is not secret, so a plain read-only bind +/// mount is used rather than a driver secret. +pub const PROXY_CA_MOUNT_PATH: &str = "/etc/openshell/tls/proxy/ca-bundle.pem"; + /// A validated corporate upstream-proxy address. /// /// Produced by [`parse_upstream_proxy_url`], which is the single source of @@ -68,6 +117,9 @@ pub struct UpstreamProxyAddr { pub host: String, /// Proxy TCP port (always explicit in the accepted URL grammar). pub port: u16, + /// `true` when the proxy URL used the `https://` scheme, so the supervisor + /// wraps the connection to the proxy in TLS before the CONNECT handshake. + pub secure: bool, } /// Why an upstream proxy URL was rejected by [`parse_upstream_proxy_url`]. @@ -88,11 +140,11 @@ pub enum UpstreamProxyUrlError { /// `http://host:port` contract exactly. #[error("proxy URL must include an explicit scheme, e.g. http://proxy.corp.com:3128")] MissingScheme, - /// The URL uses a scheme other than `http` (TLS and SOCKS proxies are + /// The URL uses a scheme other than `http` or `https` (SOCKS proxies are /// not supported by the sandbox supervisor). #[error( - "unsupported proxy scheme '{0}': only http:// forward proxies are \ - supported by the sandbox supervisor" + "unsupported proxy scheme '{0}': only http:// and https:// forward \ + proxies are supported by the sandbox supervisor" )] UnsupportedScheme(String), /// The URL has no explicit port. Corporate proxies rarely listen on the @@ -122,11 +174,13 @@ pub enum UpstreamProxyUrlError { /// Parse and validate a corporate upstream-proxy URL. /// -/// The accepted grammar is exactly `http://host:port`: the scheme and the -/// port must both be explicit, only `http://` proxies are accepted, and -/// inline userinfo is rejected. The URL must address the proxy only: a path -/// (other than a bare trailing `/`), query, or fragment is rejected rather -/// than silently discarded. +/// The accepted grammar is exactly `http://host:port` or `https://host:port`: +/// the scheme and the port must both be explicit, only `http://` and +/// `https://` proxies are accepted, and inline userinfo is rejected. The URL +/// must address the proxy only: a path (other than a bare trailing `/`), +/// query, or fragment is rejected rather than silently discarded. The +/// returned [`UpstreamProxyAddr::secure`] records whether the `https://` +/// scheme was used so the supervisor knows to TLS-wrap the proxy connection. /// /// # Errors /// @@ -142,11 +196,15 @@ pub fn parse_upstream_proxy_url(raw: &str) -> Result Result, @@ -188,6 +191,19 @@ pub struct PodmanComputeConfig { /// pointing the gateway host at the corporate resolver so validated-IP /// CONNECT works in split-horizon networks. pub proxy_connect_by_hostname: Option, + /// Path (on the gateway host) to a PEM CA bundle trusted for the corporate + /// proxy. + /// + /// A CA certificate is not secret, so the gateway bind-mounts this file + /// read-only into the sandbox (at + /// [`PROXY_CA_MOUNT_PATH`](openshell_core::driver_utils::PROXY_CA_MOUNT_PATH)) + /// and passes its path to the supervisor via `--upstream-proxy-ca-bundle`. + /// The supervisor trusts it for the TLS handshake with an `https://` + /// proxy and, because a TLS-intercepting proxy re-signs tunneled server + /// certificates with the same CA, folds it into the sandbox trust bundle + /// and upstream verification. Only meaningful with `https_proxy` set; the + /// bundle must exist and contain at least one certificate. + pub proxy_ca_bundle: Option, /// User namespace mode for sandbox containers (e.g. `auto`, `private`). /// When unset, containers use the default user namespace. pub userns: Option, @@ -295,16 +311,16 @@ impl PodmanComputeConfig { /// Shares validation semantics with the in-container supervisor through /// [`openshell_core::driver_utils::parse_upstream_proxy_url`], so a value /// accepted here can never be rejected by the supervisor at sandbox - /// startup (or vice versa). The supervisor only supports `http://` - /// forward proxies, so other schemes are rejected at config time instead - /// of failing inside every sandbox. Credentials must be supplied through - /// `proxy_auth_file`; an inline `user:pass@` in the URL is rejected - /// because it would otherwise be stored in `gateway.toml` and exposed in - /// container metadata. + /// startup (or vice versa). The supervisor supports `http://` and + /// `https://` forward proxies, so other schemes (SOCKS, etc.) are rejected + /// at config time instead of failing inside every sandbox. Credentials + /// must be supplied through `proxy_auth_file`; an inline `user:pass@` in + /// the URL is rejected because it would otherwise be stored in + /// `gateway.toml` and exposed in container metadata. pub fn validate_proxy_config(&self) -> Result<(), crate::client::PodmanApiError> { use openshell_core::driver_utils::{UpstreamProxyUrlError, parse_upstream_proxy_url}; - if let Some(url) = &self.https_proxy { - parse_upstream_proxy_url(url).map_err(|err| { + let proxy_secure = if let Some(url) = &self.https_proxy { + let addr = parse_upstream_proxy_url(url).map_err(|err| { crate::client::PodmanApiError::InvalidInput(match err { UpstreamProxyUrlError::Empty => { "https_proxy must not be empty when set".to_string() @@ -317,7 +333,10 @@ impl PodmanComputeConfig { err => format!("https_proxy {err}"), }) })?; - } + addr.secure + } else { + false + }; // The supervisor treats a present-but-empty driver-supplied argument // as a fatal misconfiguration, so never accept (and later pass) one. @@ -351,8 +370,10 @@ impl PodmanComputeConfig { // Basic auth over the plain-TCP proxy connection is readable by // anyone on the network path; sending it requires an explicit // operator acknowledgement rather than being an implicit side - // effect of configuring credentials. - if self.proxy_auth_allow_insecure != Some(true) { + // effect of configuring credentials. For an https:// proxy the + // credential is inside the verified TLS session, so the + // acknowledgement is unnecessary (but tolerated). + if self.proxy_auth_allow_insecure != Some(true) && !proxy_secure { return Err(crate::client::PodmanApiError::InvalidInput( "proxy_auth_file sends the credential as cleartext Basic auth over the \ plain-TCP connection to the http:// proxy; set proxy_auth_allow_insecure \ @@ -375,6 +396,25 @@ impl PodmanComputeConfig { "proxy_connect_by_hostname is set but no https_proxy is configured".to_string(), )); } + + // A CA bundle only makes sense relative to a proxy boundary (an + // https:// proxy handshake, or a TLS-intercepting proxy's re-sign CA). + // Mirror the proxy_auth_file pairing so a stray setting cannot hide a + // fail-open state. The file's readability and certificate content are + // checked at sandbox-create time (see the driver) and fail closed in + // the supervisor. + if let Some(path) = self.proxy_ca_bundle.as_deref() { + if path.trim().is_empty() { + return Err(crate::client::PodmanApiError::InvalidInput( + "proxy_ca_bundle must not be empty when set".to_string(), + )); + } + if self.https_proxy.is_none() { + return Err(crate::client::PodmanApiError::InvalidInput( + "proxy_ca_bundle is set but no https_proxy is configured".to_string(), + )); + } + } Ok(()) } @@ -513,6 +553,7 @@ impl Default for PodmanComputeConfig { proxy_auth_file: None, proxy_auth_allow_insecure: None, proxy_connect_by_hostname: None, + proxy_ca_bundle: None, userns: None, uidmap: Vec::new(), gidmap: Vec::new(), @@ -552,6 +593,7 @@ impl std::fmt::Debug for PodmanComputeConfig { .field("proxy_auth_file", &self.proxy_auth_file.is_some()) .field("proxy_auth_allow_insecure", &self.proxy_auth_allow_insecure) .field("proxy_connect_by_hostname", &self.proxy_connect_by_hostname) + .field("proxy_ca_bundle", &self.proxy_ca_bundle) .field("userns", &self.userns) .field("uidmap", &self.uidmap) .field("gidmap", &self.gidmap) @@ -625,7 +667,7 @@ mod tests { // ── Proxy config validation ─────────────────────────────────────── #[test] - fn validate_proxy_config_accepts_unset_and_http() { + fn validate_proxy_config_accepts_unset_http_and_https() { assert!( PodmanComputeConfig::default() .validate_proxy_config() @@ -637,11 +679,18 @@ mod tests { ..PodmanComputeConfig::default() }; assert!(cfg.validate_proxy_config().is_ok()); + // An https:// proxy URL is now supported (scheme and port required). + let cfg = PodmanComputeConfig { + https_proxy: Some("https://proxy.corp.com:3130".to_string()), + ..PodmanComputeConfig::default() + }; + assert!(cfg.validate_proxy_config().is_ok()); } #[test] - fn validate_proxy_config_rejects_non_http_schemes() { - for url in ["https://proxy:443", "socks5://proxy:1080"] { + fn validate_proxy_config_rejects_socks_schemes() { + // http:// and https:// are supported; only other schemes are rejected. + for url in ["socks5://proxy:1080", "ftp://proxy:21"] { let cfg = PodmanComputeConfig { https_proxy: Some(url.to_string()), ..PodmanComputeConfig::default() @@ -654,6 +703,46 @@ mod tests { } } + #[test] + fn validate_proxy_config_accepts_ca_bundle_with_proxy() { + let cfg = PodmanComputeConfig { + https_proxy: Some("https://proxy.corp.com:3130".to_string()), + proxy_ca_bundle: Some("/etc/openshell/proxy-ca.pem".to_string()), + ..PodmanComputeConfig::default() + }; + assert!(cfg.validate_proxy_config().is_ok()); + // A CA bundle is also valid with an http:// proxy: a TLS-intercepting + // proxy reached over plain HTTP still re-signs upstream certificates. + let cfg = PodmanComputeConfig { + https_proxy: Some("http://proxy.corp.com:8080".to_string()), + proxy_ca_bundle: Some("/etc/openshell/proxy-ca.pem".to_string()), + ..PodmanComputeConfig::default() + }; + assert!(cfg.validate_proxy_config().is_ok()); + } + + #[test] + fn validate_proxy_config_rejects_ca_bundle_without_proxy() { + let cfg = PodmanComputeConfig { + proxy_ca_bundle: Some("/etc/openshell/proxy-ca.pem".to_string()), + ..PodmanComputeConfig::default() + }; + let err = cfg.validate_proxy_config().unwrap_err(); + assert!(err.to_string().contains("proxy_ca_bundle"), "{err}"); + assert!(err.to_string().contains("no https_proxy"), "{err}"); + } + + #[test] + fn validate_proxy_config_rejects_empty_ca_bundle() { + let cfg = PodmanComputeConfig { + https_proxy: Some("https://proxy.corp.com:3130".to_string()), + proxy_ca_bundle: Some(" ".to_string()), + ..PodmanComputeConfig::default() + }; + let err = cfg.validate_proxy_config().unwrap_err(); + assert!(err.to_string().contains("proxy_ca_bundle"), "{err}"); + } + #[test] fn validate_proxy_config_rejects_url_components() { for url in [ @@ -778,6 +867,16 @@ mod tests { } } + #[test] + fn validate_proxy_config_accepts_auth_file_without_acknowledgement_for_https_proxy() { + let cfg = PodmanComputeConfig { + https_proxy: Some("https://proxy.corp.com:3130".to_string()), + proxy_auth_file: Some("/etc/openshell/secrets/proxy-auth".to_string()), + ..PodmanComputeConfig::default() + }; + assert!(cfg.validate_proxy_config().is_ok()); + } + #[test] fn validate_proxy_config_accepts_connect_by_hostname_with_proxy() { for by_hostname in [Some(true), Some(false)] { diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index cb73e4b7e7..abb9d69dd2 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -64,6 +64,7 @@ const TLS_KEY_MOUNT_PATH: &str = openshell_core::driver_utils::TLS_KEY_MOUNT_PAT const SANDBOX_TOKEN_MOUNT_PATH: &str = openshell_core::driver_utils::SANDBOX_TOKEN_MOUNT_PATH; const UPSTREAM_PROXY_AUTH_MOUNT_PATH: &str = openshell_core::driver_utils::UPSTREAM_PROXY_AUTH_MOUNT_PATH; +const PROXY_CA_MOUNT_PATH: &str = openshell_core::driver_utils::PROXY_CA_MOUNT_PATH; const PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR: &str = openshell_core::driver_utils::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR; @@ -458,6 +459,12 @@ fn upstream_proxy_cli_args(config: &PodmanComputeConfig) -> Vec { if config.proxy_connect_by_hostname == Some(true) { args.push("--upstream-proxy-connect-by-hostname".to_string()); } + // A CA certificate is not secret, so the host PEM is bind-mounted + // read-only and the container-side mount path is passed on argv. + if config.proxy_ca_bundle.is_some() { + args.push("--upstream-proxy-ca-bundle".to_string()); + args.push(PROXY_CA_MOUNT_PATH.to_string()); + } args } @@ -1297,6 +1304,24 @@ pub fn build_container_spec_for_image( options: ro, }); } + // Bind-mount the corporate proxy CA bundle read-only when + // configured. A CA certificate is not secret, so unlike the proxy + // credential (a driver secret) a plain read-only bind mount is + // used. The supervisor reads it via the --upstream-proxy-ca-bundle + // argv path (see upstream_proxy_cli_args) to verify an https:// + // proxy and trust re-signed upstream certificates. + if let Some(ca_bundle) = &config.proxy_ca_bundle { + let mut ro = vec!["ro".into(), "rbind".into()]; + if is_selinux_enabled() { + ro.push("z".into()); + } + m.push(Mount { + kind: "bind".into(), + source: ca_bundle.clone(), + destination: PROXY_CA_MOUNT_PATH.into(), + options: ro, + }); + } if let Some(bin_path) = supervisor_bin_path { let mut opts = vec!["ro".into(), "rbind".into()]; if is_selinux_enabled() { @@ -2126,6 +2151,61 @@ mod tests { ); } + #[test] + fn container_spec_binds_proxy_ca_bundle_and_passes_argv() { + let sandbox = test_sandbox("ca-id", "ca-name"); + let mut config = test_config(); + config.https_proxy = Some("https://proxy.corp.com:3130".to_string()); + config.proxy_ca_bundle = Some("/host/proxy-ca.pem".to_string()); + + let spec = build_container_spec(&sandbox, &config); + let command = spec_command(&spec); + + // The container-side mount path travels on argv as a flag/value pair. + let idx = command + .iter() + .position(|a| a == "--upstream-proxy-ca-bundle") + .expect("CA bundle flag present"); + assert_eq!( + command.get(idx + 1).map(String::as_str), + Some("/etc/openshell/tls/proxy/ca-bundle.pem") + ); + + // The host PEM is bind-mounted read-only at that container path. + let mounts = spec["mounts"] + .as_array() + .expect("mounts should be an array"); + let ca_mount = mounts + .iter() + .find(|m| { + m["type"].as_str() == Some("bind") + && m["destination"].as_str() == Some("/etc/openshell/tls/proxy/ca-bundle.pem") + }) + .expect("CA bundle bind mount present"); + assert_eq!(ca_mount["source"].as_str(), Some("/host/proxy-ca.pem")); + assert!( + ca_mount["options"] + .as_array() + .expect("options array") + .iter() + .any(|o| o.as_str() == Some("ro")), + "CA bundle mount must be read-only" + ); + } + + #[test] + fn container_spec_omits_proxy_ca_bundle_when_unconfigured() { + let sandbox = test_sandbox("test-id", "test-name"); + let spec = build_container_spec(&sandbox, &test_config()); + let mounts = spec["mounts"].as_array().expect("mounts array"); + assert!( + !mounts.iter().any( + |m| m["destination"].as_str() == Some("/etc/openshell/tls/proxy/ca-bundle.pem") + ), + "no CA bundle mount without operator config" + ); + } + #[test] fn container_spec_sandbox_env_cannot_influence_proxy_argv() { use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index 973b2ececa..a11189cbc6 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -177,6 +177,41 @@ async fn create_sandbox_proxy_auth_secret( Ok(Some(secret_name)) } +/// Fail-closed readability check for the corporate proxy CA bundle. +/// +/// When `proxy_ca_bundle` is configured the host PEM is bind-mounted read-only +/// into the sandbox; verifying up front that it exists and is a non-empty +/// regular file turns a missing path into a clear `proxy_ca_bundle` error at +/// sandbox-create time instead of an opaque bind-mount failure. The supervisor +/// independently validates the certificate content (fail-closed) at startup. +async fn validate_sandbox_proxy_ca_bundle( + config: &PodmanComputeConfig, +) -> Result<(), ComputeDriverError> { + let Some(path) = config.proxy_ca_bundle.as_deref() else { + return Ok(()); + }; + let path_owned = path.to_string(); + let metadata = tokio::task::spawn_blocking(move || std::fs::metadata(&path_owned)) + .await + .map_err(|e| ComputeDriverError::Message(format!("proxy_ca_bundle stat task failed: {e}")))? + .map_err(|err| { + ComputeDriverError::InvalidArgument(format!( + "proxy_ca_bundle '{path}' could not be read: {err}" + )) + })?; + if !metadata.is_file() { + return Err(ComputeDriverError::InvalidArgument(format!( + "proxy_ca_bundle '{path}' is not a regular file" + ))); + } + if metadata.len() == 0 { + return Err(ComputeDriverError::InvalidArgument(format!( + "proxy_ca_bundle '{path}' is empty" + ))); + } + Ok(()) +} + async fn cleanup_sandbox_proxy_auth_secret(client: &PodmanClient, secret_name: &str) { if let Err(err) = client.remove_secret(secret_name).await { warn!( @@ -793,6 +828,13 @@ impl PodmanComputeDriver { )) .await?; + // Fail closed on a missing/unreadable corporate proxy CA bundle before + // creating any resources, so the operator gets a clear error + // attributable to `proxy_ca_bundle` rather than an opaque bind-mount + // failure. The supervisor independently validates the certificate + // content at startup. + validate_sandbox_proxy_ca_bundle(&self.config).await?; + // Create workspace volume and per-sandbox token secret. let (token_secret_name, proxy_auth_secret_name) = async { let phase_status = openshell_otel::ErrorStatusGuard::current(); diff --git a/crates/openshell-driver-podman/src/main.rs b/crates/openshell-driver-podman/src/main.rs index 1eb1eb257b..7055cb089a 100644 --- a/crates/openshell-driver-podman/src/main.rs +++ b/crates/openshell-driver-podman/src/main.rs @@ -144,6 +144,13 @@ struct Args { #[arg(long, env = "OPENSHELL_SANDBOX_PROXY_CONNECT_BY_HOSTNAME")] sandbox_proxy_connect_by_hostname: Option, + /// Path (on the gateway host) to a PEM CA bundle trusted for the corporate + /// proxy: the TLS handshake with an `https://` proxy and, for + /// TLS-intercepting proxies, re-signed upstream certificates. Bind-mounted + /// read-only into the sandbox. Only meaningful with `--sandbox-https-proxy`. + #[arg(long, env = "OPENSHELL_SANDBOX_PROXY_CA_BUNDLE")] + sandbox_proxy_ca_bundle: Option, + /// User namespace mode for sandbox containers (e.g. `auto`). /// When unset, containers use the default user namespace. #[arg(long, env = "OPENSHELL_PODMAN_USERNS")] @@ -208,6 +215,7 @@ async fn main() -> Result<()> { proxy_auth_file: args.sandbox_proxy_auth_file, proxy_auth_allow_insecure: args.sandbox_proxy_auth_allow_insecure, proxy_connect_by_hostname: args.sandbox_proxy_connect_by_hostname, + proxy_ca_bundle: args.sandbox_proxy_ca_bundle, userns: args.userns, uidmap: args.uidmap, gidmap: args.gidmap, diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 64e77ef600..6d244fb6bc 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -229,6 +229,12 @@ struct Args { /// (for proxies whose ACLs filter on hostnames). #[arg(long)] upstream_proxy_connect_by_hostname: bool, + + /// Path to a PEM CA bundle trusted for the corporate proxy: the TLS + /// handshake with an `https://` proxy and, for TLS-intercepting proxies, + /// re-signed upstream certificates and the sandbox trust bundle. + #[arg(long)] + upstream_proxy_ca_bundle: Option, } /// Internal one-shot command used by the privileged supervisor to validate an @@ -675,6 +681,7 @@ fn main() -> Result<()> { proxy_auth_file: args.upstream_proxy_auth_file, proxy_auth_allow_insecure: args.upstream_proxy_auth_allow_insecure, proxy_connect_by_hostname: args.upstream_proxy_connect_by_hostname, + proxy_ca_bundle: args.upstream_proxy_ca_bundle, }; run_sandbox( diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index a9170ceee7..2a71702b4b 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -321,7 +321,27 @@ pub async fn run_networking( let tls_dir = std::env::var(openshell_core::sandbox_env::PROXY_TLS_DIR) .unwrap_or_else(|_| openshell_core::container_paths::TLS_ROOT.to_string()); let tls_dir = std::path::Path::new(&tls_dir); - let system_ca_bundle = read_system_ca_bundle(); + let mut system_ca_bundle = read_system_ca_bundle(); + // A TLS-intercepting corporate proxy (issue #1792) re-signs + // tunneled server certificates with the corporate CA, so the + // operator-provided bundle must be trusted for upstream + // re-encryption (build_upstream_client_config below) and by + // sandbox processes (the combined bundle written by + // write_ca_files) — not only for the TLS handshake with an + // https:// proxy listener. Fail closed on an unreadable or + // certificate-free bundle, matching the rest of the + // operator-owned proxy configuration. + if let Some(path) = upstream_proxy_args.proxy_ca_bundle.as_deref() { + let pem = crate::upstream_proxy::read_proxy_ca_bundle( + path, + crate::upstream_proxy::ARG_PROXY_CA_BUNDLE, + ) + .map_err(|err| miette::miette!("{err}"))?; + if !system_ca_bundle.is_empty() && !system_ca_bundle.ends_with('\n') { + system_ca_bundle.push('\n'); + } + system_ca_bundle.push_str(&pem); + } match write_ca_files(&ca, tls_dir, &system_ca_bundle) { Ok(paths) => { // /etc/openshell-tls is subsumed by the /etc baseline diff --git a/crates/openshell-supervisor-network/src/upstream_proxy.rs b/crates/openshell-supervisor-network/src/upstream_proxy.rs index e11d886375..253fa52263 100644 --- a/crates/openshell-supervisor-network/src/upstream_proxy.rs +++ b/crates/openshell-supervisor-network/src/upstream_proxy.rs @@ -24,10 +24,20 @@ //! upstream proxy or disable proxying with `NO_PROXY=*`. //! //! Scope and invariants: -//! - Only `http://` proxy URLs are supported. Configuration is fail-closed: -//! any present-but-invalid driver-supplied setting — an empty argument -//! value, an unsupported (`https://`, SOCKS) or malformed proxy URL, an -//! unreadable auth file, a malformed credential, or an auth file without the explicit +//! - `http://` and `https://` proxy URLs are supported; SOCKS proxies are +//! rejected. For an `https://` proxy the supervisor opens a TLS connection +//! to the proxy first and runs the CONNECT handshake inside it, verifying +//! the proxy certificate against the built-in Mozilla roots, the system CA +//! bundle, and an optional operator corporate CA bundle +//! (`--upstream-proxy-ca-bundle`). That same corporate CA is also folded +//! into the sandbox trust bundle and the L7 upstream verification store at +//! startup (see `run.rs`): a TLS-intercepting corporate proxy re-signs +//! tunneled server certificates with its CA, so trusting it only for the +//! proxy-listener handshake would make every intercepted upstream handshake +//! fail. Configuration is fail-closed: any present-but-invalid +//! driver-supplied setting — an empty argument value, an unsupported +//! (SOCKS) or malformed proxy URL, an unreadable auth file or CA bundle, a +//! malformed credential, or an auth file without the explicit //! cleartext-credential acknowledgement — is a fatal startup error rather //! than being silently ignored, so a typo can never quietly downgrade the //! operator's egress boundary to direct dialing or unauthenticated proxy @@ -48,13 +58,17 @@ use std::io::{Error as IoError, ErrorKind}; use std::net::{IpAddr, SocketAddr}; use std::pin::Pin; +use std::sync::Arc; use std::task::{Context, Poll}; use std::time::Duration; use base64::Engine as _; use openshell_core::net::set_tcp_nodelay_best_effort; +use rustls::ClientConfig; +use rustls::pki_types::ServerName; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; use tokio::net::TcpStream; +use tokio_rustls::TlsConnector; use tracing::debug; /// Upper bound on the corporate proxy's CONNECT response header block. @@ -72,6 +86,11 @@ pub struct ProxyEndpoint { /// Pre-computed `Basic ` header value from the proxy auth file. /// Never logged. proxy_authorization: Option, + /// TLS client config for an `https://` proxy; `None` for a plain `http://` + /// proxy. When set, the connection to the proxy is wrapped in TLS (the + /// proxy certificate verified against these roots) before the CONNECT + /// handshake. + tls: Option>, } impl ProxyEndpoint { @@ -88,6 +107,7 @@ impl std::fmt::Debug for ProxyEndpoint { .field("host", &self.host) .field("port", &self.port) .field("proxy_authorization", &self.proxy_authorization.is_some()) + .field("tls", &self.tls.is_some()) .finish() } } @@ -311,7 +331,8 @@ pub struct UpstreamProxyConfig { /// / `false` genuinely means "not configured by the operator". #[derive(Debug, Clone, Default)] pub struct UpstreamProxyArgs { - /// `http://host:port` corporate proxy URL, or `None` for direct egress. + /// `http://host:port` or `https://host:port` corporate proxy URL, or + /// `None` for direct egress. pub https_proxy: Option, /// Comma-separated `NO_PROXY` list. pub no_proxy: Option, @@ -322,6 +343,12 @@ pub struct UpstreamProxyArgs { pub proxy_auth_allow_insecure: bool, /// Send the destination hostname in CONNECT instead of a validated IP. pub proxy_connect_by_hostname: bool, + /// Path to a PEM bundle of extra CAs trusted for the corporate proxy: the + /// TLS handshake with an `https://` proxy and, because TLS-intercepting + /// proxies re-sign tunneled server certificates with the same CA, the + /// sandbox trust bundle and upstream verification too (folded in by + /// `run.rs`). + pub proxy_ca_bundle: Option, } // Supervisor CLI flag names for the corporate-proxy settings, used as the @@ -331,6 +358,7 @@ const ARG_NO_PROXY: &str = "--upstream-no-proxy"; const ARG_PROXY_AUTH_FILE: &str = "--upstream-proxy-auth-file"; const ARG_PROXY_AUTH_ALLOW_INSECURE: &str = "--upstream-proxy-auth-allow-insecure"; const ARG_PROXY_CONNECT_BY_HOSTNAME: &str = "--upstream-proxy-connect-by-hostname"; +pub(crate) const ARG_PROXY_CA_BUNDLE: &str = "--upstream-proxy-ca-bundle"; impl UpstreamProxyConfig { /// Build the corporate proxy configuration from the driver-supplied @@ -371,6 +399,8 @@ impl UpstreamProxyConfig { args.proxy_auth_allow_insecure.then(|| "true".to_string()) } else if name == ARG_PROXY_CONNECT_BY_HOSTNAME { args.proxy_connect_by_hostname.then(|| "true".to_string()) + } else if name == ARG_PROXY_CA_BUNDLE { + args.proxy_ca_bundle.clone() } else { None } @@ -398,7 +428,8 @@ impl UpstreamProxyConfig { let auth_allow_insecure = var(ARG_PROXY_AUTH_ALLOW_INSECURE)?; let connect_by_hostname_raw = var(ARG_PROXY_CONNECT_BY_HOSTNAME)?; let no_proxy_list = var(ARG_NO_PROXY)?; - let Some(mut https) = https else { + let ca_bundle = var(ARG_PROXY_CA_BUNDLE)?; + let Some((mut https, secure)) = https else { // Auxiliary proxy settings without a proxy mean the operator // believed a proxy boundary was in effect; refuse rather than // silently running with direct egress. @@ -407,6 +438,7 @@ impl UpstreamProxyConfig { (ARG_PROXY_AUTH_ALLOW_INSECURE, &auth_allow_insecure), (ARG_PROXY_CONNECT_BY_HOSTNAME, &connect_by_hostname_raw), (ARG_NO_PROXY, &no_proxy_list), + (ARG_PROXY_CA_BUNDLE, &ca_bundle), ] { if value.is_some() { return Err(format!("{name} is set but no upstream proxy is configured")); @@ -429,11 +461,10 @@ impl UpstreamProxyConfig { }; // Cleartext-credential acknowledgement. `Proxy-Authorization: Basic` - // travels over plain TCP to the http:// proxy, so credentials are - // only sent when the operator explicitly opted in. The compute driver - // enforces the same pairing at sandbox-create time; enforcing it here - // as well keeps the supervisor fail-closed against a bypassed or - // foreign driver. + // travels over plain TCP to an http:// proxy, so credentials are + // only sent when the operator explicitly opted in. For an https:// + // proxy the credential is inside the verified TLS session, so the + // acknowledgement is unnecessary (but tolerated). let allow_insecure = match auth_allow_insecure.as_deref().map(str::trim) { None => false, Some("true") => true, @@ -448,7 +479,7 @@ impl UpstreamProxyConfig { "{ARG_PROXY_AUTH_ALLOW_INSECURE} is set but no {ARG_PROXY_AUTH_FILE} is configured" )); } - if auth_file.is_some() && !allow_insecure { + if auth_file.is_some() && !allow_insecure && !secure { return Err(format!( "{ARG_PROXY_AUTH_FILE} sends the credential as cleartext Basic auth \ over the plain-TCP proxy connection; refusing without \ @@ -468,6 +499,25 @@ impl UpstreamProxyConfig { https.proxy_authorization = Some(header); } + // Wrap the proxy connection in TLS for an `https://` proxy. The + // corporate CA bundle (also folded into the sandbox trust bundle by + // `run.rs`) is trusted alongside the built-in and system roots; a bare + // `https://` proxy with no bundle verifies against those roots only. + if secure { + let corporate_pem = ca_bundle + .as_deref() + .map(|path| read_proxy_ca_bundle(path, ARG_PROXY_CA_BUNDLE)) + .transpose()?; + https.tls = Some(build_proxy_tls_config(corporate_pem.as_deref())); + } else if let Some(path) = ca_bundle.as_deref() { + // An `http://` proxy is dialed in plaintext, so the CA bundle is + // not used for the proxy connection itself. It is still validated + // fail-closed here (and folded into the sandbox trust bundle by + // `run.rs`) for the TLS-intercepting-proxy case, where a proxy + // reached over plain HTTP re-signs tunneled upstream certificates. + read_proxy_ca_bundle(path, ARG_PROXY_CA_BUNDLE)?; + } + Ok(Some(Self { https, no_proxy: NoProxy::parse(&no_proxy_list.unwrap_or_default()), @@ -518,27 +568,94 @@ impl UpstreamProxyConfig { } } -/// Parse an `http://host:port` proxy URL with the same validation rules the -/// compute driver applies at sandbox-create time +/// Parse an `http://host:port` or `https://host:port` proxy URL with the same +/// validation rules the compute driver applies at sandbox-create time /// ([`parse_upstream_proxy_url`](openshell_core::driver_utils::parse_upstream_proxy_url)). /// +/// Returns the endpoint (with `tls` still unset — the caller attaches the +/// shared TLS config afterward) and whether the URL used the `https://` +/// scheme, so the caller knows to wrap the proxy connection in TLS. +/// /// Credentials are never taken from the URL: they are delivered out of band /// through the root-only auth-file mount (`--upstream-proxy-auth-file`) so /// they never appear in config or container metadata. /// /// # Errors /// -/// Rejects unsupported schemes (TLS or SOCKS proxies), inline `user:pass@` +/// Rejects unsupported schemes (SOCKS proxies), inline `user:pass@` /// credentials, and malformed addresses. The error names `var_name` so the /// operator can locate the offending setting. -fn parse_proxy_url(raw: &str, var_name: &str) -> Result { +fn parse_proxy_url(raw: &str, var_name: &str) -> Result<(ProxyEndpoint, bool), String> { let addr = openshell_core::driver_utils::parse_upstream_proxy_url(raw) .map_err(|err| format!("{var_name} is invalid: {err}"))?; - Ok(ProxyEndpoint { - host: addr.host, - port: addr.port, - proxy_authorization: None, - }) + Ok(( + ProxyEndpoint { + host: addr.host, + port: addr.port, + proxy_authorization: None, + tls: None, + }, + addr.secure, + )) +} + +/// Read and validate the operator corporate proxy CA bundle PEM at `path`. +/// +/// A TLS-intercepting corporate proxy signs both its own listener certificate +/// and the re-signed certificates of tunneled destinations with this CA, so +/// the bundle is trusted for proxy-listener TLS (`https://` proxy URLs), for +/// upstream re-encryption verification, and by sandbox workload processes. +/// +/// Fail-closed to match the rest of the operator-owned proxy configuration: +/// the operator explicitly pointed at this file, so an unreadable file or one +/// with no usable certificate is a fatal startup error rather than a silent +/// fall-back to the built-in roots that would quietly weaken the trust +/// boundary. The error names `var_name` so the operator can locate the setting. +pub(crate) fn read_proxy_ca_bundle(path: &str, var_name: &str) -> Result { + let pem = std::fs::read_to_string(path) + .map_err(|err| format!("{var_name} '{path}' could not be read: {err}"))?; + // Validate that the bundle contributes at least one trust anchor that + // rustls actually accepts, not just that PEM framing base64-decodes. + // A PEM block with invalid DER passes `rustls_pemfile::certs` but is + // silently rejected by `RootCertStore::add_parsable_certificates`; + // counting only PEM blocks would let such a bundle satisfy the check + // while contributing zero usable anchors at runtime. + let certs: Vec<_> = rustls_pemfile::certs(&mut pem.as_bytes()) + .flatten() + .collect(); + if certs.is_empty() { + return Err(format!( + "{var_name} '{path}' contains no PEM certificate blocks" + )); + } + let mut store = rustls::RootCertStore::empty(); + let (added, _ignored) = store.add_parsable_certificates(certs); + if added == 0 { + return Err(format!( + "{var_name} '{path}' contains no usable trust anchors \ + (PEM blocks were found but none contain valid X.509 DER)" + )); + } + Ok(pem) +} + +/// Build the TLS client config used to connect to an `https://` corporate +/// proxy and to verify re-signed upstream certificates: built-in Mozilla +/// roots + the system CA bundle + the optional corporate CA bundle PEM. +/// +/// Reuses [`build_upstream_client_config`](crate::l7::tls::build_upstream_client_config), +/// which already folds the built-in and passed-in roots, so the proxy-listener +/// trust store matches the L7 upstream store exactly. +fn build_proxy_tls_config(corporate_ca_pem: Option<&str>) -> Arc { + let mut bundle = crate::l7::tls::read_system_ca_bundle(); + if let Some(pem) = corporate_ca_pem { + if !bundle.is_empty() && !bundle.ends_with('\n') { + bundle.push('\n'); + } + bundle.push_str(pem); + } + crate::l7::tls::build_upstream_client_config(&bundle) + .expect("corporate proxy TLS config must be valid") } /// Build a `Proxy-Authorization: Basic ` header value from a raw @@ -565,7 +682,78 @@ fn basic_auth_header(credential: &str) -> Result { )) } -/// A `TcpStream` that first replays bytes already read from the socket. +/// The connection to the corporate proxy (or a direct dial): either plain +/// TCP or, for an `https://` proxy, a TLS session wrapping the TCP socket. +/// +/// Direct dials and `http://` proxy tunnels are `Plain`; only the transport +/// to the proxy differs, so both variants behave as transparent byte pipes +/// once the CONNECT handshake completes. +#[derive(Debug)] +enum UpstreamStream { + Plain(TcpStream), + Tls(Box>), +} + +impl AsyncRead for UpstreamStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + match self.get_mut() { + Self::Plain(s) => Pin::new(s).poll_read(cx, buf), + Self::Tls(s) => Pin::new(s.as_mut()).poll_read(cx, buf), + } + } +} + +impl AsyncWrite for UpstreamStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + match self.get_mut() { + Self::Plain(s) => Pin::new(s).poll_write(cx, buf), + Self::Tls(s) => Pin::new(s.as_mut()).poll_write(cx, buf), + } + } + + fn poll_write_vectored( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + bufs: &[std::io::IoSlice<'_>], + ) -> Poll> { + match self.get_mut() { + Self::Plain(s) => Pin::new(s).poll_write_vectored(cx, bufs), + Self::Tls(s) => Pin::new(s.as_mut()).poll_write_vectored(cx, bufs), + } + } + + fn is_write_vectored(&self) -> bool { + match self { + Self::Plain(s) => s.is_write_vectored(), + Self::Tls(s) => s.is_write_vectored(), + } + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + Self::Plain(s) => Pin::new(s).poll_flush(cx), + Self::Tls(s) => Pin::new(s.as_mut()).poll_flush(cx), + } + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + Self::Plain(s) => Pin::new(s).poll_shutdown(cx), + Self::Tls(s) => Pin::new(s.as_mut()).poll_shutdown(cx), + } + } +} + +/// An upstream connection that first replays bytes already read from the +/// socket. /// /// The CONNECT handshake reads from the proxy socket in chunks, so the read /// that completes the response header block may also contain the first @@ -576,7 +764,7 @@ fn basic_auth_header(credential: &str) -> Result { /// straight through. #[derive(Debug)] pub struct PrefixedStream { - inner: TcpStream, + inner: UpstreamStream, /// Bytes read past the CONNECT header terminator, replayed first. prefix: Vec, /// Read offset into `prefix`. @@ -587,15 +775,11 @@ pub struct PrefixedStream { } impl PrefixedStream { - /// Wrap `inner`, replaying `prefix` before socket reads. + /// Wrap a directly dialed or `http://`-tunneled `TcpStream`, replaying + /// `prefix` before socket reads. #[must_use] pub fn new(inner: TcpStream, prefix: Vec) -> Self { - Self { - inner, - prefix, - pos: 0, - connect_target: None, - } + Self::over(UpstreamStream::Plain(inner), prefix) } /// Wrap a directly dialed stream with nothing to replay. @@ -604,6 +788,17 @@ impl PrefixedStream { Self::new(inner, Vec::new()) } + /// Wrap an already-established upstream connection (plain or TLS-wrapped + /// proxy), replaying `prefix` before further reads. + fn over(inner: UpstreamStream, prefix: Vec) -> Self { + Self { + inner, + prefix, + pos: 0, + connect_target: None, + } + } + fn with_connect_target(mut self, target: ConnectTarget) -> Self { self.connect_target = Some(target); self @@ -612,7 +807,10 @@ impl PrefixedStream { /// Return the connected socket peer. For a proxied tunnel this is the /// corporate proxy, not the ultimate destination. pub fn peer_addr(&self) -> std::io::Result { - self.inner.peer_addr() + match &self.inner { + UpstreamStream::Plain(s) => s.peer_addr(), + UpstreamStream::Tls(s) => s.get_ref().0.peer_addr(), + } } /// Return the HTTP CONNECT target for a proxied tunnel. An IP target is @@ -821,8 +1019,35 @@ async fn connect_via_inner( port: u16, target: ConnectTarget, ) -> std::io::Result { - let mut stream = TcpStream::connect((endpoint.host.as_str(), endpoint.port)).await?; - set_tcp_nodelay_best_effort(&stream); + let tcp = TcpStream::connect((endpoint.host.as_str(), endpoint.port)).await?; + set_tcp_nodelay_best_effort(&tcp); + // For an `https://` proxy, wrap the connection in TLS (verifying the proxy + // certificate against the configured roots) before the CONNECT handshake. + let mut stream = match &endpoint.tls { + Some(config) => { + let server_name = ServerName::try_from(endpoint.host.clone()).map_err(|err| { + IoError::new( + ErrorKind::InvalidInput, + format!( + "upstream proxy host '{}' is not a valid TLS server name: {err}", + endpoint.host + ), + ) + })?; + let connector = TlsConnector::from(Arc::clone(config)); + let tls = connector.connect(server_name, tcp).await.map_err(|err| { + IoError::new( + err.kind(), + format!( + "TLS handshake with upstream proxy {} failed: {err}", + endpoint.display_addr() + ), + ) + })?; + UpstreamStream::Tls(Box::new(tls)) + } + None => UpstreamStream::Plain(tcp), + }; let connect_target = target; let target = match connect_target { @@ -883,7 +1108,7 @@ async fn connect_via_inner( ); buf.truncate(used); let overflow = buf.split_off(header_end); - Ok(PrefixedStream::new(stream, overflow).with_connect_target(connect_target)) + Ok(PrefixedStream::over(stream, overflow).with_connect_target(connect_target)) } Some(code) => Err(IoError::other(format!( "upstream proxy {} refused CONNECT to {target}: HTTP {code}", @@ -905,7 +1130,7 @@ mod tests { use super::{ ARG_HTTPS_PROXY as HTTPS_PROXY, ARG_NO_PROXY as NO_PROXY, ARG_PROXY_AUTH_ALLOW_INSECURE as PROXY_AUTH_ALLOW_INSECURE, - ARG_PROXY_AUTH_FILE as PROXY_AUTH_FILE, + ARG_PROXY_AUTH_FILE as PROXY_AUTH_FILE, ARG_PROXY_CA_BUNDLE as PROXY_CA_BUNDLE, ARG_PROXY_CONNECT_BY_HOSTNAME as PROXY_CONNECT_BY_HOSTNAME, }; @@ -923,6 +1148,13 @@ mod tests { config_from(pairs).unwrap().unwrap() } + /// Install the process-wide rustls crypto provider once. Building a + /// `ClientConfig` (for an `https://` proxy) requires it; the install is + /// idempotent, so tests that build TLS configs call this first. + fn install_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); + } + #[test] fn no_env_yields_none() { assert!(config_from(&[]).unwrap().is_none()); @@ -1045,14 +1277,116 @@ mod tests { // to direct dialing or unauthenticated proxy access. #[test] - fn tls_and_socks_proxies_are_fatal() { - for url in ["https://proxy:443", "socks5://proxy:1080"] { + fn socks_proxies_are_fatal() { + // http:// and https:// are supported; other schemes are fatal rather + // than silently ignored. + for url in ["socks5://proxy:1080", "ftp://proxy:21"] { let err = config_from(&[(HTTPS_PROXY, url)]).unwrap_err(); assert!(err.contains(HTTPS_PROXY), "{err}"); assert!(err.contains("scheme"), "{err}"); } } + #[test] + fn https_proxy_scheme_enables_tls_and_requires_explicit_port() { + install_crypto_provider(); + // An explicit port is required (no scheme-default fallback), matching + // the http:// grammar. + let err = config_from(&[(HTTPS_PROXY, "https://proxy.corp.com")]).unwrap_err(); + assert!(err.contains("explicit"), "{err}"); + + let cfg = config_ok(&[(HTTPS_PROXY, "https://proxy.corp.com:3130")]); + assert!( + cfg.https.tls.is_some(), + "https:// proxy dials the proxy over TLS" + ); + assert_eq!(cfg.https.display_addr(), "proxy.corp.com:3130"); + } + + #[test] + fn http_proxy_scheme_stays_plaintext() { + let cfg = config_ok(&[(HTTPS_PROXY, "http://proxy.corp.com:8080")]); + assert!( + cfg.https.tls.is_none(), + "http:// proxy is dialed without TLS" + ); + } + + #[test] + fn ca_bundle_without_a_proxy_is_fatal() { + let err = config_from(&[(PROXY_CA_BUNDLE, "/etc/openshell/tls/proxy/ca-bundle.pem")]) + .unwrap_err(); + assert!(err.contains(PROXY_CA_BUNDLE), "{err}"); + assert!(err.contains("no upstream proxy"), "{err}"); + } + + #[test] + fn empty_ca_bundle_value_is_fatal() { + let err = config_from(&[ + (HTTPS_PROXY, "https://proxy.corp.com:3130"), + (PROXY_CA_BUNDLE, " "), + ]) + .unwrap_err(); + assert!(err.contains(PROXY_CA_BUNDLE), "{err}"); + assert!(err.contains("empty"), "{err}"); + } + + #[test] + fn unreadable_ca_bundle_is_fatal_for_https_proxy() { + install_crypto_provider(); + let err = config_from(&[ + (HTTPS_PROXY, "https://proxy.corp.com:3130"), + (PROXY_CA_BUNDLE, "/nonexistent/proxy-ca.pem"), + ]) + .unwrap_err(); + assert!(err.contains(PROXY_CA_BUNDLE), "{err}"); + assert!(err.contains("could not be read"), "{err}"); + } + + #[test] + fn unreadable_ca_bundle_is_fatal_for_http_proxy_too() { + // A CA bundle with an http:// proxy still describes the intercepting + // proxy's re-sign CA and must be validated fail-closed. + let err = config_from(&[ + (HTTPS_PROXY, "http://proxy.corp.com:8080"), + (PROXY_CA_BUNDLE, "/nonexistent/proxy-ca.pem"), + ]) + .unwrap_err(); + assert!(err.contains(PROXY_CA_BUNDLE), "{err}"); + } + + #[test] + fn ca_bundle_with_no_certificates_is_fatal() { + install_crypto_provider(); + let bundle = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(bundle.path(), "not a certificate\n").unwrap(); + let path = bundle.path().to_string_lossy().into_owned(); + let err = config_from(&[ + (HTTPS_PROXY, "https://proxy.corp.com:3130"), + (PROXY_CA_BUNDLE, path.as_str()), + ]) + .unwrap_err(); + assert!(err.contains("no PEM certificate blocks"), "{err}"); + } + + #[test] + fn ca_bundle_with_invalid_der_certificates_is_fatal() { + install_crypto_provider(); + let bundle = tempfile::NamedTempFile::new().unwrap(); + std::fs::write( + bundle.path(), + "-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----\n", + ) + .unwrap(); + let path = bundle.path().to_string_lossy().into_owned(); + let err = config_from(&[ + (HTTPS_PROXY, "https://proxy.corp.com:3130"), + (PROXY_CA_BUNDLE, path.as_str()), + ]) + .unwrap_err(); + assert!(err.contains("no usable trust anchors"), "{err}"); + } + #[test] fn url_userinfo_is_fatal_not_used_as_credentials() { // Inline credentials in the URL must never become the proxy auth; @@ -1110,7 +1444,7 @@ mod tests { } #[test] - fn auth_file_without_insecure_acknowledgement_is_fatal() { + fn auth_file_without_insecure_acknowledgement_is_fatal_for_http_proxy() { // Basic auth over the plain-TCP proxy connection is readable on the // network path; sending it requires the explicit opt-in. let err = config_from(&[ @@ -1122,6 +1456,23 @@ mod tests { assert!(err.contains("cleartext"), "{err}"); } + #[test] + fn auth_file_without_insecure_acknowledgement_is_allowed_for_https_proxy() { + install_crypto_provider(); + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), "user:secret\n").unwrap(); + let path = file.path().to_str().unwrap().to_string(); + let cfg = config_ok(&[ + (HTTPS_PROXY, "https://proxy.corp.com:3130"), + (PROXY_AUTH_FILE, &path), + ]); + let ep = proxy_endpoint(&cfg, "example.com").unwrap(); + assert!( + ep.proxy_authorization.is_some(), + "https:// proxy must accept auth without the insecure acknowledgement" + ); + } + #[test] fn invalid_insecure_acknowledgement_value_is_fatal() { for value in ["false", "yes", "1", "TRUE"] { @@ -1503,6 +1854,7 @@ mod tests { host: addr.ip().to_string(), port: addr.port(), proxy_authorization: auth.map(str::to_string), + tls: None, } } @@ -1905,4 +2257,94 @@ mod tests { assert!(err.contains(PROXY_CONNECT_BY_HOSTNAME), "{err}"); assert!(err.contains("no upstream proxy"), "{err}"); } + + // -- TLS (https://) proxies -- + + /// A fake `https://` proxy: a TLS server with a self-signed cert for + /// 127.0.0.1 that answers CONNECT with 200. Returns the listen address, + /// the server task (yielding the received CONNECT request), and the + /// server certificate PEM to use as the corporate CA bundle. + async fn fake_tls_proxy() -> (SocketAddr, tokio::task::JoinHandle, String) { + install_crypto_provider(); + let key = rcgen::KeyPair::generate().unwrap(); + let cert = rcgen::CertificateParams::new(vec!["127.0.0.1".to_string()]) + .unwrap() + .self_signed(&key) + .unwrap(); + let cert_pem = cert.pem(); + + let server_config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert( + vec![cert.der().clone()], + rustls::pki_types::PrivateKeyDer::try_from(key.serialize_der()).unwrap(), + ) + .unwrap(); + let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_config)); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut tls = acceptor.accept(socket).await.unwrap(); + let mut buf = vec![0u8; 4096]; + let mut used = 0; + loop { + let n = tls.read(&mut buf[used..]).await.unwrap(); + used += n; + if n == 0 || buf[..used].windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + tls.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n") + .await + .unwrap(); + tls.flush().await.unwrap(); + String::from_utf8_lossy(&buf[..used]).into_owned() + }); + (addr, handle, cert_pem) + } + + #[tokio::test] + async fn connect_via_https_proxy_with_corporate_ca_bundle() { + let (addr, handle, cert_pem) = fake_tls_proxy().await; + let ca_file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(ca_file.path(), cert_pem).unwrap(); + + let proxy_url = format!("https://{addr}"); + let ca_path = ca_file.path().to_string_lossy().into_owned(); + let cfg = config_ok(&[ + (HTTPS_PROXY, proxy_url.as_str()), + (PROXY_CA_BUNDLE, ca_path.as_str()), + ]); + let endpoint = &cfg.https; + assert!(endpoint.tls.is_some()); + + let stream = connect_via(endpoint, "api.example.com", 443, ConnectTarget::Hostname) + .await + .unwrap(); + assert!(matches!(stream.inner, UpstreamStream::Tls(_))); + drop(stream); + let request = handle.await.unwrap(); + assert!(request.starts_with("CONNECT api.example.com:443 HTTP/1.1\r\n")); + } + + #[tokio::test] + async fn connect_via_https_proxy_rejects_untrusted_cert() { + // No corporate CA bundle: the self-signed proxy cert must not verify + // against the built-in / system roots, so the handshake fails closed. + let (addr, _handle, _cert_pem) = fake_tls_proxy().await; + let proxy_url = format!("https://{addr}"); + let cfg = config_ok(&[(HTTPS_PROXY, proxy_url.as_str())]); + let endpoint = &cfg.https; + + let err = connect_via(endpoint, "api.example.com", 443, ConnectTarget::Hostname) + .await + .unwrap_err(); + assert!( + err.to_string() + .contains("TLS handshake with upstream proxy"), + "{err}" + ); + } } diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 74ad988c0a..a287d3d5b0 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -657,10 +657,13 @@ health_check_interval_secs = 10 # Corporate forward proxy for sandbox egress. When set, the in-container # supervisor chains policy-approved TLS tunnels through this proxy with HTTP # CONNECT instead of dialing destinations directly. Plain-HTTP requests are -# not proxied and always dial the destination directly. Only http:// proxy -# URLs in explicit http://host:port form are supported: the scheme and port -# are both required, and a URL carrying a path, query, or fragment is -# rejected rather than silently truncated. +# not proxied and always dial the destination directly. http:// and https:// +# proxy URLs in explicit scheme://host:port form are supported: the scheme and +# port are both required, and a URL carrying a path, query, or fragment is +# rejected rather than silently truncated. For an https:// proxy the supervisor +# wraps the connection to the proxy in TLS before the CONNECT handshake, +# verifying the proxy certificate against the built-in and system roots plus +# the optional proxy_ca_bundle below. # NO_PROXY entries (hostnames, domain suffixes, IPs, CIDRs, each with an # optional :port qualifier) are dialed directly. A port-qualified entry only # bypasses that destination port. IP and CIDR entries also match hostnames @@ -700,20 +703,35 @@ health_check_interval_secs = 10 # supervisor, so a credential accepted here is never rejected in-container. # Keep gateway.toml and the auth file owner-readable only (mode 0600). # -# WARNING: the supervisor sends the credential as a Proxy-Authorization: -# Basic header over the plain-TCP connection to the http:// proxy. Basic +# WARNING: with an http:// proxy the supervisor sends the credential as a +# Proxy-Authorization: Basic header over the plain-TCP connection. Basic # auth is base64, not encryption: anyone on the network path between the # sandbox host and the proxy can recover the credential. Because of that, -# proxy_auth_file requires the explicit acknowledgement -# proxy_auth_allow_insecure = true; without it the configuration is -# rejected at gateway startup. Only opt in when the path to the proxy is a -# trusted network segment. +# proxy_auth_file with an http:// proxy requires the explicit +# acknowledgement proxy_auth_allow_insecure = true; without it the +# configuration is rejected at gateway startup. Only opt in when the path +# to the proxy is a trusted network segment. For an https:// proxy the +# credential is sent inside the verified TLS session, so the +# acknowledgement is not required (but tolerated if set). +# +# proxy_ca_bundle points at a PEM CA bundle (on the gateway host) trusted for +# the corporate proxy. A CA certificate is not secret, so the gateway +# bind-mounts it read-only into the sandbox. It is trusted in two places: the +# TLS handshake with an https:// proxy, and — because a TLS-intercepting proxy +# (mitmproxy, squid ssl-bump) re-signs tunneled server certificates with the +# same CA — the sandbox trust bundle and the supervisor's upstream +# re-encryption, so intercepted upstream TLS keeps working and sandbox +# workloads trust the re-signed certificates. It is valid with either an +# http:// or https:// proxy and requires a proxy URL; the file must exist and +# contain at least one certificate, or the sandbox fails closed at startup. # https_proxy = "http://proxy.corp.com:8080" # no_proxy = "*.svc.cluster.local,10.0.0.0/8" # proxy_auth_file = "/etc/openshell/secrets/proxy-auth" # proxy_auth_allow_insecure = true # Last resort for hostname-filtering proxy ACLs; see the warning above. # proxy_connect_by_hostname = true +# Corporate CA trusted for an https:// proxy and TLS-intercepting proxies. +# proxy_ca_bundle = "/etc/openshell/tls/proxy-ca.pem" ``` ### MicroVM diff --git a/e2e/rust/tests/podman_corporate_proxy.rs b/e2e/rust/tests/podman_corporate_proxy.rs index 2f18fc91c4..7a0d67048e 100644 --- a/e2e/rust/tests/podman_corporate_proxy.rs +++ b/e2e/rust/tests/podman_corporate_proxy.rs @@ -30,6 +30,7 @@ use openshell_e2e::harness::cli::wait_for_healthy; use openshell_e2e::harness::container::{ContainerEngine, SupportContainer}; use openshell_e2e::harness::gateway::ManagedGateway; use openshell_e2e::harness::sandbox::SandboxGuard; +use serial_test::serial; use tempfile::NamedTempFile; const PROXY_ALIAS: &str = "corp-proxy.openshell.test"; @@ -136,6 +137,151 @@ while True: ) } +// Delimits the proxy's CA certificate in its stdout, so the test can recover +// it and hand it back as the corporate CA bundle. +const CA_BEGIN: &str = "---PROXY-CA-BEGIN---"; +const CA_END: &str = "---PROXY-CA-END---"; + +/// A forward proxy that terminates TLS with a CA-signed certificate and logs +/// every CONNECT it sees. +/// +/// The container generates a corporate CA and a separate listener leaf signed +/// by it (SAN = the proxy alias, which is the name the supervisor uses for +/// SNI), serves the leaf, and prints the CA between [`CA_BEGIN`]/[`CA_END`] so +/// the test can trust it via `proxy_ca_bundle`. The listener certificate must +/// be a leaf: rustls rejects a `CA:TRUE` certificate presented as an +/// end-entity certificate (`CaUsedAsEndEntity`), so a single self-signed +/// `openssl req -x509` certificate — which is a CA by default — cannot serve +/// as both the anchor and the listener identity. Signing a leaf also matches +/// what a real intercepting proxy does. No Basic auth: this test isolates the +/// `https://` proxy + corporate-CA path; credential delivery is covered by the +/// plaintext-proxy test. +fn tls_proxy_script() -> String { + format!( + r" +import os, select, socket, ssl, subprocess, tempfile, threading + +workdir = tempfile.mkdtemp() +ca_key = os.path.join(workdir, 'ca-key.pem') +ca_crt = os.path.join(workdir, 'ca.pem') +leaf_key = os.path.join(workdir, 'leaf-key.pem') +leaf_csr = os.path.join(workdir, 'leaf.csr') +leaf_crt = os.path.join(workdir, 'leaf.pem') +chain = os.path.join(workdir, 'chain.pem') +ext = os.path.join(workdir, 'leaf.ext') + +def run(*args): + subprocess.run(args, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + +run('openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', + '-keyout', ca_key, '-out', ca_crt, '-days', '1', + '-subj', '/CN=corp-proxy-e2e-ca') + +with open(ext, 'w') as fh: + fh.write('basicConstraints=critical,CA:FALSE\n' + 'subjectAltName=DNS:{PROXY_ALIAS}\n' + 'extendedKeyUsage=serverAuth\n') +run('openssl', 'req', '-newkey', 'rsa:2048', '-nodes', + '-keyout', leaf_key, '-out', leaf_csr, '-subj', '/CN={PROXY_ALIAS}') +run('openssl', 'x509', '-req', '-in', leaf_csr, '-CA', ca_crt, '-CAkey', ca_key, + '-CAcreateserial', '-out', leaf_crt, '-days', '1', '-extfile', ext) + +with open(chain, 'w') as out: + for part in (leaf_crt, ca_crt): + with open(part) as fh: + out.write(fh.read()) + +with open(ca_crt) as fh: + print('{CA_BEGIN}\n' + fh.read() + '{CA_END}', flush=True) + +def log(msg): + print(msg, flush=True) + +def read_head(conn): + data = b'' + while b'\r\n\r\n' not in data: + chunk = conn.recv(4096) + if not chunk: + return None + data += chunk + if len(data) > 65536: + return None + return data + +def pipe(a, b): + try: + while True: + ready, _, _ = select.select([a, b], [], []) + for sock in ready: + chunk = sock.recv(65536) + if not chunk: + return + (b if sock is a else a).sendall(chunk) + except OSError: + return + +def handle(conn): + try: + head = read_head(conn) + if head is None: + return + lines = head.decode('latin-1').split('\r\n') + parts = lines[0].split() + if len(parts) < 2 or parts[0].upper() != 'CONNECT': + log('NON_CONNECT %s' % lines[0]) + conn.sendall(b'HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\n\r\n') + return + target = parts[1] + host, _, port = target.rpartition(':') + host = host.strip('[]') + try: + upstream = socket.create_connection((host, int(port)), timeout=10) + except OSError: + log('CONNECT %s dial=fail' % target) + conn.sendall(b'HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n') + return + log('CONNECT %s ok' % target) + conn.sendall(b'HTTP/1.1 200 Connection Established\r\n\r\n') + pipe(conn, upstream) + upstream.close() + except OSError: + pass + finally: + conn.close() + +ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +ctx.load_cert_chain(chain, leaf_key) +server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +server.bind(('0.0.0.0', {PROXY_PORT})) +server.listen(64) +log('tls-proxy-listening') +while True: + raw, _ = server.accept() + try: + conn = ctx.wrap_socket(raw, server_side=True) + except OSError: + # Readiness probes open a bare TCP connection; the failed handshake is + # expected and must not spam the log. + raw.close() + continue + threading.Thread(target=handle, args=(conn,), daemon=True).start() +" + ) +} + +/// Extract the proxy's CA certificate PEM from its stdout. +fn ca_cert_from_logs(logs: &str) -> Result { + let start = logs + .find(CA_BEGIN) + .ok_or_else(|| format!("proxy CA begin marker not found in logs:\n{logs}"))? + + CA_BEGIN.len(); + let end = logs[start..] + .find(CA_END) + .ok_or_else(|| format!("proxy CA end marker not found in logs:\n{logs}"))?; + Ok(logs[start..start + end].trim().to_string()) +} + /// A TLS server with a self-signed certificate, serving one identifying marker. /// /// The sandbox workload uses an unverified TLS context, so the certificate only @@ -302,6 +448,16 @@ impl GatewayProxyConfig { /// Rewrite the gateway config with corporate-proxy settings and restart the /// gateway so the Podman driver picks them up. async fn apply(proxy_url: &str, auth_file: &str) -> Result { + Self::apply_with(proxy_url, Some(auth_file), None).await + } + + /// Like [`apply`], but with optional proxy credentials and an optional + /// corporate CA bundle path (for an `https://` proxy). + async fn apply_with( + proxy_url: &str, + auth_file: Option<&str>, + ca_bundle: Option<&str>, + ) -> Result { let config_path = Self::config_path_from_args()?; let original = std::fs::read(&config_path) .map_err(|err| format!("read gateway config '{}': {err}", config_path.display()))?; @@ -310,14 +466,19 @@ impl GatewayProxyConfig { if !updated.ends_with(b"\n") { updated.push(b'\n'); } - updated.extend_from_slice( - format!( - "https_proxy = \"{proxy_url}\"\n\ - proxy_auth_file = \"{auth_file}\"\n\ - proxy_auth_allow_insecure = true\n" - ) - .as_bytes(), - ); + updated.extend_from_slice(format!("https_proxy = \"{proxy_url}\"\n").as_bytes()); + if let Some(auth_file) = auth_file { + updated.extend_from_slice( + format!( + "proxy_auth_file = \"{auth_file}\"\n\ + proxy_auth_allow_insecure = true\n" + ) + .as_bytes(), + ); + } + if let Some(ca_bundle) = ca_bundle { + updated.extend_from_slice(format!("proxy_ca_bundle = \"{ca_bundle}\"\n").as_bytes()); + } std::fs::write(&config_path, &updated) .map_err(|err| format!("write gateway config '{}': {err}", config_path.display()))?; @@ -480,6 +641,7 @@ fn assert_proxied_egress(output: &str, proxy_logs: &str, allowed_ip: &str, denie } #[tokio::test] +#[serial(corporate_proxy)] async fn podman_corporate_proxy_routes_approved_tls_egress() { if std::env::var("OPENSHELL_E2E_DRIVER").as_deref() != Ok("podman") { eprintln!("Skipping corporate proxy test: e2e driver is not podman"); @@ -592,3 +754,131 @@ async fn podman_corporate_proxy_routes_approved_tls_egress() { .await .expect("restore gateway config"); } + +/// Assert the approved destination reached the `https://` proxy over the +/// tunnel and the denied one never did. The TLS proxy logs `CONNECT +/// : ok` (no auth dimension in this test). +fn assert_https_proxied_egress(output: &str, proxy_logs: &str, allowed_ip: &str, denied_ip: &str) { + assert!( + output.contains(READY_MARKER), + "workload did not finish; output:\n{output}" + ); + assert!( + output.contains(&format!( + "\"body\": \"{{\\\"upstream\\\":\\\"{ALLOWED_MARKER}\\\"" + )), + "approved upstream body missing — egress did not complete through the https proxy:\n{output}" + ); + assert!( + proxy_logs.contains(&format!("CONNECT {allowed_ip}:{UPSTREAM_PORT} ok")), + "https proxy should have seen a validated-IP CONNECT to the approved upstream:\n{proxy_logs}" + ); + assert!( + !proxy_logs.contains(DENIED_ALIAS) + && !proxy_logs.contains(&format!("{denied_ip}:{UPSTREAM_PORT}")), + "policy-denied destination {denied_ip} ({DENIED_ALIAS}) must never reach the https proxy:\n{proxy_logs}" + ); +} + +#[tokio::test] +#[serial(corporate_proxy)] +async fn podman_corporate_proxy_trusts_ca_bundle_for_https_proxy() { + if std::env::var("OPENSHELL_E2E_DRIVER").as_deref() != Ok("podman") { + eprintln!("Skipping https corporate proxy test: e2e driver is not podman"); + return; + } + if ManagedGateway::from_env() + .expect("load managed e2e gateway metadata") + .is_none() + { + eprintln!( + "Skipping https corporate proxy test: e2e gateway is not managed by this test run" + ); + return; + } + + // ── Fixtures on the shared e2e network ──────────────────────────── + let proxy = SupportContainer::start_python(PROXY_ALIAS, &tls_proxy_script(), PROXY_PORT) + .await + .expect("start fake https corporate proxy"); + let allowed = SupportContainer::start_python( + ALLOWED_ALIAS, + &tls_upstream_script(ALLOWED_ALIAS, ALLOWED_MARKER), + UPSTREAM_PORT, + ) + .await + .expect("start approved TLS upstream"); + let denied = SupportContainer::start_python( + DENIED_ALIAS, + &tls_upstream_script(DENIED_ALIAS, DENIED_MARKER), + UPSTREAM_PORT, + ) + .await + .expect("start denied TLS upstream"); + + let allowed_ip = allowed.ip().expect("resolve approved upstream IP"); + let denied_ip = denied.ip().expect("resolve denied upstream IP"); + assert_ne!( + allowed_ip, denied_ip, + "approved and denied upstreams must have distinct IPs" + ); + + // Recover the proxy's self-signed CA from its logs and hand it back as the + // corporate CA bundle the supervisor must trust for the proxy handshake. + let ca_pem = ca_cert_from_logs(&proxy.logs().expect("read proxy logs for CA")) + .expect("extract proxy CA certificate"); + let mut ca_file = NamedTempFile::new().expect("create proxy CA bundle file"); + ca_file + .write_all(ca_pem.as_bytes()) + .expect("write proxy CA bundle"); + ca_file.flush().expect("flush proxy CA bundle"); + let ca_path = ca_file + .path() + .to_str() + .expect("CA bundle path should be utf-8") + .to_string(); + + // ── Point the gateway at the https:// proxy with the CA bundle ───── + let mut gateway_config = GatewayProxyConfig::apply_with( + &format!("https://{PROXY_ALIAS}:{PROXY_PORT}"), + None, + Some(&ca_path), + ) + .await + .expect("apply https corporate proxy gateway config"); + + // ── Run the workload ────────────────────────────────────────────── + let mut policy = NamedTempFile::new().expect("create policy file"); + policy + .write_all(policy_yaml().as_bytes()) + .expect("write policy file"); + policy.flush().expect("flush policy file"); + let policy_path = policy + .path() + .to_str() + .expect("policy path should be utf-8") + .to_string(); + + let script = workload_script(); + let mut sandbox = SandboxGuard::create_keep_with_args( + &["--policy", &policy_path], + &["python3", "-c", &script], + READY_MARKER, + ) + .await + .expect("create sandbox behind the https corporate proxy"); + + assert_https_proxied_egress( + &sandbox.create_output, + &proxy.logs().expect("read fake proxy logs"), + &allowed_ip, + &denied_ip, + ); + + sandbox.cleanup().await; + + gateway_config + .restore() + .await + .expect("restore gateway config"); +} diff --git a/tasks/scripts/gateway-podman.sh b/tasks/scripts/gateway-podman.sh index 5ff32a22f7..2b70e3a85b 100644 --- a/tasks/scripts/gateway-podman.sh +++ b/tasks/scripts/gateway-podman.sh @@ -259,6 +259,9 @@ if [[ -n "${OPENSHELL_SANDBOX_PROXY_CONNECT_BY_HOSTNAME+x}" ]]; then ;; esac fi +if [[ -n "${OPENSHELL_SANDBOX_PROXY_CA_BUNDLE+x}" ]]; then + printf 'proxy_ca_bundle = "%s"\n' "$(toml_escape "${OPENSHELL_SANDBOX_PROXY_CA_BUNDLE}")" >>"${CONFIG_PATH}" +fi GATEWAY_ENDPOINT="http://${CLI_ENDPOINT_HOST}:${PORT}" register_gateway_metadata "${GATEWAY_NAME}" "${GATEWAY_ENDPOINT}" "${PORT}" From 72b9c4acef17b426c1ecc076133c73621af1ba47 Mon Sep 17 00:00:00 2001 From: Russell Bryant Date: Mon, 24 Aug 2026 22:04:25 +0000 Subject: [PATCH 008/505] test(e2e): pin direct podman calls to harness socket on macOS (#2909) The podman_gateway_start e2e test shells out to `podman` directly (unlike every other Podman e2e test, which routes through the gateway). The harness `e2e/with-podman-gateway.sh` clobbers `XDG_CONFIG_HOME` with an empty dir to isolate CLI/SDK gateway metadata. On macOS the podman client resolves its VM connection config from `XDG_CONFIG_HOME`, so a bare `podman ps` falls back to a nonexistent native rootless socket and fails with "unable to connect to Podman socket". On Linux the socket resolves via `XDG_RUNTIME_DIR`, so the test passes there and this is a macOS-only false failure. Target the same API socket the gateway uses by passing `--url unix://$SOCKET` when the harness-exported `OPENSHELL_PODMAN_SOCKET` is set, mirroring the shell's `podman_cmd` helper. When the var is unset (running the test outside the harness), fall back to plain `podman`, so Linux behavior is unchanged. Signed-off-by: Russell Bryant --- e2e/rust/tests/podman_gateway_start.rs | 27 ++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/e2e/rust/tests/podman_gateway_start.rs b/e2e/rust/tests/podman_gateway_start.rs index 9a047980ba..28a9ceb15c 100644 --- a/e2e/rust/tests/podman_gateway_start.rs +++ b/e2e/rust/tests/podman_gateway_start.rs @@ -23,9 +23,32 @@ const START_FILE: &str = "/sandbox/podman-gateway-start-state"; const MANAGED_BY_LABEL_FILTER: &str = "label=openshell.managed=true"; const SANDBOX_NAME_LABEL: &str = "openshell.ai/sandbox-name"; +/// Build a `podman` command that targets the same API socket the gateway uses. +/// +/// The e2e harness (`e2e/with-podman-gateway.sh`) exports +/// `OPENSHELL_PODMAN_SOCKET` (discovered via `podman machine inspect` on macOS, +/// or the rootless socket on Linux) and also clobbers `XDG_CONFIG_HOME` with an +/// empty directory to isolate CLI/SDK gateway metadata. On macOS the podman +/// client resolves its VM connection through `XDG_CONFIG_HOME`, so a bare +/// `podman ps` here falls back to a nonexistent native rootless socket and +/// fails. Pinning `--url` to the exported socket bypasses connection-config +/// resolution entirely and mirrors the shell's `podman_cmd` helper. +/// +/// When `OPENSHELL_PODMAN_SOCKET` is unset (e.g. running this test outside the +/// harness), fall back to plain `podman`, leaving Linux behavior unchanged. +fn podman_command() -> Command { + let mut command = Command::new("podman"); + if let Ok(socket) = std::env::var("OPENSHELL_PODMAN_SOCKET") { + if !socket.is_empty() { + command.arg("--url").arg(format!("unix://{socket}")); + } + } + command +} + fn sandbox_container_running(sandbox_name: &str) -> Result { let sandbox_name_filter = format!("label={SANDBOX_NAME_LABEL}={sandbox_name}"); - let output = Command::new("podman") + let output = podman_command() .args(["ps", "-aq", "--filter", MANAGED_BY_LABEL_FILTER, "--filter"]) .arg(sandbox_name_filter) .stdout(Stdio::piped()) @@ -50,7 +73,7 @@ fn sandbox_container_running(sandbox_name: &str) -> Result { "expected one Podman container for sandbox '{sandbox_name}', found {ids:?}" )); }; - let output = Command::new("podman") + let output = podman_command() .args(["inspect", "-f", "{{.State.Running}}", container_id]) .output() .map_err(|err| format!("failed to run podman inspect: {err}"))?; From aa848f164de201e137b2388ab472f58248a66d45 Mon Sep 17 00:00:00 2001 From: Russell Bryant Date: Tue, 25 Aug 2026 11:49:22 +0000 Subject: [PATCH 009/505] fix(core): fall back to podman CLI when no API socket is found (#1858) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(core): fall back to podman CLI when no API socket responds Auto-detection only checked well-known Podman socket paths, so a Podman machine exposing its API socket at a non-standard location went undetected. The symlink at a well-known path is not always present; it varies by Podman version, machine provider, and platform. Extend detect_podman_socket() to fall back to podman CLI discovery when no well-known candidate responds: podman info --format json determines whether the service is local or remote, and podman machine inspect resolves the host-side forwarded socket for VM-backed machines. All existing callers (driver auto-detection, the Podman driver, and the VM driver's container-engine fallback) pick this up without change. Select the machine backing the active Podman connection instead of the first entry from podman machine inspect, honoring podman's connection precedence: CONTAINER_CONNECTION, then CONTAINER_HOST (mapped to a connection by URI), then the containers.conf default. An explicit endpoint that maps to no known machine is left unresolved rather than guessing an unrelated machine. When CONTAINER_HOST is an explicit unix:// socket, use that path directly since podman info connects through it. Update the gateway config reference and the Podman driver README, which described probe-only detection. Signed-off-by: Russell Bryant * fix(core): inspect podman machine by name and bound discovery probes Address two startup defects in Podman socket auto-detection: - Remote discovery ran `podman machine inspect` with no arguments, which inspects only `podman-machine-default`. On a host whose default connection is a different machine, selection fell back to the first (wrong) entry, so the gateway could operate against the wrong backend. Resolve the active machine first and inspect it by name, trying the `-root`-stripped machine for rootful connections and returning None when no machine can be mapped instead of substituting another. - `podman info`, `podman machine inspect`, and `podman system connection list` used unbounded `Command::output()`, so a stalled machine, SSH connection, or helper could hang gateway startup indefinitely. Route all three through a bounded runner that kills and reaps the child on a documented deadline and returns None so detection can continue. Signed-off-by: Russell Bryant * fix(core): bound podman probe stdout drainage and kill descendants The previous timeout only bounded waiting for the direct child to exit. Draining stdout still called read_to_end, which waits for pipe EOF — a Podman probe can leave a daemonized descendant (SSH multiplexer, gvproxy) that inherited the stdout pipe, so drainage could wait indefinitely even after the direct child exited, defeating the deadline. Run each probe as its own process-group leader, cap stdout drainage by the same deadline via a channel, and on expiry kill the whole process group so descendants holding the pipe are terminated and EOF is reached. Add a regression test where the direct child exits but a backgrounded descendant keeps stdout open. Signed-off-by: Russell Bryant * fix(core): make podman probe deadline absolute against escaped descendants The prior drainage timeout still ended in an untimed rx.recv() after killing the process group. A descendant that escaped the probe's process group (e.g. via setsid) while holding the stdout pipe would not be killed, so read_to_end never saw EOF, the reader never sent, and that recv() could block gateway startup indefinitely. On drainage timeout, best-effort kill the process group and return None immediately, abandoning the reader thread instead of waiting on it again. The deadline now bounds the whole call regardless of what descendants do. Add a regression test whose stdout-holding descendant escapes the process group (`set -m`) and assert the call still returns promptly. Signed-off-by: Russell Bryant --------- Signed-off-by: Russell Bryant --- crates/openshell-core/src/config.rs | 737 ++++++++++++++++++++++- crates/openshell-driver-podman/README.md | 2 +- docs/reference/gateway-config.mdx | 5 +- 3 files changed, 734 insertions(+), 10 deletions(-) diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index 62411e20b5..ed5736eff7 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -13,8 +13,9 @@ use std::net::SocketAddr; #[cfg(unix)] use std::os::unix::fs::FileTypeExt; use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; use std::str::FromStr; -use std::time::Duration; +use std::time::{Duration, Instant}; // ── Public default constants ──────────────────────────────────────────── // @@ -214,9 +215,16 @@ pub fn is_podman_available() -> bool { detect_podman_socket().is_some() } -/// Return the first responsive Podman API socket, or `None` if none respond. +/// Return the Podman API socket, or `None` if Podman is not available. +/// +/// Probes the well-known socket candidates first, then falls back to asking +/// the Podman CLI where its socket lives. The symlink at a well-known path is +/// not always present — it varies by Podman version, machine provider, and +/// platform — so the CLI fallback is what makes detection work on hosts where +/// Podman is functional but the socket is somewhere else. pub fn detect_podman_socket() -> Option { detect_podman_socket_from_candidates(&podman_socket_candidates()) + .or_else(discover_podman_socket) } fn detect_podman_socket_from_candidates(candidates: &[PathBuf]) -> Option { @@ -226,6 +234,339 @@ fn detect_podman_socket_from_candidates(candidates: &[PathBuf]) -> Option` with a bounded deadline, returning captured stdout on a +/// successful exit. Returns `None` on spawn failure, non-zero exit, or timeout. +fn run_podman_capture(args: &[&str]) -> Option> { + run_bounded_command("podman", args, PODMAN_DISCOVERY_TIMEOUT) +} + +/// Run `program ` with a deadline, capturing stdout. +/// +/// Unlike `Command::output()`, which blocks until the child exits, the deadline +/// is absolute: the call returns within `timeout` no matter what the probe or +/// its descendants do. A Podman probe can leave a daemonized descendant (an SSH +/// multiplexer, `gvproxy`, etc.) that inherited the stdout pipe, so +/// `read_to_end` would otherwise wait for EOF forever even after the direct +/// child exits — and such a descendant may even have escaped the probe's +/// process group. To stay bounded, the deadline covers both waiting for the +/// child and draining its stdout; on expiry the call best-effort kills the +/// process group (cleaning up in-group descendants) and gives up immediately, +/// abandoning the reader thread rather than waiting on it again. The abandoned +/// reader exits on its own once the pipe finally closes. Returns the captured +/// stdout only on a successful exit whose output was fully drained within the +/// deadline; otherwise `None`. +fn run_bounded_command(program: &str, args: &[&str], timeout: Duration) -> Option> { + use std::io::Read as _; + use std::sync::mpsc; + + let mut command = Command::new(program); + command + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + // Run the probe as its own process-group leader so in-group descendants that + // inherited the stdout pipe can be terminated as a group on timeout. + set_new_process_group(&mut command); + let mut child = command.spawn().ok()?; + + // Drain stdout on a separate thread so a child that fills the pipe buffer + // cannot deadlock against the polling loop below. The reader reports through + // a channel so the drain can be bounded by the deadline and abandoned if it + // outlives it. + let mut stdout = child.stdout.take()?; + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = stdout.read_to_end(&mut buf); + let _ = tx.send(buf); + }); + + let deadline = Instant::now() + timeout; + + // Wait for the direct child to exit, bounded by the deadline. + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break Some(status), + Ok(None) => { + if Instant::now() >= deadline { + break None; + } + std::thread::sleep(PODMAN_DISCOVERY_POLL_INTERVAL); + } + Err(_) => break None, + } + }; + + // The child never exited within the deadline: kill the group, reap the + // (now-killed) direct child, and give up. `wait()` is bounded because the + // direct child is dead. + let Some(status) = status else { + terminate_process_group(&mut child); + let _ = child.wait(); + return None; + }; + + // The direct child exited (already reaped by `try_wait`). Collect its stdout + // without ever blocking past the deadline. A surviving descendant — possibly + // one that escaped the process group — can hold the pipe open indefinitely, + // so on expiry best-effort kill the group for cleanup and return None rather + // than waiting on the reader again. + let remaining = deadline.saturating_duration_since(Instant::now()); + match rx.recv_timeout(remaining) { + Ok(stdout) if status.success() => Some(stdout), + Ok(_) => None, + Err(_) => { + terminate_process_group(&mut child); + None + } + } +} + +/// Configure `command` to start its child as a new process-group leader so the +/// group can be signaled as a unit. No-op on non-Unix platforms. +#[cfg(unix)] +fn set_new_process_group(command: &mut Command) { + use std::os::unix::process::CommandExt as _; + command.process_group(0); +} + +#[cfg(not(unix))] +fn set_new_process_group(_command: &mut Command) {} + +/// Kill the child's whole process group so daemonized descendants that inherited +/// the stdout pipe are terminated too. Falls back to killing just the child. +#[cfg(unix)] +fn terminate_process_group(child: &mut std::process::Child) { + // `set_new_process_group` made the child a group leader, so its PID doubles + // as the group ID; signaling the negated PID targets the entire group. The + // group stays valid while a descendant is alive, so this reaches survivors + // even after the leader has been reaped. + let raw_pid = i32::try_from(child.id()).unwrap_or(i32::MAX); + let pgid = nix::unistd::Pid::from_raw(-raw_pid); + let _ = nix::sys::signal::kill(pgid, nix::sys::signal::Signal::SIGKILL); + let _ = child.kill(); +} + +#[cfg(not(unix))] +fn terminate_process_group(child: &mut std::process::Child) { + let _ = child.kill(); +} + +/// Query the Podman CLI to discover the host-side API socket path. +/// +/// Strategy: +/// 1. Run `podman info --format json` to check connectivity and whether +/// the service is remote (macOS/Windows VM) or local (native Linux). +/// 2. If `CONTAINER_HOST` explicitly points at a Unix socket, `podman info` +/// just connected through it — use that path directly (a raw unix:// URL +/// has no machine to inspect and reports the VM-internal socket). +/// 3. If `serviceIsRemote` is true, run `podman machine inspect` to get +/// the host-side forwarded socket (the `remoteSocket` from `podman info` +/// is the VM-internal path, which is not reachable from the host). +/// 4. If `serviceIsRemote` is false, use `remoteSocket.path` directly +/// (on native Linux this IS the real local socket). +fn discover_podman_socket() -> Option { + let stdout = run_podman_capture(&["info", "--format", "json"])?; + + // podman info succeeded, so an explicit unix:// CONTAINER_HOST is the exact + // working host-side socket. This must be checked before the machine path, + // which cannot map a raw unix:// endpoint to a machine. + if let Some(path) = explicit_unix_container_host() { + return Some(path); + } + + let info: serde_json::Value = serde_json::from_slice(&stdout).ok()?; + let is_remote = info["host"]["serviceIsRemote"].as_bool().unwrap_or(false); + + if is_remote { + discover_podman_machine_socket() + } else { + parse_podman_info_socket(&info) + } +} + +/// Return the socket path when `CONTAINER_HOST` is an explicit `unix://` URL. +/// +/// Honors Podman's precedence: `CONTAINER_CONNECTION` outranks `CONTAINER_HOST`, +/// so a set `CONTAINER_CONNECTION` means `podman info` did not use +/// `CONTAINER_HOST` and this returns `None`. +fn explicit_unix_container_host() -> Option { + if env_var_nonempty("CONTAINER_CONNECTION").is_some() { + return None; + } + let host = env_var_nonempty("CONTAINER_HOST")?; + unix_url_socket_path(&host) +} + +/// Parse the socket path from a `unix://` URL, or `None` for other schemes. +fn unix_url_socket_path(url: &str) -> Option { + let path = url.trim().strip_prefix("unix://")?; + (!path.is_empty()).then(|| PathBuf::from(path)) +} + +/// Extract the socket path from `podman info` JSON output. +/// Used on native Linux where `remoteSocket.path` is the real local socket. +fn parse_podman_info_socket(info: &serde_json::Value) -> Option { + let path_str = info["host"]["remoteSocket"]["path"].as_str()?; + let path = path_str.strip_prefix("unix://").unwrap_or(path_str); + if path.is_empty() { + return None; + } + Some(PathBuf::from(path)) +} + +/// Which Podman machine `podman info` connected through. +/// +/// Podman resolves its endpoint (highest precedence first) from +/// `CONTAINER_CONNECTION` (a named connection), then `CONTAINER_HOST` (a URL), +/// then the default connection in `containers.conf`. +#[derive(Debug, PartialEq, Eq)] +enum ActiveMachine { + /// An explicit selector (`CONTAINER_CONNECTION`, or `CONTAINER_HOST` mapped + /// to a connection by URL) named this connection. It must match a machine + /// exactly; guessing another machine would connect to the wrong socket. + Explicit(String), + /// An explicit `CONTAINER_HOST` is set but maps to no known connection + /// (e.g. a plain remote server, not a local machine). The active machine + /// cannot be determined and must not be guessed. + UnresolvedExplicit, + /// No explicit selector; the `containers.conf` default connection name, if + /// any. When absent, Podman's built-in default machine is inspected by name. + Default(Option), +} + +/// Run `podman machine inspect ` to discover the host-side forwarded +/// socket. Used on macOS/Windows where the Podman service runs inside a VM. +/// +/// The active machine is resolved first and inspected *by name*: a no-argument +/// `podman machine inspect` inspects only `podman-machine-default`, so a host +/// whose default connection is a different machine would otherwise be pointed +/// at the wrong machine's socket. When the active machine cannot be mapped to a +/// name, this returns `None` rather than substituting an unrelated machine. +fn discover_podman_machine_socket() -> Option { + let targets = podman_machine_inspect_targets(&active_podman_machine())?; + targets.iter().find_map(|name| { + let stdout = run_podman_capture(&["machine", "inspect", name])?; + let machines: serde_json::Value = serde_json::from_slice(&stdout).ok()?; + parse_podman_machine_inspect_socket(&machines) + }) +} + +/// Machine names to try with `podman machine inspect`, most specific first. +/// +/// `None` means the active machine cannot be determined; inspection must not +/// guess, since picking an unrelated machine would return the wrong socket. A +/// rootful connection is named `-root` while the machine itself is +/// ``, so the `-root`-stripped name is offered as a fallback. +fn podman_machine_inspect_targets(active: &ActiveMachine) -> Option> { + fn names_for(connection: &str) -> Vec { + let mut names = vec![connection.to_string()]; + if let Some(stripped) = connection.strip_suffix("-root") + && !stripped.is_empty() + { + names.push(stripped.to_string()); + } + names + } + + match active { + ActiveMachine::Explicit(name) | ActiveMachine::Default(Some(name)) => Some(names_for(name)), + ActiveMachine::UnresolvedExplicit => None, + // No explicit selector and no default connection: `podman info` used + // Podman's built-in default machine, so inspect it by name rather than + // guessing an arbitrary entry. + ActiveMachine::Default(None) => Some(vec!["podman-machine-default".to_string()]), + } +} + +/// Determine which machine `podman info` connected through. +fn active_podman_machine() -> ActiveMachine { + if let Some(name) = env_var_nonempty("CONTAINER_CONNECTION") { + return ActiveMachine::Explicit(name); + } + let container_host = env_var_nonempty("CONTAINER_HOST"); + resolve_active_podman_machine(container_host.as_deref(), podman_connection_list().as_ref()) +} + +/// Resolve the active machine from `CONTAINER_HOST` and the connection list. +/// +/// `CONTAINER_CONNECTION` is handled by the caller (it needs no connection +/// list). This is split out as a pure function for testing. +fn resolve_active_podman_machine( + container_host: Option<&str>, + connections: Option<&serde_json::Value>, +) -> ActiveMachine { + if let Some(host) = container_host { + return connections + .and_then(|c| podman_connection_name_for_uri(c, host)) + .map_or(ActiveMachine::UnresolvedExplicit, ActiveMachine::Explicit); + } + ActiveMachine::Default(connections.and_then(parse_default_podman_connection)) +} + +fn env_var_nonempty(key: &str) -> Option { + std::env::var(key) + .ok() + .filter(|value| !value.trim().is_empty()) +} + +/// Run `podman system connection list --format json`. +fn podman_connection_list() -> Option { + let stdout = run_podman_capture(&["system", "connection", "list", "--format", "json"])?; + serde_json::from_slice(&stdout).ok() +} + +/// Extract the default machine connection name from +/// `podman system connection list --format json`. +fn parse_default_podman_connection(connections: &serde_json::Value) -> Option { + connections + .as_array()? + .iter() + .find(|c| { + c["Default"].as_bool().unwrap_or(false) && c["IsMachine"].as_bool().unwrap_or(false) + }) + .and_then(|c| c["Name"].as_str()) + .map(str::to_string) +} + +/// Find the machine connection whose URI matches `CONTAINER_HOST`. +/// +/// Only machine connections (`IsMachine: true`) map to a local socket, so a +/// `CONTAINER_HOST` pointing at a plain remote server yields `None`. +fn podman_connection_name_for_uri(connections: &serde_json::Value, uri: &str) -> Option { + connections + .as_array()? + .iter() + .find(|c| c["IsMachine"].as_bool().unwrap_or(false) && c["URI"].as_str() == Some(uri)) + .and_then(|c| c["Name"].as_str()) + .map(str::to_string) +} + +/// Extract the host-side socket path from a `podman machine inspect ` +/// JSON array (which contains only the inspected machine). +fn parse_podman_machine_inspect_socket(machines: &serde_json::Value) -> Option { + let machine = machines.as_array()?.first()?; + let path_str = machine["ConnectionInfo"]["PodmanSocket"]["Path"].as_str()?; + if path_str.is_empty() { + return None; + } + Some(PathBuf::from(path_str)) +} + fn podman_socket_candidates() -> Vec { let socket = std::env::var("OPENSHELL_PODMAN_SOCKET") .ok() @@ -1088,12 +1429,15 @@ const fn default_ssh_session_ttl_secs() -> u64 { #[cfg(test)] mod tests { use super::{ - ComputeDriverKind, Config, DEFAULT_SERVICE_ROUTING_DOMAIN, GatewayInterceptorBindingPolicy, - GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, GatewayJwtConfig, - GatewayProviderProfileSourceConfig, PolicyValidationFailureMode, + ActiveMachine, ComputeDriverKind, Config, DEFAULT_SERVICE_ROUTING_DOMAIN, + GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, + GatewayJwtConfig, GatewayProviderProfileSourceConfig, PolicyValidationFailureMode, detect_docker_socket_from_candidates, detect_driver, detect_podman_socket_from_candidates, - docker_host_unix_socket_path, docker_socket_responds, normalize_compute_driver_name, - podman_socket_candidates_from_env, podman_socket_responds, + docker_host_unix_socket_path, docker_socket_responds, explicit_unix_container_host, + normalize_compute_driver_name, parse_default_podman_connection, parse_podman_info_socket, + parse_podman_machine_inspect_socket, podman_connection_name_for_uri, + podman_machine_inspect_targets, podman_socket_candidates_from_env, podman_socket_responds, + resolve_active_podman_machine, run_bounded_command, unix_url_socket_path, }; #[cfg(unix)] use super::{is_reachable_unix_socket, is_unix_socket}; @@ -1104,6 +1448,8 @@ mod tests { use std::os::unix::net::UnixListener; use std::path::PathBuf; use std::time::Duration; + #[cfg(unix)] + use std::time::Instant; #[test] fn compute_driver_kind_parses_supported_values() { @@ -1595,6 +1941,23 @@ mod tests { ); } + #[test] + fn parse_podman_info_socket_extracts_linux_local_socket() { + let info: serde_json::Value = serde_json::json!({ + "host": { + "serviceIsRemote": false, + "remoteSocket": { + "path": "unix:///run/user/1000/podman/podman.sock", + "exists": true + } + } + }); + assert_eq!( + parse_podman_info_socket(&info), + Some(PathBuf::from("/run/user/1000/podman/podman.sock")) + ); + } + #[test] fn supervisor_image_tag_sanitizes_build_metadata_for_oci() { use super::resolve_supervisor_image_tag; @@ -1608,6 +1971,22 @@ mod tests { ); } + #[test] + fn parse_podman_info_socket_handles_path_without_unix_prefix() { + let info: serde_json::Value = serde_json::json!({ + "host": { + "remoteSocket": { + "path": "/run/user/1000/podman/podman.sock", + "exists": true + } + } + }); + assert_eq!( + parse_podman_info_socket(&info), + Some(PathBuf::from("/run/user/1000/podman/podman.sock")) + ); + } + #[test] fn default_supervisor_image_is_version_pinned() { use super::default_supervisor_image; @@ -1616,4 +1995,348 @@ mod tests { let tag = image.rsplit_once(':').unwrap().1; assert!(!tag.is_empty()); } + + #[test] + fn parse_podman_info_socket_returns_none_for_missing_path() { + let info: serde_json::Value = serde_json::json!({ + "host": { + "remoteSocket": {} + } + }); + assert_eq!(parse_podman_info_socket(&info), None); + } + + #[test] + fn parse_podman_info_socket_returns_none_for_empty_path() { + let info: serde_json::Value = serde_json::json!({ + "host": { + "remoteSocket": { + "path": "", + "exists": false + } + } + }); + assert_eq!(parse_podman_info_socket(&info), None); + } + + #[test] + fn parse_podman_machine_inspect_socket_extracts_macos_socket() { + // `podman machine inspect ` returns only the inspected machine. + let machines: serde_json::Value = serde_json::json!([ + { + "ConnectionInfo": { + "PodmanSocket": { + "Path": "/var/folders/1q/jx7s14b928n8zvstgfk98lj00000gn/T/podman/podman-machine-default-api.sock" + }, + "PodmanPipe": null + }, + "Name": "podman-machine-default" + } + ]); + assert_eq!( + parse_podman_machine_inspect_socket(&machines), + Some(PathBuf::from( + "/var/folders/1q/jx7s14b928n8zvstgfk98lj00000gn/T/podman/podman-machine-default-api.sock" + )) + ); + } + + #[test] + fn parse_podman_machine_inspect_socket_returns_none_for_empty_array() { + let machines: serde_json::Value = serde_json::json!([]); + assert_eq!(parse_podman_machine_inspect_socket(&machines), None); + } + + #[test] + fn parse_podman_machine_inspect_socket_returns_none_for_missing_socket() { + let machines: serde_json::Value = serde_json::json!([ + { + "ConnectionInfo": {}, + "Name": "podman-machine-default" + } + ]); + assert_eq!(parse_podman_machine_inspect_socket(&machines), None); + } + + #[test] + fn podman_machine_inspect_targets_uses_explicit_machine_by_name() { + // The active connection points at `work`; discovery must inspect `work` + // explicitly rather than the no-argument default machine, which would + // return a different machine's socket. + assert_eq!( + podman_machine_inspect_targets(&ActiveMachine::Explicit("work".to_string())), + Some(vec!["work".to_string()]) + ); + } + + #[test] + fn podman_machine_inspect_targets_strips_rootful_suffix() { + // Rootful connections are named `-root`; the machine itself is + // ``, offered as a fallback after the connection name. + assert_eq!( + podman_machine_inspect_targets(&ActiveMachine::Explicit("work-root".to_string())), + Some(vec!["work-root".to_string(), "work".to_string()]) + ); + } + + #[test] + fn podman_machine_inspect_targets_uses_default_connection_name() { + assert_eq!( + podman_machine_inspect_targets(&ActiveMachine::Default(Some("work".to_string()))), + Some(vec!["work".to_string()]) + ); + } + + #[test] + fn podman_machine_inspect_targets_falls_back_to_builtin_default() { + // No explicit selector and no default connection: inspect Podman's own + // built-in default machine by name. + assert_eq!( + podman_machine_inspect_targets(&ActiveMachine::Default(None)), + Some(vec!["podman-machine-default".to_string()]) + ); + } + + #[test] + fn podman_machine_inspect_targets_does_not_guess_for_unresolved_explicit() { + // CONTAINER_HOST pointing at a non-machine endpoint cannot be mapped to + // a machine; discovery must not guess an unrelated one. + assert_eq!( + podman_machine_inspect_targets(&ActiveMachine::UnresolvedExplicit), + None + ); + } + + #[cfg(unix)] + #[test] + fn run_bounded_command_captures_stdout_on_success() { + assert_eq!( + run_bounded_command("printf", &["hello"], Duration::from_secs(5)), + Some(b"hello".to_vec()) + ); + } + + #[cfg(unix)] + #[test] + fn run_bounded_command_returns_none_on_nonzero_exit() { + assert_eq!( + run_bounded_command("false", &[], Duration::from_secs(5)), + None + ); + } + + #[cfg(unix)] + #[test] + fn run_bounded_command_kills_child_that_exceeds_deadline() { + // A process that would otherwise block startup indefinitely must be + // bounded: `run_bounded_command` returns within the deadline instead of + // hanging until the child exits. + let start = Instant::now(); + let result = run_bounded_command("sleep", &["30"], Duration::from_millis(200)); + let elapsed = start.elapsed(); + assert_eq!(result, None); + assert!( + elapsed < Duration::from_secs(5), + "bounded command did not return promptly: {elapsed:?}" + ); + } + + #[cfg(unix)] + #[test] + fn run_bounded_command_bounds_drain_when_in_group_descendant_holds_stdout() { + // The shell exits immediately after `echo`, but the backgrounded child + // (in the same process group) inherits and holds the stdout pipe open. + // Without a bounded drain, `read_to_end` would wait ~30s for EOF even + // though the direct child already exited. + let start = Instant::now(); + let result = run_bounded_command( + "sh", + &["-c", "sleep 30 & echo done"], + Duration::from_millis(300), + ); + let elapsed = start.elapsed(); + assert!( + elapsed < Duration::from_secs(2), + "drain blocked on a descendant holding stdout: {elapsed:?}" + ); + // Draining hit the deadline, so the probe is treated as "not found". + assert_eq!(result, None); + } + + #[cfg(unix)] + #[test] + fn run_bounded_command_bounds_drain_when_descendant_escapes_process_group() { + // `set -m` runs the background job in its OWN process group, so it + // survives the group kill while still holding the stdout pipe. The + // deadline must remain absolute: the call must not fall back to an + // untimed receive that waits for the escaped descendant's EOF. + let start = Instant::now(); + let result = run_bounded_command( + "bash", + &["-c", "set -m; sleep 5 & echo done"], + Duration::from_millis(300), + ); + let elapsed = start.elapsed(); + // A regression (blocking receive after the timeout) would wait ~5s for + // the escaped `sleep`; the bounded implementation returns promptly. + assert!( + elapsed < Duration::from_secs(2), + "drain blocked on a descendant that escaped the process group: {elapsed:?}" + ); + assert_eq!(result, None); + } + + #[cfg(unix)] + #[test] + fn run_bounded_command_returns_none_for_missing_program() { + assert_eq!( + run_bounded_command( + "openshell-nonexistent-binary-xyz", + &[], + Duration::from_secs(5) + ), + None + ); + } + + #[test] + fn resolve_active_podman_machine_maps_container_host_to_connection() { + let connections: serde_json::Value = serde_json::json!([ + { "Name": "work", "IsMachine": true, "Default": false, + "URI": "ssh://core@127.0.0.1:5555/run/user/1000/podman/podman.sock" }, + { "Name": "podman-machine-default", "IsMachine": true, "Default": true, + "URI": "ssh://core@127.0.0.1:4444/run/user/1000/podman/podman.sock" } + ]); + // CONTAINER_HOST pointing at the non-default machine's URI resolves to + // that machine, not the default. + assert_eq!( + resolve_active_podman_machine( + Some("ssh://core@127.0.0.1:5555/run/user/1000/podman/podman.sock"), + Some(&connections) + ), + ActiveMachine::Explicit("work".to_string()) + ); + } + + #[test] + fn resolve_active_podman_machine_unmatched_host_is_unresolved() { + let connections: serde_json::Value = serde_json::json!([ + { "Name": "podman-machine-default", "IsMachine": true, "Default": true, + "URI": "ssh://core@127.0.0.1:4444/run/user/1000/podman/podman.sock" } + ]); + // A CONTAINER_HOST that matches no machine connection is unresolved, + // never silently mapped to the default machine. + assert_eq!( + resolve_active_podman_machine(Some("tcp://192.0.2.10:2375"), Some(&connections)), + ActiveMachine::UnresolvedExplicit + ); + } + + #[test] + fn resolve_active_podman_machine_defaults_without_host() { + let connections: serde_json::Value = serde_json::json!([ + { "Name": "podman-machine-default", "IsMachine": true, "Default": true, + "URI": "ssh://core@127.0.0.1:4444/run/user/1000/podman/podman.sock" } + ]); + assert_eq!( + resolve_active_podman_machine(None, Some(&connections)), + ActiveMachine::Default(Some("podman-machine-default".to_string())) + ); + } + + #[test] + fn podman_connection_name_for_uri_ignores_non_machine_matches() { + let connections: serde_json::Value = serde_json::json!([ + { "Name": "remote", "IsMachine": false, "Default": false, + "URI": "tcp://192.0.2.10:2375" } + ]); + // A URI match against a non-machine connection does not map to a local + // machine socket. + assert_eq!( + podman_connection_name_for_uri(&connections, "tcp://192.0.2.10:2375"), + None + ); + } + + #[test] + fn unix_url_socket_path_parses_unix_urls() { + assert_eq!( + unix_url_socket_path("unix:///run/user/1000/podman/podman.sock"), + Some(PathBuf::from("/run/user/1000/podman/podman.sock")) + ); + // Non-unix schemes and empty paths are not sockets. + assert_eq!(unix_url_socket_path("ssh://core@127.0.0.1:22/x"), None); + assert_eq!(unix_url_socket_path("tcp://127.0.0.1:2375"), None); + assert_eq!(unix_url_socket_path("unix://"), None); + } + + #[test] + #[allow(unsafe_code)] // std::env::set_var/remove_var require unsafe in Rust 2024 + fn explicit_unix_container_host_honors_scheme_and_precedence() { + fn set(key: &str, value: Option<&str>) { + unsafe { + match value { + Some(v) => std::env::set_var(key, v), + None => std::env::remove_var(key), + } + } + } + + let original_host = std::env::var("CONTAINER_HOST").ok(); + let original_connection = std::env::var("CONTAINER_CONNECTION").ok(); + + // A unix:// CONTAINER_HOST with no CONTAINER_CONNECTION is used directly. + set("CONTAINER_CONNECTION", None); + set("CONTAINER_HOST", Some("unix:///tmp/podman/custom.sock")); + assert_eq!( + explicit_unix_container_host(), + Some(PathBuf::from("/tmp/podman/custom.sock")) + ); + + // CONTAINER_CONNECTION outranks CONTAINER_HOST. + set("CONTAINER_CONNECTION", Some("work")); + assert_eq!(explicit_unix_container_host(), None); + + // A non-unix CONTAINER_HOST is not a direct socket. + set("CONTAINER_CONNECTION", None); + set( + "CONTAINER_HOST", + Some("ssh://core@127.0.0.1:5555/run/podman.sock"), + ); + assert_eq!(explicit_unix_container_host(), None); + + // Nothing set. + set("CONTAINER_HOST", None); + assert_eq!(explicit_unix_container_host(), None); + + set("CONTAINER_HOST", original_host.as_deref()); + set("CONTAINER_CONNECTION", original_connection.as_deref()); + } + + #[test] + fn parse_default_podman_connection_picks_default_machine() { + let connections: serde_json::Value = serde_json::json!([ + { "Name": "podman-machine-default", "IsMachine": true, "Default": true }, + { "Name": "podman-machine-default-root", "IsMachine": true, "Default": false } + ]); + assert_eq!( + parse_default_podman_connection(&connections), + Some("podman-machine-default".to_string()) + ); + } + + #[test] + fn parse_default_podman_connection_ignores_non_machine_and_missing_default() { + // Default connection that is not a machine is ignored. + let non_machine: serde_json::Value = serde_json::json!([ + { "Name": "remote-host", "IsMachine": false, "Default": true } + ]); + assert_eq!(parse_default_podman_connection(&non_machine), None); + + // No default at all. + let no_default: serde_json::Value = serde_json::json!([ + { "Name": "work", "IsMachine": true, "Default": false } + ]); + assert_eq!(parse_default_podman_connection(&no_default), None); + } } diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index f8890b00d1..b0436634ee 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -374,7 +374,7 @@ Podman resources after out-of-band container removal or label drift. | Environment Variable | CLI Flag | Default | Description | |---|---|---|---| -| `OPENSHELL_PODMAN_SOCKET` | `--podman-socket` | Probes known local Podman API sockets and uses the first responsive socket. Fails to start if none respond. | Podman API Unix socket path. | +| `OPENSHELL_PODMAN_SOCKET` | `--podman-socket` | Probes known local Podman API sockets and uses the first responsive socket, then falls back to asking the `podman` CLI for the host-side socket. Fails to start if neither finds one. | Podman API Unix socket path. | | `OPENSHELL_SANDBOX_IMAGE` | `--sandbox-image` | From gateway config | Default OCI image for sandboxes. | | `OPENSHELL_SANDBOX_IMAGE_PULL_POLICY` | `--sandbox-image-pull-policy` | `missing` | Pull policy: `always`, `missing`, `never`, or `newer`. | | `OPENSHELL_GRPC_ENDPOINT` | `--grpc-endpoint` | Auto-detected via `host.containers.internal` | Gateway gRPC endpoint for sandbox callbacks. | diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index a287d3d5b0..d1fed9ae44 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -616,8 +616,9 @@ compute_drivers = ["podman"] [openshell.drivers.podman] # Rootless socket path. For root Podman use /run/podman/podman.sock. -# Omit to auto-detect: the driver probes for a responsive Podman socket and -# fails to start if none respond. +# Omit to auto-detect: the driver probes for a responsive Podman socket, then +# asks the podman CLI where its socket is, and fails to start if neither finds +# one. Set this to pin a specific Podman machine instead. socket_path = "/run/user/1000/podman/podman.sock" default_image = "ghcr.io/nvidia/openshell/sandbox:latest" image_pull_policy = "missing" # always | missing | never | newer From 4fe5b0f6057f4d9a8e949da0dc3dd47a7865b058 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Tue, 25 Aug 2026 12:43:45 +0000 Subject: [PATCH 010/505] fix(ci): allow pasta to receive Podman stop signals (#2900) * fix(ci): allow pasta to receive Podman stop signals Signed-off-by: Evan Lezar * test(ci): fail on pasta SIGTERM denial Signed-off-by: Evan Lezar * test(ci): retain AppArmor denial diagnostics Signed-off-by: Evan Lezar --------- Signed-off-by: Evan Lezar --- .github/workflows/e2e-test.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 006f5cf440..3e6a972874 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -197,6 +197,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ + apparmor \ build-essential \ clang \ fuse-overlayfs \ @@ -219,6 +220,20 @@ jobs: echo "/usr/bin" >> "${GITHUB_PATH}" echo "CONTAINERS_CONF_OVERRIDE=${podman_config}" >> "${GITHUB_ENV}" + - name: Allow pasta to receive Podman stop signals + # Ubuntu's packaged pasta profile currently blocks this signal, forcing + # Podman to wait for its SIGKILL fallback. Keep this narrow allowance + # until the distribution package includes the upstream profile fix. + run: | + set -euo pipefail + profile=/etc/apparmor.d/usr.bin.pasta + rule=' signal (receive) peer=podman,' + if ! sudo grep -Fqx "${rule}" "${profile}"; then + sudo sed -i '\|^ include $|a\ signal (receive) peer=podman,' "${profile}" + fi + sudo grep -Fqx "${rule}" "${profile}" + sudo apparmor_parser --replace "${profile}" + - name: Configure rootless Podman run: | set -euo pipefail @@ -279,6 +294,17 @@ jobs: if: always() run: sudo dmesg | grep -E 'apparmor=.*DENIED|profile="unprivileged_userns"' | tail -100 || true + - name: Fail on pasta SIGTERM AppArmor denial + if: always() + run: | + set -euo pipefail + denials="$(sudo dmesg | grep -E 'profile="pasta".*requested_mask="receive".*signal=term.*peer="podman"' || true)" + if [ -n "${denials}" ]; then + echo "::error::pasta denied Podman's SIGTERM; Podman will use its SIGKILL fallback" + printf '%s\n' "${denials}" + exit 1 + fi + e2e-vm: name: E2E (rust-vm-${{ matrix.suite }}) # libkrun needs KVM, so this job must run directly on a GitHub-hosted From 455883905a7ace88e6e69834dc0685bfc799ad44 Mon Sep 17 00:00:00 2001 From: Simon Scatton <44714756+SDAChess@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:20:42 +0000 Subject: [PATCH 011/505] fix(python): remove CLI from wheel (#2321) The Maturin-based wheel packaging was a historical remnant from when the local gateway launch path and OpenShell CLI were coupled in one binary. The gateway and CLI now ship as standalone artifacts, so the Python distribution should contain only the SDK. Build a single platform-independent setuptools wheel, verify that it cannot contain native code or an openshell entry point, and simplify the release jobs and documentation for SDK-only PyPI installs. Signed-off-by: Simon Scatton --- .github/workflows/release-dev.yml | 119 +++-------- .github/workflows/release-tag.yml | 120 +++--------- README.md | 8 +- THIRD-PARTY-NOTICES | 7 - architecture/build.md | 23 ++- deploy/docker/Dockerfile.python-wheels | 115 ----------- deploy/docker/Dockerfile.python-wheels-macos | 126 ------------ docs/about/installation.mdx | 6 + docs/get-started/quickstart.mdx | 6 - docs/get-started/tutorials/docker-compose.mdx | 8 +- flake.nix | 1 + pyproject.toml | 36 ++-- python/wheel_verifier_test.py | 143 ++++++++++++++ tasks/python.toml | 184 ++---------------- tasks/scripts/verify-python-wheel.py | 151 ++++++++++++++ tasks/test.toml | 4 +- uv.lock | 40 +--- 17 files changed, 398 insertions(+), 699 deletions(-) delete mode 100644 deploy/docker/Dockerfile.python-wheels delete mode 100644 deploy/docker/Dockerfile.python-wheels-macos create mode 100644 python/wheel_verifier_test.py create mode 100644 tasks/scripts/verify-python-wheel.py diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 768f5bd5a6..1df404d052 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -99,91 +99,24 @@ jobs: "${REGISTRY}/${component}:${{ github.sha }}" done - build-python-wheels-linux: - name: Build Python Wheels (Linux ${{ matrix.arch }}) - needs: [compute-versions] - strategy: - matrix: - include: - - arch: amd64 - runner: linux-amd64-cpu8 - artifact: linux-amd64 - task: python:build:linux:amd64 - output_path: target/wheels/linux-amd64/*.whl - - arch: arm64 - runner: linux-arm64-cpu8 - artifact: linux-arm64 - task: python:build:linux:arm64 - output_path: target/wheels/linux-arm64/*.whl - runs-on: ${{ matrix.runner }} - timeout-minutes: 120 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENSHELL_IMAGE_TAG: dev - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Sync Python dependencies - run: uv sync - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - with: - shared-key: python-wheel-linux-${{ matrix.arch }} - cache-directories: .cache/sccache - cache-targets: "true" - - - name: Build Python wheels - run: | - set -euo pipefail - OPENSHELL_CARGO_VERSION="${{ needs.compute-versions.outputs.cargo_version }}" mise run ${{ matrix.task }} - ls -la ${{ matrix.output_path }} - - - name: Upload wheel artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: python-wheels-${{ matrix.artifact }} - path: ${{ matrix.output_path }} - retention-days: 5 - - build-python-wheel-macos: - name: Build Python Wheel (macOS) + build-python-wheel: + name: Build Python Wheel needs: [compute-versions] runs-on: linux-amd64-cpu8 - timeout-minutes: 120 + timeout-minutes: 20 container: image: ghcr.io/nvidia/openshell/ci:latest credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - volumes: - - /var/run/docker.sock:/var/run/docker.sock env: MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENSHELL_IMAGE_TAG: dev + SETUPTOOLS_SCM_PRETEND_VERSION_FOR_OPENSHELL: ${{ needs.compute-versions.outputs.python_version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Set up Docker Buildx - uses: ./.github/actions/setup-buildx - - name: Mark workspace safe for git run: git config --global --add safe.directory "$GITHUB_WORKSPACE" @@ -193,13 +126,13 @@ jobs: - name: Build Python wheel run: | set -euo pipefail - OPENSHELL_CARGO_VERSION="${{ needs.compute-versions.outputs.cargo_version }}" mise run python:build:macos + mise run python:build ls -la target/wheels/*.whl - - name: Upload wheel artifacts + - name: Upload wheel artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: python-wheels-macos + name: python-wheel path: target/wheels/*.whl retention-days: 5 @@ -683,7 +616,7 @@ jobs: smoke-linux-dev-artifacts: name: Smoke Linux Dev Artifacts (${{ matrix.name }}) - needs: [build-gateway-binary-linux, build-driver-vm-linux, build-deb, build-rpm, build-python-wheels-linux] + needs: [build-gateway-binary-linux, build-driver-vm-linux, build-deb, build-rpm, build-python-wheel] timeout-minutes: 20 strategy: fail-fast: false @@ -713,18 +646,10 @@ jobs: kind: rpm artifact_arch: arm64 rpm_arch: aarch64 - - name: python-wheel-amd64 + - name: python-wheel runner: linux-amd64-cpu8 image: python:3.12-slim kind: wheel - artifact_arch: amd64 - rpm_arch: x86_64 - - name: python-wheel-arm64 - runner: linux-arm64-cpu8 - image: python:3.12-slim - kind: wheel - artifact_arch: arm64 - rpm_arch: aarch64 runs-on: ${{ matrix.runner }} container: image: ${{ matrix.image }} @@ -763,27 +688,29 @@ jobs: if: matrix.kind == 'wheel' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: python-wheels-linux-${{ matrix.artifact_arch }} + name: python-wheel path: wheel-input/ - name: Smoke Python wheel if: matrix.kind == 'wheel' run: | set -euo pipefail - pip install --no-cache-dir wheel-input/*.whl - python - <<'PY' + python -m venv /tmp/openshell-wheel-smoke + /tmp/openshell-wheel-smoke/bin/pip install --no-cache-dir wheel-input/*.whl + /tmp/openshell-wheel-smoke/bin/python - <<'PY' import importlib for name in ("openshell", "openshell._proto", "openshell.sandbox"): importlib.import_module(name) print(name, "OK") PY + test ! -e /tmp/openshell-wheel-smoke/bin/openshell # --------------------------------------------------------------------------- # Create / update the dev GitHub Release with CLI, gateway, driver, and wheels # --------------------------------------------------------------------------- release-dev: name: Release Dev - needs: [compute-versions, build-cli-linux, build-cli-macos, build-gateway-binary-linux, build-gateway-binary-macos, build-supervisor-binary-linux, build-python-wheels-linux, build-python-wheel-macos, e2e, build-driver-vm-linux, build-driver-vm-macos, build-deb, build-rpm, build-snap, smoke-linux-dev-artifacts] + needs: [compute-versions, build-cli-linux, build-cli-macos, build-gateway-binary-linux, build-gateway-binary-macos, build-supervisor-binary-linux, build-python-wheel, e2e, build-driver-vm-linux, build-driver-vm-macos, build-deb, build-rpm, build-snap, smoke-linux-dev-artifacts] runs-on: linux-amd64-cpu8 timeout-minutes: 10 permissions: @@ -824,12 +751,11 @@ jobs: path: release/ merge-multiple: true - - name: Download wheel artifacts + - name: Download wheel artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: python-wheels-* + name: python-wheel path: release/ - merge-multiple: true - name: Download Debian package artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -881,9 +807,14 @@ jobs: id: wheel_filenames run: | set -euo pipefail - ls -la release/*.whl - WHEEL_FILENAMES=$(ls release/*.whl | xargs -n1 basename | sort | paste -sd, -) - echo "wheel_filenames=${WHEEL_FILENAMES}" >> "$GITHUB_OUTPUT" + shopt -s nullglob + wheels=(release/*.whl) + if [ "${#wheels[@]}" -ne 1 ]; then + echo "expected exactly one Python wheel, found ${#wheels[@]}" >&2 + exit 1 + fi + wheel_filename=$(basename "${wheels[0]}") + echo "wheel_filenames=${wheel_filename}" >> "$GITHUB_OUTPUT" - name: Generate checksums run: | diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 35792bcb63..e0d19a46ec 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -130,93 +130,25 @@ jobs: "${REGISTRY}/${component}:${SOURCE_TAG}" done - build-python-wheels-linux: - name: Build Python Wheels (Linux ${{ matrix.arch }}) - needs: [compute-versions] - strategy: - matrix: - include: - - arch: amd64 - runner: linux-amd64-cpu8 - artifact: linux-amd64 - task: python:build:linux:amd64 - output_path: target/wheels/linux-amd64/*.whl - - arch: arm64 - runner: linux-arm64-cpu8 - artifact: linux-arm64 - task: python:build:linux:arm64 - output_path: target/wheels/linux-arm64/*.whl - runs-on: ${{ matrix.runner }} - timeout-minutes: 120 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENSHELL_IMAGE_TAG: ${{ needs.compute-versions.outputs.semver }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.tag || github.ref }} - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Sync Python dependencies - run: uv sync - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - with: - shared-key: python-wheel-linux-${{ matrix.arch }} - cache-directories: .cache/sccache - cache-targets: "true" - - - name: Build Python wheels - run: | - set -euo pipefail - OPENSHELL_CARGO_VERSION="${{ needs.compute-versions.outputs.cargo_version }}" mise run ${{ matrix.task }} - ls -la ${{ matrix.output_path }} - - - name: Upload wheel artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: python-wheels-${{ matrix.artifact }} - path: ${{ matrix.output_path }} - retention-days: 5 - - build-python-wheel-macos: - name: Build Python Wheel (macOS) + build-python-wheel: + name: Build Python Wheel needs: [compute-versions] runs-on: linux-amd64-cpu8 - timeout-minutes: 120 + timeout-minutes: 20 container: image: ghcr.io/nvidia/openshell/ci:latest credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - volumes: - - /var/run/docker.sock:/var/run/docker.sock env: MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENSHELL_IMAGE_TAG: ${{ needs.compute-versions.outputs.semver }} + SETUPTOOLS_SCM_PRETEND_VERSION_FOR_OPENSHELL: ${{ needs.compute-versions.outputs.python_version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.tag || github.ref }} fetch-depth: 0 - - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Set up Docker Buildx - uses: ./.github/actions/setup-buildx - - name: Mark workspace safe for git run: git config --global --add safe.directory "$GITHUB_WORKSPACE" @@ -226,13 +158,13 @@ jobs: - name: Build Python wheel run: | set -euo pipefail - OPENSHELL_CARGO_VERSION="${{ needs.compute-versions.outputs.cargo_version }}" mise run python:build:macos + mise run python:build ls -la target/wheels/*.whl - - name: Upload wheel artifacts + - name: Upload wheel artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: python-wheels-macos + name: python-wheel path: target/wheels/*.whl retention-days: 5 @@ -721,7 +653,7 @@ jobs: smoke-linux-release-artifacts: name: Smoke Linux Release Artifacts (${{ matrix.name }}) - needs: [build-gateway-binary-linux, build-driver-vm-linux, build-deb, build-rpm, build-python-wheels-linux] + needs: [build-gateway-binary-linux, build-driver-vm-linux, build-deb, build-rpm, build-python-wheel] timeout-minutes: 20 strategy: fail-fast: false @@ -773,16 +705,6 @@ jobs: runner: linux-amd64-cpu8 image: python:3.12-slim kind: wheel - artifact_arch: amd64 - rpm_arch: x86_64 - target: x86_64-unknown-linux-gnu - - name: python-wheel-arm64 - runner: linux-arm64-cpu8 - image: python:3.12-slim - kind: wheel - artifact_arch: arm64 - rpm_arch: aarch64 - target: aarch64-unknown-linux-gnu runs-on: ${{ matrix.runner }} container: image: ${{ matrix.image }} @@ -846,27 +768,29 @@ jobs: if: matrix.kind == 'wheel' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: python-wheels-linux-${{ matrix.artifact_arch }} + name: python-wheel path: wheel-input/ - name: Smoke Python wheel if: matrix.kind == 'wheel' run: | set -euo pipefail - pip install --no-cache-dir wheel-input/*.whl - python - <<'PY' + python -m venv /tmp/openshell-wheel-smoke + /tmp/openshell-wheel-smoke/bin/pip install --no-cache-dir wheel-input/*.whl + /tmp/openshell-wheel-smoke/bin/python - <<'PY' import importlib for name in ("openshell", "openshell._proto", "openshell.sandbox"): importlib.import_module(name) print(name, "OK") PY + test ! -e /tmp/openshell-wheel-smoke/bin/openshell # --------------------------------------------------------------------------- # Create a tagged GitHub Release with CLI, gateway, driver, and wheels # --------------------------------------------------------------------------- release: name: Release - needs: [compute-versions, build-cli-linux, build-cli-macos, build-gateway-binary-linux, build-gateway-binary-macos, build-supervisor-binary-linux, build-python-wheels-linux, build-python-wheel-macos, e2e, build-driver-vm-linux, build-driver-vm-macos, build-deb, build-rpm, build-snap, smoke-linux-release-artifacts] + needs: [compute-versions, build-cli-linux, build-cli-macos, build-gateway-binary-linux, build-gateway-binary-macos, build-supervisor-binary-linux, build-python-wheel, e2e, build-driver-vm-linux, build-driver-vm-macos, build-deb, build-rpm, build-snap, smoke-linux-release-artifacts] runs-on: linux-amd64-cpu8 timeout-minutes: 10 permissions: @@ -909,12 +833,11 @@ jobs: path: release/ merge-multiple: true - - name: Download wheel artifacts + - name: Download wheel artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: python-wheels-* + name: python-wheel path: release/ - merge-multiple: true - name: Download Debian package artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -941,9 +864,14 @@ jobs: id: wheel_filenames run: | set -euo pipefail - ls -la release/*.whl - WHEEL_FILENAMES=$(ls release/*.whl | xargs -n1 basename | sort | paste -sd, -) - echo "wheel_filenames=${WHEEL_FILENAMES}" >> "$GITHUB_OUTPUT" + shopt -s nullglob + wheels=(release/*.whl) + if [ "${#wheels[@]}" -ne 1 ]; then + echo "expected exactly one Python wheel, found ${#wheels[@]}" >&2 + exit 1 + fi + wheel_filename=$(basename "${wheels[0]}") + echo "wheel_filenames=${wheel_filename}" >> "$GITHUB_OUTPUT" - name: Generate checksums run: | diff --git a/README.md b/README.md index b8ce17a6d2..ba1ccf9859 100644 --- a/README.md +++ b/README.md @@ -33,14 +33,14 @@ OpenShell is built agent-first. The project ships with agent skills for everythi curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh ``` -**From PyPI (requires [uv](https://docs.astral.sh/uv/)):** +The installer installs the latest stable release by default. To install a specific version, set `OPENSHELL_VERSION`. A [`dev` release](https://github.com/NVIDIA/OpenShell/releases/tag/dev) is also available that tracks the latest commit on `main`. + +The `openshell` package on PyPI provides the Python SDK only. It does not install the `openshell` CLI. Add the SDK to a Python project with [uv](https://docs.astral.sh/uv/): ```bash -uv tool install -U openshell +uv add openshell ``` -Both methods install the latest stable release by default. To install a specific version, set `OPENSHELL_VERSION` (binary) or pin the version with `uv tool install openshell==`. A [`dev` release](https://github.com/NVIDIA/OpenShell/releases/tag/dev) is also available that tracks the latest commit on `main`. - **Helm chart:** > **Experimental** — the Kubernetes deployment path is under active development. Expect rough edges and breaking changes. diff --git a/THIRD-PARTY-NOTICES b/THIRD-PARTY-NOTICES index 41eda0e0a8..8125e5692f 100644 --- a/THIRD-PARTY-NOTICES +++ b/THIRD-PARTY-NOTICES @@ -14813,13 +14813,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -================================================================================ -maturin 1.11.5 -License: MIT OR Apache-2.0 --------------------------------------------------------------------------------- - -UNKNOWN - ================================================================================ packaging 26.0 License: Apache-2.0 OR BSD-2-Clause diff --git a/architecture/build.md b/architecture/build.md index 5c5751772a..bc9eb9a1d0 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -11,7 +11,8 @@ OpenShell builds these main artifacts: | Artifact | Source | |---|---| | Gateway binary | `crates/openshell-server` | -| CLI package and Python SDK | `python/openshell` plus Rust binaries where packaged | +| CLI binaries and system packages | `crates/openshell-cli` plus release packaging | +| Python SDK wheel | `python/openshell` | | TypeScript SDK package | `sdk/typescript` | | Gateway container image | `deploy/docker/Dockerfile.gateway` | | Supervisor container image | `deploy/docker/Dockerfile.supervisor` | @@ -205,14 +206,18 @@ for explicit publication. ## Python Wheel Packaging The generated protobuf/gRPC stubs under `python/openshell/_proto/` are gitignored -build outputs of `mise run python:proto`. The task uses `uv run --frozen` to -synchronize the current worktree's `.venv` from `uv.lock` before generation. -maturin honors `.gitignore` when collecting `python-source` files, so native -builds (Linux CI, local `pip install .`) would drop them and ship an unimportable -wheel. `pyproject.toml` -pins them back in with `[tool.maturin].include` globs. The release workflows -install each Linux wheel in a clean image and import `openshell.sandbox` as a -smoke check. +build outputs of `mise run python:proto`. Setuptools includes them through the +package-data configuration in `pyproject.toml`. Release workflows build the +wheel directly and do not produce a source distribution. Setuptools SCM derives +local versions from Git and accepts the release workflow's computed version +through its distribution-specific override. + +The build produces one platform-independent `py3-none-any` wheel. A verifier +checks its tag, metadata, version, required package files, and the absence of +native files or an `openshell` executable entry point. Release workflows build +the wheel once, install it in a clean virtual environment, import the public +package modules, and confirm that installation did not create an `openshell` +command. ## TypeScript SDK Packaging diff --git a/deploy/docker/Dockerfile.python-wheels b/deploy/docker/Dockerfile.python-wheels deleted file mode 100644 index fe58e3fcc7..0000000000 --- a/deploy/docker/Dockerfile.python-wheels +++ /dev/null @@ -1,115 +0,0 @@ -# syntax=docker/dockerfile:1.6 - -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - - -ARG PYTHON_VERSION=3.12 - -FROM --platform=$BUILDPLATFORM python:${PYTHON_VERSION}-slim AS base - -ENV PATH="/root/.cargo/bin:${PATH}" - -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - cmake \ - curl \ - gcc \ - libc6-dev \ - libclang-dev \ - pkg-config \ - libssl-dev \ - && rm -rf /var/lib/apt/lists/* - -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.95.0 -RUN pip install --no-cache-dir maturin - -COPY deploy/docker/cross-build.sh /usr/local/bin/ - -FROM base AS builder - -ARG TARGETARCH -ARG BUILDARCH -ARG CARGO_TARGET_CACHE_SCOPE=default - -ARG SCCACHE_MEMCACHED_ENDPOINT - -WORKDIR /build - -RUN . cross-build.sh && install_cross_toolchain && install_sccache && add_rust_target - -# Copy dependency manifests first for better caching. -COPY Cargo.toml Cargo.lock ./ -COPY crates/openshell-cli/Cargo.toml crates/openshell-cli/Cargo.toml -COPY crates/openshell-core/Cargo.toml crates/openshell-core/Cargo.toml -COPY crates/openshell-ocsf/Cargo.toml crates/openshell-ocsf/Cargo.toml -COPY crates/openshell-providers/Cargo.toml crates/openshell-providers/Cargo.toml -COPY crates/openshell-router/Cargo.toml crates/openshell-router/Cargo.toml -COPY crates/openshell-sandbox/Cargo.toml crates/openshell-sandbox/Cargo.toml -COPY crates/openshell-server/Cargo.toml crates/openshell-server/Cargo.toml -COPY crates/openshell-bootstrap/Cargo.toml crates/openshell-bootstrap/Cargo.toml -COPY crates/openshell-policy/Cargo.toml crates/openshell-policy/Cargo.toml -COPY crates/openshell-prover/Cargo.toml crates/openshell-prover/Cargo.toml -COPY crates/openshell-tui/Cargo.toml crates/openshell-tui/Cargo.toml -COPY crates/openshell-core/build.rs crates/openshell-core/build.rs -COPY proto/ proto/ - -# Create dummy source files to build dependencies. -RUN mkdir -p crates/openshell-cli/src crates/openshell-core/src crates/openshell-ocsf/src crates/openshell-policy/src crates/openshell-providers/src crates/openshell-prover/src crates/openshell-router/src crates/openshell-sandbox/src crates/openshell-server/src crates/openshell-bootstrap/src crates/openshell-tui/src && \ - echo "fn main() {}" > crates/openshell-cli/src/main.rs && \ - echo "fn main() {}" > crates/openshell-sandbox/src/main.rs && \ - echo "fn main() {}" > crates/openshell-server/src/main.rs && \ - touch crates/openshell-core/src/lib.rs && \ - touch crates/openshell-ocsf/src/lib.rs && \ - touch crates/openshell-providers/src/lib.rs && \ - touch crates/openshell-router/src/lib.rs && \ - touch crates/openshell-bootstrap/src/lib.rs && \ - touch crates/openshell-policy/src/lib.rs && \ - touch crates/openshell-prover/src/lib.rs && \ - touch crates/openshell-tui/src/lib.rs - -# Build dependencies only (cached unless Cargo.toml/lock changes). -# sccache uses memcached in CI or the local disk cache mount for local dev. -# The cargo-target mount gives cargo a persistent target/ dir for incremental rebuilds. -RUN --mount=type=cache,id=cargo-registry-python-wheels-${TARGETARCH},sharing=locked,target=/root/.cargo/registry \ - --mount=type=cache,id=cargo-git-python-wheels-${TARGETARCH},sharing=locked,target=/root/.cargo/git \ - --mount=type=cache,id=cargo-target-python-wheels-${TARGETARCH}-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \ - --mount=type=cache,id=sccache-python-wheels-${TARGETARCH},sharing=locked,target=/tmp/sccache \ - . cross-build.sh && cargo_cross_build --release -p openshell-cli 2>/dev/null || true - -# Copy actual source code and Python packaging files. -COPY crates/ crates/ -COPY pyproject.toml README.md ./ -COPY python/ python/ - -# Touch source files to ensure they're rebuilt (not the cached dummy). -RUN touch crates/openshell-cli/src/main.rs \ - crates/openshell-cli/src/lib.rs \ - crates/openshell-bootstrap/src/lib.rs \ - crates/openshell-core/src/lib.rs \ - crates/openshell-providers/src/lib.rs \ - crates/openshell-router/src/lib.rs \ - crates/openshell-sandbox/src/main.rs \ - crates/openshell-server/src/main.rs \ - crates/openshell-core/build.rs \ - proto/*.proto - -# Declare version ARGs here (not earlier) so the git-hash-bearing values do not -# invalidate the expensive dependency-build layers above on every commit. -ARG OPENSHELL_CARGO_VERSION -ARG OPENSHELL_IMAGE_TAG -RUN --mount=type=cache,id=cargo-registry-python-wheels-${TARGETARCH},sharing=locked,target=/root/.cargo/registry \ - --mount=type=cache,id=cargo-git-python-wheels-${TARGETARCH},sharing=locked,target=/root/.cargo/git \ - --mount=type=cache,id=cargo-target-python-wheels-${TARGETARCH}-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \ - --mount=type=cache,id=sccache-python-wheels-${TARGETARCH},sharing=locked,target=/tmp/sccache \ - . cross-build.sh && \ - export_cross_env && \ - export RUSTC_WRAPPER=sccache && \ - export CARGO_BUILD_TARGET="$(rust_target)" && \ - if [ -n "${OPENSHELL_CARGO_VERSION:-}" ]; then \ - sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${OPENSHELL_CARGO_VERSION}"'"/}' Cargo.toml; \ - fi && \ - maturin build --release --target "${CARGO_BUILD_TARGET}" --out /wheels - -FROM scratch AS wheels -COPY --from=builder /wheels/*.whl / diff --git a/deploy/docker/Dockerfile.python-wheels-macos b/deploy/docker/Dockerfile.python-wheels-macos deleted file mode 100644 index 1825dd6276..0000000000 --- a/deploy/docker/Dockerfile.python-wheels-macos +++ /dev/null @@ -1,126 +0,0 @@ -# syntax=docker/dockerfile:1.6 - -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - - -ARG OSXCROSS_IMAGE=ghcr.io/crazy-max/osxcross:latest -ARG PYTHON_IMAGE=public.ecr.aws/docker/library/python -ARG PYTHON_VERSION=3.12 - -FROM ${OSXCROSS_IMAGE} AS osxcross - -FROM ${PYTHON_IMAGE}:${PYTHON_VERSION}-slim AS builder - -ARG TARGETARCH -ARG CARGO_TARGET_CACHE_SCOPE=default - -ENV PATH="/root/.cargo/bin:/usr/local/bin:/osxcross/bin:${PATH}" -ENV LD_LIBRARY_PATH="/osxcross/lib" - -COPY --from=osxcross /osxcross /osxcross - -RUN SDKROOT="$(echo /osxcross/SDK/MacOSX*.sdk)" && ln -sfn "${SDKROOT}" /osxcross/SDK/MacOSX.sdk - -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - ca-certificates \ - clang \ - cmake \ - curl \ - libclang-dev \ - libssl-dev \ - pkg-config \ - && rm -rf /var/lib/apt/lists/* - -# aws-lc-sys probes with --target=arm64-apple-macosx and clang then looks for -# arm64-apple-macosx-ld. Provide a linker alias to osxcross ld64. -RUN ln -sf /osxcross/bin/arm64-apple-darwin25.1-ld /usr/local/bin/arm64-apple-macosx-ld - -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.95.0 -RUN rustup target add aarch64-apple-darwin -RUN pip install --no-cache-dir maturin - -WORKDIR /build - -ENV CC_aarch64_apple_darwin=oa64-clang -ENV CXX_aarch64_apple_darwin=oa64-clang++ -ENV AR_aarch64_apple_darwin=aarch64-apple-darwin25.1-ar -ENV CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER=oa64-clang -ENV CARGO_TARGET_AARCH64_APPLE_DARWIN_AR=aarch64-apple-darwin25.1-ar -ENV SDKROOT=/osxcross/SDK/MacOSX.sdk -ENV MACOSX_DEPLOYMENT_TARGET=13.3 -ENV CFLAGS_aarch64_apple_darwin=--target=arm64-apple-macosx\ -mmacosx-version-min=13.3 -ENV CXXFLAGS_aarch64_apple_darwin=--target=arm64-apple-macosx\ -mmacosx-version-min=13.3 -ENV BINDGEN_EXTRA_CLANG_ARGS_aarch64_apple_darwin=--target=arm64-apple-macosx\ -isysroot\ ${SDKROOT} - -# Copy dependency manifests first for better caching. -COPY Cargo.toml Cargo.lock ./ -COPY crates/openshell-cli/Cargo.toml crates/openshell-cli/Cargo.toml -COPY crates/openshell-core/Cargo.toml crates/openshell-core/Cargo.toml -COPY crates/openshell-ocsf/Cargo.toml crates/openshell-ocsf/Cargo.toml -COPY crates/openshell-providers/Cargo.toml crates/openshell-providers/Cargo.toml -COPY crates/openshell-router/Cargo.toml crates/openshell-router/Cargo.toml -COPY crates/openshell-sandbox/Cargo.toml crates/openshell-sandbox/Cargo.toml -COPY crates/openshell-server/Cargo.toml crates/openshell-server/Cargo.toml -COPY crates/openshell-bootstrap/Cargo.toml crates/openshell-bootstrap/Cargo.toml -COPY crates/openshell-policy/Cargo.toml crates/openshell-policy/Cargo.toml -COPY crates/openshell-prover/Cargo.toml crates/openshell-prover/Cargo.toml -COPY crates/openshell-tui/Cargo.toml crates/openshell-tui/Cargo.toml -COPY crates/openshell-core/build.rs crates/openshell-core/build.rs -COPY proto/ proto/ - -# Create dummy source files to build dependencies. -RUN mkdir -p crates/openshell-cli/src crates/openshell-core/src crates/openshell-ocsf/src crates/openshell-policy/src crates/openshell-providers/src crates/openshell-prover/src crates/openshell-router/src crates/openshell-sandbox/src crates/openshell-server/src crates/openshell-bootstrap/src crates/openshell-tui/src && \ - echo "fn main() {}" > crates/openshell-cli/src/main.rs && \ - echo "fn main() {}" > crates/openshell-sandbox/src/main.rs && \ - echo "fn main() {}" > crates/openshell-server/src/main.rs && \ - touch crates/openshell-core/src/lib.rs && \ - touch crates/openshell-ocsf/src/lib.rs && \ - touch crates/openshell-providers/src/lib.rs && \ - touch crates/openshell-router/src/lib.rs && \ - touch crates/openshell-bootstrap/src/lib.rs && \ - touch crates/openshell-policy/src/lib.rs && \ - touch crates/openshell-prover/src/lib.rs && \ - touch crates/openshell-tui/src/lib.rs - -# Build dependencies only (cached unless Cargo.toml/lock changes). -RUN --mount=type=cache,id=cargo-registry-python-wheels-macos-${TARGETARCH},sharing=locked,target=/root/.cargo/registry \ - --mount=type=cache,id=cargo-git-python-wheels-macos-${TARGETARCH},sharing=locked,target=/root/.cargo/git \ - --mount=type=cache,id=cargo-target-python-wheels-macos-${TARGETARCH}-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \ - cargo build --release --target aarch64-apple-darwin -p openshell-cli 2>/dev/null || true - -# Copy actual source code and Python packaging files. -COPY crates/ crates/ -COPY providers/ providers/ -COPY pyproject.toml README.md ./ -COPY python/ python/ - -# Touch source files to ensure they're rebuilt (not the cached dummy). -# Touch build.rs and proto files to force proto code regeneration when the -# cargo target cache mount retains stale OUT_DIR artifacts from prior builds. -RUN touch crates/openshell-cli/src/main.rs \ - crates/openshell-cli/src/lib.rs \ - crates/openshell-bootstrap/src/lib.rs \ - crates/openshell-core/src/lib.rs \ - crates/openshell-providers/src/lib.rs \ - crates/openshell-router/src/lib.rs \ - crates/openshell-sandbox/src/main.rs \ - crates/openshell-server/src/main.rs \ - crates/openshell-core/build.rs \ - proto/*.proto - -# Declare version ARGs here (not earlier) so the git-hash-bearing values do not -# invalidate the expensive dependency-build layers above on every commit. -ARG OPENSHELL_CARGO_VERSION -ARG OPENSHELL_IMAGE_TAG -RUN --mount=type=cache,id=cargo-registry-python-wheels-macos-${TARGETARCH},sharing=locked,target=/root/.cargo/registry \ - --mount=type=cache,id=cargo-git-python-wheels-macos-${TARGETARCH},sharing=locked,target=/root/.cargo/git \ - --mount=type=cache,id=cargo-target-python-wheels-macos-${TARGETARCH}-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \ - if [ -n "${OPENSHELL_CARGO_VERSION:-}" ]; then \ - sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${OPENSHELL_CARGO_VERSION}"'"/}' Cargo.toml; \ - fi && \ - maturin build --release --target aarch64-apple-darwin --out /wheels - -FROM scratch AS wheels -COPY --from=builder /wheels/*.whl / diff --git a/docs/about/installation.mdx b/docs/about/installation.mdx index e464d3da42..a733a1b881 100644 --- a/docs/about/installation.mdx +++ b/docs/about/installation.mdx @@ -20,6 +20,12 @@ The script detects your operating system and installs the OpenShell CLI and gate You can also download release artifacts directly from the [OpenShell GitHub Releases](https://github.com/NVIDIA/OpenShell/releases) page. +The `openshell` package on PyPI provides the Python SDK only. It does not install the `openshell` CLI. Add the SDK to a Python project with: + +```shell +uv add openshell +``` + Use `openshell status` to confirm the CLI can reach the gateway. ## Supported Compute Drivers diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index ebd7a9880c..8a8b82046e 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -30,12 +30,6 @@ curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | The install script uses Homebrew, RPM, or a Debian package based on your machine. It starts the local gateway server after installation. -If you prefer [uv](https://docs.astral.sh/uv/): - -```shell -uv tool install -U openshell -``` - After installing the CLI, run `openshell --help` in your terminal to view the full CLI reference. diff --git a/docs/get-started/tutorials/docker-compose.mdx b/docs/get-started/tutorials/docker-compose.mdx index 4037c02995..677f34595b 100644 --- a/docs/get-started/tutorials/docker-compose.mdx +++ b/docs/get-started/tutorials/docker-compose.mdx @@ -72,13 +72,9 @@ curl -sf http://localhost:8080/healthz curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh ``` -**From PyPI (any platform with [uv](https://docs.astral.sh/uv/)):** - -```shell -uv tool install -U openshell -``` - +The `openshell` package on PyPI provides the Python SDK only and does not install the CLI. + On Windows without WSL, install the CLI inside a WSL 2 distribution (for example AlmaLinux or Ubuntu) and run all `openshell` commands from that distribution. diff --git a/flake.nix b/flake.nix index ec21dd3b8b..aaa8bc75c7 100644 --- a/flake.nix +++ b/flake.nix @@ -47,6 +47,7 @@ pkg-config # Coverage. lcov + uv ]; treefmtEval = treefmt-nix.lib.evalModule pkgs { projectRootFile = "flake.nix"; diff --git a/pyproject.toml b/pyproject.toml index fb8aac17a2..247a40b412 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,8 +2,8 @@ # SPDX-License-Identifier: Apache-2.0 [build-system] -requires = ["maturin>=1.5,<2.0"] -build-backend = "maturin" +requires = ["setuptools>=80", "setuptools-scm[simple]>=8"] +build-backend = "setuptools.build_meta" [project] name = "openshell" @@ -29,8 +29,6 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", - "Programming Language :: Rust", - "License :: OSI Approved :: Apache Software License", "Topic :: Security", "Topic :: Scientific/Engineering", "Topic :: Scientific/Engineering :: Artificial Intelligence", @@ -51,33 +49,23 @@ dev = [ "pytest-xdist>=3.0", "ruff>=0.4", "ty>=0.0.1a6", - "maturin>=1.5,<2.0", - "setuptools-scm>=8", "grpcio-tools>=1.60", "pyelftools>=0.30", "pyyaml==6.0.2", "types-pyyaml>=6.0.12.20260518", ] -[tool.uv] -# Don't try to install the root package with uv sync - use uv pip install . instead -# This is needed because maturin bin packages don't install the binary with editable installs -# (both `uv pip install -e .` and `maturin develop` skip binary installation) -# Use `uv pip install .` (without -e) to install the openshell CLI binary -package = false +[tool.setuptools] +package-dir = { "" = "python" } +include-package-data = false -[tool.maturin] -bindings = "bin" -manifest-path = "crates/openshell-cli/Cargo.toml" -python-source = "python" -module-name = "openshell" -# _proto/ stubs are gitignored; force them into the wheel so git-aware -# maturin builds don't drop them (package-relative paths). -include = [ - "openshell/_proto/*_pb2.py", - "openshell/_proto/*_pb2_grpc.py", - "openshell/_proto/*.pyi", -] +[tool.setuptools.packages.find] +where = ["python"] +include = ["openshell*"] +namespaces = false + +[tool.setuptools.package-data] +openshell = ["py.typed", "_proto/*.py", "_proto/*.pyi"] [tool.ruff] target-version = "py311" diff --git a/python/wheel_verifier_test.py b/python/wheel_verifier_test.py new file mode 100644 index 0000000000..777f348f73 --- /dev/null +++ b/python/wheel_verifier_test.py @@ -0,0 +1,143 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from zipfile import ZipFile + +import pytest + + +def _load_verifier_module(): + path = Path(__file__).resolve().parents[1] / "tasks/scripts/verify-python-wheel.py" + spec = importlib.util.spec_from_file_location("openshell_wheel_verifier", path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +verifier = _load_verifier_module() + + +def _wheel_files() -> set[str]: + files = { + "openshell/__init__.py", + "openshell/sandbox.py", + "openshell/py.typed", + "openshell/_proto/__init__.py", + } + for stem in ("datamodel", "inference", "openshell", "options", "sandbox"): + files.add(f"openshell/_proto/{stem}_pb2.py") + files.add(f"openshell/_proto/{stem}_pb2.pyi") + files.add(f"openshell/_proto/{stem}_pb2_grpc.py") + return files + + +def _write_wheel( + directory: Path, + *, + version: str = "1.2.3", + tag: str = "py3-none-any", + files: set[str] | None = None, + entry_points: str | None = None, +) -> Path: + wheel = directory / f"openshell-{version}-{tag}.whl" + dist_info = f"openshell-{version}.dist-info" + with ZipFile(wheel, "w") as archive: + for name in files if files is not None else _wheel_files(): + archive.writestr(name, "") + archive.writestr( + f"{dist_info}/METADATA", + f"Metadata-Version: 2.4\nName: openshell\nVersion: {version}\n", + ) + archive.writestr( + f"{dist_info}/WHEEL", + f"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: {tag}\n", + ) + if entry_points is not None: + archive.writestr(f"{dist_info}/entry_points.txt", entry_points) + return wheel + + +def test_accepts_pure_python_sdk_wheel(tmp_path: Path) -> None: + wheel = _write_wheel(tmp_path) + + assert verifier.verify_wheel(wheel, "1.2.3") == wheel + + +def test_requires_exactly_one_wheel_in_directory(tmp_path: Path) -> None: + _write_wheel(tmp_path, version="1.2.3") + _write_wheel(tmp_path, version="1.2.4") + + with pytest.raises(ValueError, match="expected exactly one wheel"): + verifier.verify_wheel(tmp_path) + + +def test_rejects_missing_generated_proto(tmp_path: Path) -> None: + files = _wheel_files() - {"openshell/_proto/sandbox_pb2.py"} + wheel = _write_wheel(tmp_path, files=files) + + with pytest.raises(ValueError, match=r"sandbox_pb2\.py"): + verifier.verify_wheel(wheel) + + +def test_rejects_bundled_cli(tmp_path: Path) -> None: + files = _wheel_files() | {"openshell-1.2.3.data/scripts/openshell"} + wheel = _write_wheel(tmp_path, files=files) + + with pytest.raises(ValueError, match="contains an openshell executable"): + verifier.verify_wheel(wheel) + + +def test_rejects_bundled_windows_cli(tmp_path: Path) -> None: + files = _wheel_files() | {"openshell-1.2.3.data/scripts/openshell.exe"} + wheel = _write_wheel(tmp_path, files=files) + + with pytest.raises(ValueError, match="contains an openshell executable"): + verifier.verify_wheel(wheel) + + +def test_rejects_openshell_console_script(tmp_path: Path) -> None: + wheel = _write_wheel( + tmp_path, + entry_points="[console_scripts]\nopenshell = openshell.cli:main\n", + ) + + with pytest.raises(ValueError, match="defines an openshell console script"): + verifier.verify_wheel(wheel) + + +def test_rejects_native_extension(tmp_path: Path) -> None: + files = _wheel_files() | {"openshell/_native.so"} + wheel = _write_wheel(tmp_path, files=files) + + with pytest.raises(ValueError, match="contains native files"): + verifier.verify_wheel(wheel) + + +def test_rejects_bytecode(tmp_path: Path) -> None: + files = _wheel_files() | {"openshell/__pycache__/sandbox.cpython-314.pyc"} + wheel = _write_wheel(tmp_path, files=files) + + with pytest.raises(ValueError, match="contains Python bytecode"): + verifier.verify_wheel(wheel) + + +def test_rejects_platform_wheel(tmp_path: Path) -> None: + wheel = _write_wheel(tmp_path, tag="cp311-cp311-manylinux_2_28_x86_64") + + with pytest.raises(ValueError, match="not platform-independent"): + verifier.verify_wheel(wheel) + + +def test_rejects_unexpected_version(tmp_path: Path) -> None: + wheel = _write_wheel(tmp_path) + + with pytest.raises(ValueError, match=r"expected version 1\.2\.4"): + verifier.verify_wheel(wheel, "1.2.4") diff --git a/tasks/python.toml b/tasks/python.toml index cdd0c8c4c1..9623bb226b 100644 --- a/tasks/python.toml +++ b/tasks/python.toml @@ -4,193 +4,35 @@ # Python development, build, lint, and format tasks ["python:dev"] -description = "Install Python package in development mode (builds CLI binary)" +description = "Install the Python SDK in development mode" depends = ["python:proto"] -run = "uv sync --group dev && uv pip install ." +run = "uv sync --group dev" hide = true ["build:python:wheel"] -description = "Build Python wheel with CLI binary (native)" +description = "Build the pure-Python SDK wheel" depends = ["python:proto"] -run = "uv run maturin build --release" -hide = true - -["python:build"] -description = "Alias for build:python:wheel" -depends = ["build:python:wheel"] -hide = true - -["build:python:wheel:linux"] -description = "Build a Linux Python wheel natively on the current host" -depends = ["python:proto"] -run = ''' +run = """ #!/usr/bin/env bash set -euo pipefail -WHEEL_OUTPUT_DIR=${WHEEL_OUTPUT_DIR:?Set WHEEL_OUTPUT_DIR to a per-platform wheel output directory} -EXPECTED_HOST_ARCH=${EXPECTED_HOST_ARCH:?Set EXPECTED_HOST_ARCH to amd64 or arm64} -ORIGINAL_CARGO_TOML="" - -normalize_arch() { - case "$1" in - x86_64|amd64) echo amd64 ;; - aarch64|arm64) echo arm64 ;; - *) echo "$1" ;; - esac -} - -cleanup() { - if [ -n "$ORIGINAL_CARGO_TOML" ] && [ -f "$ORIGINAL_CARGO_TOML" ]; then - cp "$ORIGINAL_CARGO_TOML" Cargo.toml - rm -f "$ORIGINAL_CARGO_TOML" - fi -} - -trap cleanup EXIT - -if [ "$(uname -s)" != "Linux" ]; then - echo "build:python:wheel:linux requires a Linux host" >&2 - exit 1 -fi - -HOST_ARCH=$(normalize_arch "$(uname -m)") -EXPECTED_HOST_ARCH=$(normalize_arch "$EXPECTED_HOST_ARCH") -if [ "$HOST_ARCH" != "$EXPECTED_HOST_ARCH" ]; then - echo "build:python:wheel:linux expected ${EXPECTED_HOST_ARCH} host, got ${HOST_ARCH}" >&2 - exit 1 -fi - -# Avoid failures when RUSTC_WRAPPER is set to an unresolved `sccache` binary. -if command -v sccache >/dev/null 2>&1; then - export RUSTC_WRAPPER="$(command -v sccache)" -else - unset RUSTC_WRAPPER || true -fi - -if [ -n "${OPENSHELL_CARGO_VERSION:-}" ]; then - ORIGINAL_CARGO_TOML=$(mktemp) - cp Cargo.toml "$ORIGINAL_CARGO_TOML" - sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${OPENSHELL_CARGO_VERSION}"'"/}' Cargo.toml -fi - +WHEEL_OUTPUT_DIR=${WHEEL_OUTPUT_DIR:-target/wheels} rm -rf "$WHEEL_OUTPUT_DIR" mkdir -p "$WHEEL_OUTPUT_DIR" -uv run maturin build --release --out "$WHEEL_OUTPUT_DIR" - -ls -la "$WHEEL_OUTPUT_DIR"/*.whl -''' -hide = true - -["build:python:wheel:linux:amd64"] -description = "Build Python wheel for Linux amd64 natively" -depends = ["EXPECTED_HOST_ARCH=amd64 WHEEL_OUTPUT_DIR=target/wheels/linux-amd64 build:python:wheel:linux"] -hide = true - -["python:build:linux:amd64"] -description = "Alias for build:python:wheel:linux:amd64" -depends = ["build:python:wheel:linux:amd64"] -hide = true - -["build:python:wheel:linux:arm64"] -description = "Build Python wheel for Linux arm64 natively" -depends = ["EXPECTED_HOST_ARCH=arm64 WHEEL_OUTPUT_DIR=target/wheels/linux-arm64 build:python:wheel:linux"] -hide = true - -["python:build:linux:arm64"] -description = "Alias for build:python:wheel:linux:arm64" -depends = ["build:python:wheel:linux:arm64"] -hide = true +uv build --wheel --out-dir "$WHEEL_OUTPUT_DIR" -["build:python:wheel:macos"] -description = "Build Python wheel for macOS arm64" -depends = ["python:proto"] -run = """ -#!/usr/bin/env bash -set -euo pipefail - -if [ "$(uname -s)" = "Darwin" ] && [ -z "${OPENSHELL_CARGO_VERSION:-}" ]; then - # Avoid failures when RUSTC_WRAPPER is set to an unresolved `sccache` binary. - if command -v sccache >/dev/null 2>&1; then - export RUSTC_WRAPPER="$(command -v sccache)" - else - unset RUSTC_WRAPPER || true - fi - - mkdir -p target/wheels - - rustup target add aarch64-apple-darwin - - uv run maturin build --release --target aarch64-apple-darwin --out target/wheels -else - echo "Building macOS wheel via Docker cross-toolchain" - mise run build:python:wheel:macos:docker -fi - -ls -la target/wheels/*.whl -""" -hide = true - -["python:build:macos"] -description = "Alias for build:python:wheel:macos" -depends = ["build:python:wheel:macos"] -hide = true - -["build:python:wheel:macos:docker"] -description = "Build Python wheel for macOS arm64 from Docker" -depends = ["python:proto"] -run = """ -#!/usr/bin/env bash -set -euo pipefail - -source tasks/scripts/container-engine.sh - -sha256_16() { - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$1" | awk '{print substr($1, 1, 16)}' - else - shasum -a 256 "$1" | awk '{print substr($1, 1, 16)}' - fi -} - -sha256_16_stdin() { - if command -v sha256sum >/dev/null 2>&1; then - sha256sum | awk '{print substr($1, 1, 16)}' - else - shasum -a 256 | awk '{print substr($1, 1, 16)}' - fi -} - -CARGO_VERSION=${OPENSHELL_CARGO_VERSION:-} -OSXCROSS_IMAGE_REF=${OSXCROSS_IMAGE:-crazymax/osxcross:latest} -if [ -z "$CARGO_VERSION" ] && [ -n "${CI:-}" ]; then - CARGO_VERSION=$(uv run python tasks/scripts/release.py get-version --cargo) +VERIFY_ARGS=() +if [ -n "${SETUPTOOLS_SCM_PRETEND_VERSION_FOR_OPENSHELL:-}" ]; then + VERIFY_ARGS+=(--expected-version "$SETUPTOOLS_SCM_PRETEND_VERSION_FOR_OPENSHELL") fi - -LOCK_HASH=$(sha256_16 Cargo.lock) -RUST_SCOPE=${RUST_TOOLCHAIN_SCOPE:-rustup-1.95.0} -CACHE_SCOPE_INPUT="v1|python-wheels-macos|base|${LOCK_HASH}|${RUST_SCOPE}" -CARGO_TARGET_CACHE_SCOPE=$(printf '%s' "$CACHE_SCOPE_INPUT" | sha256_16_stdin) - -mkdir -p target/wheels - -ce build \ - -f deploy/docker/Dockerfile.python-wheels-macos \ - --target wheels \ - --build-arg "OSXCROSS_IMAGE=${OSXCROSS_IMAGE_REF}" \ - --build-arg "CARGO_TARGET_CACHE_SCOPE=${CARGO_TARGET_CACHE_SCOPE}" \ - ${CARGO_VERSION:+--build-arg "OPENSHELL_CARGO_VERSION=${CARGO_VERSION}"} \ - ${OPENSHELL_IMAGE_TAG:+--build-arg "OPENSHELL_IMAGE_TAG=${OPENSHELL_IMAGE_TAG}"} \ - --output type=local,dest=target/wheels \ - . - -ls -la target/wheels/*macosx*arm64.whl +uv run --no-project python tasks/scripts/verify-python-wheel.py "$WHEEL_OUTPUT_DIR" "${VERIFY_ARGS[@]}" """ hide = true -["python:build:macos:docker"] -description = "Alias for build:python:wheel:macos:docker" -depends = ["build:python:wheel:macos:docker"] +["python:build"] +description = "Alias for build:python:wheel" +depends = ["build:python:wheel"] hide = true ["python:lint"] diff --git a/tasks/scripts/verify-python-wheel.py b/tasks/scripts/verify-python-wheel.py new file mode 100644 index 0000000000..6234c0cdb6 --- /dev/null +++ b/tasks/scripts/verify-python-wheel.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import argparse +import sys +from configparser import ConfigParser +from configparser import Error as ConfigParserError +from email.parser import Parser +from pathlib import Path +from zipfile import BadZipFile, ZipFile + +PROTO_STEMS = ("datamodel", "inference", "openshell", "options", "sandbox") +BYTECODE_SUFFIXES = (".pyc", ".pyo") +NATIVE_SUFFIXES = (".dll", ".dylib", ".exe", ".pyd", ".so") + + +def _find_wheel(path: Path) -> Path: + if path.is_file(): + return path + + wheels = sorted(path.glob("*.whl")) + if len(wheels) != 1: + raise ValueError(f"expected exactly one wheel in {path}, found {len(wheels)}") + return wheels[0] + + +def _required_files() -> set[str]: + files = { + "openshell/__init__.py", + "openshell/sandbox.py", + "openshell/py.typed", + "openshell/_proto/__init__.py", + } + for stem in PROTO_STEMS: + files.add(f"openshell/_proto/{stem}_pb2.py") + files.add(f"openshell/_proto/{stem}_pb2.pyi") + files.add(f"openshell/_proto/{stem}_pb2_grpc.py") + return files + + +def _single_member(names: set[str], suffix: str) -> str: + matches = [name for name in names if name.endswith(suffix)] + if len(matches) != 1: + raise ValueError(f"expected exactly one {suffix} file, found {len(matches)}") + return matches[0] + + +def verify_wheel(path: Path, expected_version: str | None = None) -> Path: + wheel = _find_wheel(path) + if not wheel.name.endswith("-py3-none-any.whl"): + raise ValueError(f"wheel is not platform-independent: {wheel.name}") + + with ZipFile(wheel) as archive: + names = set(archive.namelist()) + missing = sorted(_required_files() - names) + if missing: + raise ValueError(f"wheel is missing required files: {', '.join(missing)}") + + scripts = sorted( + name + for name in names + if not name.endswith("/") + and Path(name).name in {"openshell", "openshell.exe"} + ) + if scripts: + raise ValueError( + f"wheel contains an openshell executable: {', '.join(scripts)}" + ) + + entry_points = [ + name for name in names if name.endswith(".dist-info/entry_points.txt") + ] + if len(entry_points) > 1: + raise ValueError( + f"expected at most one entry_points.txt file, found {len(entry_points)}" + ) + if entry_points: + parser = ConfigParser(interpolation=None) + try: + parser.read_string(archive.read(entry_points[0]).decode()) + except (ConfigParserError, UnicodeDecodeError) as error: + raise ValueError( + "wheel contains invalid entry point metadata" + ) from error + if parser.has_option("console_scripts", "openshell"): + raise ValueError("wheel defines an openshell console script") + + bytecode_files = sorted( + name for name in names if name.endswith(BYTECODE_SUFFIXES) + ) + if bytecode_files: + raise ValueError( + f"wheel contains Python bytecode: {', '.join(bytecode_files)}" + ) + + native_files = sorted(name for name in names if name.endswith(NATIVE_SUFFIXES)) + if native_files: + raise ValueError(f"wheel contains native files: {', '.join(native_files)}") + + metadata_name = _single_member(names, ".dist-info/METADATA") + metadata = Parser().parsestr(archive.read(metadata_name).decode()) + if metadata["Name"] != "openshell": + raise ValueError(f"unexpected distribution name: {metadata['Name']}") + + version = metadata["Version"] + filename_version = wheel.name.removesuffix("-py3-none-any.whl").removeprefix( + "openshell-" + ) + if version != filename_version: + raise ValueError( + f"wheel filename version {filename_version} does not match metadata {version}" + ) + if expected_version is not None and version != expected_version: + raise ValueError(f"expected version {expected_version}, found {version}") + + wheel_name = _single_member(names, ".dist-info/WHEEL") + wheel_metadata = Parser().parsestr(archive.read(wheel_name).decode()) + if wheel_metadata.get_all("Tag", []) != ["py3-none-any"]: + raise ValueError( + f"unexpected wheel tags: {wheel_metadata.get_all('Tag', [])}" + ) + if wheel_metadata["Root-Is-Purelib"] != "true": + raise ValueError("wheel is not marked as pure Python") + + return wheel + + +def main() -> int: + parser = argparse.ArgumentParser(description="Verify the OpenShell Python wheel") + parser.add_argument( + "path", type=Path, help="Wheel file or directory containing one wheel" + ) + parser.add_argument("--expected-version") + args = parser.parse_args() + + try: + wheel = verify_wheel(args.path, args.expected_version) + except (BadZipFile, OSError, ValueError) as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + print(f"Verified {wheel}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tasks/test.toml b/tasks/test.toml index a796ea67b4..e3328d12bd 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -18,8 +18,8 @@ depends = [ ["test:docs-website"] description = "Test the docs-website sync script" -# --no-project skips the workspace (maturin) build; --with supplies pytest and -# the script's runtime dep (PyYAML), which live outside the project env. +# --no-project skips installing the OpenShell package; --with supplies pytest +# and the script's runtime dependency (PyYAML), which lives outside the project env. run = "uv run --no-project --with pytest --with pyyaml pytest tasks/scripts/sync_docs_website_test.py" ["test:sbom"] diff --git a/uv.lock b/uv.lock index 1e86bc0df2..b12d0c0fcd 100644 --- a/uv.lock +++ b/uv.lock @@ -302,30 +302,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] -[[package]] -name = "maturin" -version = "1.11.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a4/84/bfed8cc10e2d8b6656cf0f0ca6609218e6fcb45a62929f5094e1063570f7/maturin-1.11.5.tar.gz", hash = "sha256:7579cf47640fb9595a19fe83a742cbf63203f0343055c349c1cab39045a30c29", size = 226885, upload-time = "2026-01-09T11:06:13.801Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/6c/3443d2f8c6d4eae5fc7479cd4053542aff4c1a8566d0019d0612d241b15a/maturin-1.11.5-py3-none-linux_armv6l.whl", hash = "sha256:edd1d4d35050ea2b9ef42aa01e87fe019a1e822940346b35ccb973e0aa8f6d82", size = 8845897, upload-time = "2026-01-09T11:06:17.327Z" }, - { url = "https://files.pythonhosted.org/packages/c5/03/abf1826d8aebc0d47ef6d21bdd752d98d63ac4372ad2b115db9cd5176229/maturin-1.11.5-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:2a596eab137cb3e169b97e89a739515abfa7a8755e2e5f0fc91432ef446f74f4", size = 17233855, upload-time = "2026-01-09T11:06:04.272Z" }, - { url = "https://files.pythonhosted.org/packages/90/a1/5ad62913271724035a7e4bcf796d7c95b4119317ae5f8cb034844aa99bc4/maturin-1.11.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1c27a2eb47821edf26c75d100b3150b52dca2c1a5f074d7514af06f7a7acb9d5", size = 8881776, upload-time = "2026-01-09T11:06:10.24Z" }, - { url = "https://files.pythonhosted.org/packages/c6/66/997974b44f8d3de641281ec04fbf5b6ca821bdc8291a2fa73305978db74d/maturin-1.11.5-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:f1320dacddcd3aa84a4bdfc77ee6fdb60e4c3835c853d7eb79c09473628b0498", size = 8870347, upload-time = "2026-01-09T11:06:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/58/e0/c8fa042daf0608cc2e9a59b6df3a9e287bfc7f229136f17727f4118bac2d/maturin-1.11.5-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:ffe7418834ff3b4a6c987187b7abb85ba033f4733e089d77d84e2de87057b4e7", size = 9291396, upload-time = "2026-01-09T11:06:02.05Z" }, - { url = "https://files.pythonhosted.org/packages/99/af/9d3edc8375efc8d435d5f24794bc4de234d4e743447592da970d53b31361/maturin-1.11.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:c739b243d012386902f112ea63a54a94848932b70ae3565fa5e121fd1c0200e0", size = 8827831, upload-time = "2026-01-09T11:06:19.523Z" }, - { url = "https://files.pythonhosted.org/packages/8a/12/cc341f6abbf9005f90935a4ee5dc7b30e2df7d1bb90b96d48b756b2c0ee7/maturin-1.11.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:8127d2cd25950bacbcdc8a2ec6daab1d4d27200f7d73964392680ad64d27f7f0", size = 8718895, upload-time = "2026-01-09T11:06:21.617Z" }, - { url = "https://files.pythonhosted.org/packages/76/17/654a59c66287e287373f2a0086e4fc8a23f0545a81c2bd6e324db26a5801/maturin-1.11.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:2a4e872fb78e77748217084ffeb59de565d08a86ccefdace054520aaa7b66db4", size = 11384741, upload-time = "2026-01-09T11:06:15.261Z" }, - { url = "https://files.pythonhosted.org/packages/2e/da/7118de648182971d723ea99d79c55007f96cdafc95f5322cc1ad15f6683e/maturin-1.11.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2079447967819b5cf615e5b5b99a406d662effdc8d6afd493dcd253c6afc3707", size = 9423814, upload-time = "2026-01-09T11:05:57.242Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8f/be14395c6e23b19ddaa0c171e68915bdcd1ef61ad1f411739c6721196903/maturin-1.11.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:50f6c668c1d5d4d4dc1c3ffec7b4270dab493e5b2368f8e4213f4bcde6a50eea", size = 9104378, upload-time = "2026-01-09T11:05:59.835Z" }, - { url = "https://files.pythonhosted.org/packages/77/83/53ea82a2f42a03930ea5545673d11a4ef49bb886827353a701f41a5f11c4/maturin-1.11.5-py3-none-win32.whl", hash = "sha256:49f85ce6cbe478e9743ecddd6da2964afc0ded57013aa4d054256be702d23d40", size = 7738696, upload-time = "2026-01-09T11:06:06.651Z" }, - { url = "https://files.pythonhosted.org/packages/3c/41/353a26d49aa80081c514a6354d429efbecedb90d0153ec598cece3baa607/maturin-1.11.5-py3-none-win_amd64.whl", hash = "sha256:70d3e5beffb9ef9dfae5f3c1a7eeb572091505eb8cb076e9434518df1c42a73b", size = 9029838, upload-time = "2026-01-09T11:05:54.543Z" }, - { url = "https://files.pythonhosted.org/packages/15/67/c94f8f5440bc42d54113a2d99de0d6107f06b5a33f31823e52b2715d856f/maturin-1.11.5-py3-none-win_arm64.whl", hash = "sha256:9348f7f0a346108e0c96e6719be91da4470bd43c15802435e9f4157f5cca43d4", size = 7624029, upload-time = "2026-01-09T11:06:08.728Z" }, -] - [[package]] name = "openshell" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "cloudpickle" }, { name = "grpcio" }, @@ -336,7 +315,6 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "grpcio-tools" }, - { name = "maturin" }, { name = "pyelftools" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -344,7 +322,6 @@ dev = [ { name = "pytest-xdist" }, { name = "pyyaml" }, { name = "ruff" }, - { name = "setuptools-scm" }, { name = "ty" }, { name = "types-pyyaml" }, ] @@ -360,7 +337,6 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "grpcio-tools", specifier = ">=1.60" }, - { name = "maturin", specifier = ">=1.5,<2.0" }, { name = "pyelftools", specifier = ">=0.30" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-asyncio", specifier = ">=0.23" }, @@ -368,7 +344,6 @@ dev = [ { name = "pytest-xdist", specifier = ">=3.0" }, { name = "pyyaml", specifier = "==6.0.2" }, { name = "ruff", specifier = ">=0.4" }, - { name = "setuptools-scm", specifier = ">=8" }, { name = "ty", specifier = ">=0.0.1a6" }, { name = "types-pyyaml", specifier = ">=6.0.12.20260518" }, ] @@ -550,19 +525,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" }, ] -[[package]] -name = "setuptools-scm" -version = "9.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, - { name = "setuptools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7b/b1/19587742aad604f1988a8a362e660e8c3ac03adccdb71c96d86526e5eb62/setuptools_scm-9.2.2.tar.gz", hash = "sha256:1c674ab4665686a0887d7e24c03ab25f24201c213e82ea689d2f3e169ef7ef57", size = 203385, upload-time = "2025-10-19T22:08:05.608Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/ea/ac2bf868899d0d2e82ef72d350d97a846110c709bacf2d968431576ca915/setuptools_scm-9.2.2-py3-none-any.whl", hash = "sha256:30e8f84d2ab1ba7cb0e653429b179395d0c33775d54807fc5f1dd6671801aef7", size = 62975, upload-time = "2025-10-19T22:08:04.007Z" }, -] - [[package]] name = "tomli" version = "2.4.1" From e2ca9cb890ee11c8e18f194a32c76bdb345b6224 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Tue, 25 Aug 2026 14:01:29 +0000 Subject: [PATCH 012/505] fix(test-guest): pin HVF runtime dependencies (#2924) Signed-off-by: Evan Lezar --- flake.lock | 17 +++++++++++++++++ flake.nix | 11 ++++++++++- nix/test-guest/README.md | 6 ++++++ nix/test-guest/default.nix | 12 ++++++++---- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/flake.lock b/flake.lock index 746d9b48a4..b5861792ac 100644 --- a/flake.lock +++ b/flake.lock @@ -34,10 +34,27 @@ "type": "github" } }, + "nixpkgs-test-guest": { + "locked": { + "lastModified": 1785318670, + "narHash": "sha256-dN6Ou5x/+23FZLEpYP3IffO+NyJFzUlGumt1uu3MMaY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0954f7ee2f6bb3dc7d4e3d0d8bcb8fd4bde4cfc5", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0954f7ee2f6bb3dc7d4e3d0d8bcb8fd4bde4cfc5", + "type": "github" + } + }, "root": { "inputs": { "flake-utils": "flake-utils", "nixpkgs": "nixpkgs", + "nixpkgs-test-guest": "nixpkgs-test-guest", "rust-overlay": "rust-overlay", "treefmt-nix": "treefmt-nix" } diff --git a/flake.nix b/flake.nix index aaa8bc75c7..9491ebeae3 100644 --- a/flake.nix +++ b/flake.nix @@ -14,6 +14,9 @@ inputs = { flake-utils.url = "github:numtide/flake-utils"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + # Keep the QEMU and OVMF runtime used by Apple Silicon test guests on a + # known-good release. Development shells and cross toolchains use nixpkgs. + nixpkgs-test-guest.url = "github:NixOS/nixpkgs/0954f7ee2f6bb3dc7d4e3d0d8bcb8fd4bde4cfc5"; rust-overlay = { url = "github:oxalica/rust-overlay"; inputs.nixpkgs.follows = "nixpkgs"; @@ -29,6 +32,7 @@ { flake-utils, nixpkgs, + nixpkgs-test-guest, treefmt-nix, rust-overlay, ... @@ -40,6 +44,7 @@ inherit system; overlays = [ (import rust-overlay) ]; }; + testGuestPkgs = import nixpkgs-test-guest { inherit system; }; commonDevShellPackages = with pkgs; [ # Assemble Debian artifacts on macOS and Linux. dpkg @@ -56,7 +61,11 @@ rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; z3-static = pkgs.callPackage ./nix/pkgs/z3-static.nix { }; aws-lc-static = pkgs.callPackage ./nix/pkgs/aws-lc-static.nix { }; - testGuest = import ./nix/test-guest { inherit pkgs; }; + testGuest = import ./nix/test-guest { + inherit pkgs; + qemuPkgs = testGuestPkgs; + firmwarePkgs = testGuestPkgs; + }; in { apps.test-guest = testGuest.app; diff --git a/nix/test-guest/README.md b/nix/test-guest/README.md index ae28452056..ca62f893e6 100644 --- a/nix/test-guest/README.md +++ b/nix/test-guest/README.md @@ -16,6 +16,12 @@ This prototype uses Nix, QEMU, and Ansible to boot and configure disposable Linu The first run downloads the selected cloud image and VM runtime. Nix reuses those immutable inputs on later runs, while each guest starts from a fresh writable overlay. +On Apple Silicon, test guests deliberately take QEMU 11.0.2 and its matching OVMF +firmware from a separately pinned Nixpkgs revision. All other guest-runtime tools, +development dependencies, and cross-toolchain dependencies continue to use the main +current Nixpkgs input. This avoids a QEMU 11.1 HVF guest boot regression. Update +this pair only after validating an ARM64 Ubuntu guest boot with HVF. + ## Directory structure ```text diff --git a/nix/test-guest/default.nix b/nix/test-guest/default.nix index 2cfc772279..72937cfec2 100644 --- a/nix/test-guest/default.nix +++ b/nix/test-guest/default.nix @@ -3,13 +3,17 @@ # PROTOTYPE: Composable distro VMs for installing and exercising artifacts. -{ pkgs }: +{ + pkgs, + qemuPkgs ? pkgs, + firmwarePkgs ? pkgs, +}: let isAarch64 = pkgs.stdenv.hostPlatform.isAarch64; isDarwin = pkgs.stdenv.hostPlatform.isDarwin; architecture = if isAarch64 then "aarch64" else "x86_64"; - qemu = pkgs.qemu.override { hostCpuOnly = true; }; + qemu = qemuPkgs.qemu.override { hostCpuOnly = true; }; qemuBinary = if isAarch64 then "${qemu}/bin/qemu-system-aarch64" else "${qemu}/bin/qemu-system-x86_64"; @@ -75,8 +79,8 @@ let export OPENSHELL_TEST_GUEST_RUNNER=${./run.sh} export TEST_GUEST_BASH=${pkgs.bash}/bin/bash export TEST_GUEST_QEMU=${qemuBinary} - export TEST_GUEST_FIRMWARE_CODE=${pkgs.OVMF.firmware} - export TEST_GUEST_FIRMWARE_VARS=${pkgs.OVMF.variables} + export TEST_GUEST_FIRMWARE_CODE=${firmwarePkgs.OVMF.firmware} + export TEST_GUEST_FIRMWARE_VARS=${firmwarePkgs.OVMF.variables} export TEST_GUEST_MACHINE=${if isAarch64 then "virt" else "q35"} export TEST_GUEST_ACCELERATOR=${if isDarwin then "hvf" else "kvm"} export TEST_GUEST_ARCHITECTURE=${architecture} From fb6610df3926e5f86ae40e1306bbc5847775e786 Mon Sep 17 00:00:00 2001 From: alangou Date: Tue, 25 Aug 2026 14:36:15 +0000 Subject: [PATCH 013/505] feat(build): embed auditable Rust dependency metadata (#2734) Signed-off-by: Adrien Langou --- .agents/skills/sbom/SKILL.md | 24 +++++++++++++++++- .github/workflows/docker-build.yml | 6 +++++ .github/workflows/release-dev.yml | 2 ++ .github/workflows/release-tag.yml | 2 ++ .github/workflows/rust-native-build.yml | 31 +++++++++++++++++++++++- architecture/build.md | 21 ++++++++++++++++ mise.lock | 24 ++++++++++++++++++ mise.toml | 1 + tasks/scripts/stage-prebuilt-binaries.sh | 21 ++++++++++++++-- 9 files changed, 128 insertions(+), 4 deletions(-) diff --git a/.agents/skills/sbom/SKILL.md b/.agents/skills/sbom/SKILL.md index 5d62dde0ba..852259293d 100644 --- a/.agents/skills/sbom/SKILL.md +++ b/.agents/skills/sbom/SKILL.md @@ -9,15 +9,37 @@ Generate CycloneDX SBOMs, resolve missing licenses, and export to CSV for compli ## Overview -The OpenShell SBOM tooling produces CycloneDX JSON SBOMs using Syft, resolves missing or hash-based licenses by querying public registries (crates.io, npm, PyPI), and exports the results to CSV for stakeholder review. +The OpenShell SBOM tooling produces source-tree CycloneDX JSON SBOMs using Syft, resolves missing or hash-based licenses by querying public registries (crates.io, npm, PyPI), and exports the results to CSV for stakeholder review. SBOMs are **release artifacts only** -- they are generated on demand and not committed to the repository. Output lands in `deploy/sbom/output/` (gitignored). +Release Dev and Release Tag image builds separately embed cargo-auditable metadata in the staged gateway and supervisor binaries. This metadata describes the binary's Rust dependency graph and lets Syft discover Cargo packages from the binary itself. It is not a complete image SBOM and is not an OCI SBOM attestation; publishing such an attestation remains separate work. + ## Prerequisites - `mise install` has been run (installs Syft and other tools) - The repository is checked out at the root +## Inspecting an Auditable Image Binary + +Opt into auditable metadata when staging a local image binary: + +```bash +OPENSHELL_AUDITABLE=1 PREBUILT_ARCH=amd64 \ + tasks/scripts/stage-prebuilt-binaries.sh gateway +``` + +Scan the staged binary rather than the source tree: + +```bash +mise x -- syft \ + "file:deploy/docker/.build/prebuilt-binaries/amd64/openshell-gateway" \ + -o cyclonedx-json +``` + +This output is limited to packages Syft discovers from that binary. Use +`mise run sbom` for the broader source-tree license-compliance inventory. + ## Workflow 1: Full SBOM Generation (One Command) ```bash diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index d0d600e58b..2e7abe2008 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -32,6 +32,11 @@ on: required: false type: string default: "" + auditable: + description: "Embed cargo-auditable dependency metadata in image binaries" + required: false + type: boolean + default: false image-tag: description: "Image tag base to build/push (defaults to the GitHub SHA)" required: false @@ -177,6 +182,7 @@ jobs: image-tag: ${{ needs.resolve.outputs.image_tag_base }} checkout-ref: ${{ inputs['checkout-ref'] }} features: ${{ needs.resolve.outputs.features }} + auditable: ${{ inputs.auditable }} artifact-name: ${{ needs.resolve.outputs.artifact_prefix }}-linux-${{ matrix.arch }} secrets: inherit diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 1df404d052..500fcb4be6 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -59,6 +59,7 @@ jobs: with: component: gateway cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + auditable: true build-supervisor: needs: [compute-versions] @@ -66,6 +67,7 @@ jobs: with: component: supervisor cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + auditable: true e2e: needs: [build-gateway, build-supervisor] diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index e0d19a46ec..74be300b0c 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -81,6 +81,7 @@ jobs: cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} image-tag: ${{ needs.compute-versions.outputs.source_sha }} checkout-ref: ${{ inputs.tag || github.ref }} + auditable: true build-supervisor: needs: [compute-versions] @@ -90,6 +91,7 @@ jobs: cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} image-tag: ${{ needs.compute-versions.outputs.source_sha }} checkout-ref: ${{ inputs.tag || github.ref }} + auditable: true e2e: needs: [compute-versions, build-gateway, build-supervisor] diff --git a/.github/workflows/rust-native-build.yml b/.github/workflows/rust-native-build.yml index f024cacc50..3496f4779f 100644 --- a/.github/workflows/rust-native-build.yml +++ b/.github/workflows/rust-native-build.yml @@ -40,6 +40,11 @@ on: required: false type: string default: "" + auditable: + description: "Embed cargo-auditable dependency metadata in the binary" + required: false + type: boolean + default: false retention-days: description: "Artifact retention period" required: false @@ -265,6 +270,7 @@ jobs: mise x -- rustup target add "${{ steps.target.outputs.target }}" cargo_cmd=(cargo build) + cargo_env=() build_target="${{ steps.target.outputs.target }}" args=() @@ -278,6 +284,13 @@ jobs: # accepts -static for *-linux-gnu and links dynamically anyway. export RUSTFLAGS="${RUSTFLAGS:-} -C target-feature=+crt-static" fi + if [[ "${{ inputs.auditable }}" == "true" ]]; then + cargo_cmd=("${cargo_cmd[0]}" auditable "${cargo_cmd[@]:1}") + # mise.toml injects RUSTC_WRAPPER=sccache. Unset it after mise has + # constructed the environment so it cannot wrap cargo-auditable's + # RUSTC_WORKSPACE_WRAPPER and misidentify that wrapper as rustc. + cargo_env=(env -u RUSTC_WRAPPER) + fi args+=( --release --target "$build_target" @@ -290,7 +303,7 @@ jobs: if [[ -n "${{ steps.version.outputs.cargo_version }}" ]]; then export GIT_DIR=/nonexistent fi - mise x -- "${cargo_cmd[@]}" "${args[@]}" + mise x -- "${cargo_env[@]}" "${cargo_cmd[@]}" "${args[@]}" - name: Verify packaged binary run: | @@ -307,6 +320,22 @@ jobs: exit 1 fi + - name: Verify auditable dependency metadata + if: inputs.auditable + run: | + set -euo pipefail + BIN="target/${{ steps.target.outputs.target }}/release/${{ steps.target.outputs.binary }}" + cargo_packages="$( + SYFT_CHECK_FOR_APP_UPDATE=false \ + mise x -- syft "file:$BIN" -o cyclonedx-json | + jq '[.components[]? | select((.purl // "") | startswith("pkg:cargo/"))] | length' + )" + if [[ "$cargo_packages" -eq 0 ]]; then + echo "Syft did not decode any Cargo packages from $BIN" >&2 + exit 1 + fi + echo "auditable dependency metadata: $cargo_packages Cargo packages" + - name: Verify glibc symbol floor if: inputs.component == 'gateway' run: | diff --git a/architecture/build.md b/architecture/build.md index bc9eb9a1d0..7daf4a7c66 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -131,6 +131,27 @@ step via the `rust-native-build.yml` workflow (per-architecture, per-component) and uploads the result as an artifact that the image build job downloads back into the staging directory before running Buildx. +Gateway and supervisor binaries staged into 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 into the +binary. That section holds data rather than symbols, so it survives the +workspace's `strip = true` release profile, and Syft can catalog the crates +present in image binaries instead of inferring them from the source tree. This +is a different artifact from the source SBOM produced by `syft dir:.` in +`tasks/sbom.toml`, which describes the checkout, and from an OCI SBOM +attestation, which remains out of scope. + +Only release image builds are auditable. `docker-build.yml` and +`rust-native-build.yml` take an `auditable` input that defaults to false, so PR +and E2E image builds and standalone release artifacts remain non-auditable. The +CI image gains the pinned `cargo-auditable` tool through `mise install --locked` +but ships no auditable OpenShell binary of its own. Local staging opts in with +`OPENSHELL_AUDITABLE=1`. sccache's `RUSTC_WRAPPER` is unset only around auditable +builds, because it would otherwise wrap `cargo-auditable`'s workspace wrapper and +be misidentified as `rustc`. Auditable builds are verified by scanning the built +binary with Syft and requiring at least one decoded Cargo package; the check runs +only for those builds. + Runtime layout: - **Gateway**: `gcr.io/distroless/cc-debian13:nonroot` base, GNU-linked binary at diff --git a/mise.lock b/mise.lock index e24bd77225..23ce8b24e0 100644 --- a/mise.lock +++ b/mise.lock @@ -158,6 +158,30 @@ checksum = "sha256:675804464634cf068dc206e9fafc1bd4557ffcc0b1638a1ff246d899b776f url = "https://github.com/rust-cross/cargo-zigbuild/releases/download/v0.22.3/cargo-zigbuild-x86_64-pc-windows-msvc.zip" url_api = "https://api.github.com/repos/rust-cross/cargo-zigbuild/releases/assets/405676944" +[[tools."github:rust-secure-code/cargo-auditable"]] +version = "0.7.5" +backend = "github:rust-secure-code/cargo-auditable" + +[tools."github:rust-secure-code/cargo-auditable"."platforms.linux-arm64"] +checksum = "sha256:6d364879b516fa914f98504e551faddedf35b71543b4331a6d26e4e217778cfa" +url = "https://github.com/rust-secure-code/cargo-auditable/releases/download/v0.7.5/cargo-auditable-aarch64-unknown-linux-gnu.tar.xz" +url_api = "https://api.github.com/repos/rust-secure-code/cargo-auditable/releases/assets/426566627" + +[tools."github:rust-secure-code/cargo-auditable"."platforms.linux-x64"] +checksum = "sha256:322eda09f24e5dba97371c6c2c6949569c411ddf893852e88135cc5b51d5a719" +url = "https://github.com/rust-secure-code/cargo-auditable/releases/download/v0.7.5/cargo-auditable-x86_64-unknown-linux-gnu.tar.xz" +url_api = "https://api.github.com/repos/rust-secure-code/cargo-auditable/releases/assets/426566663" + +[tools."github:rust-secure-code/cargo-auditable"."platforms.macos-arm64"] +checksum = "sha256:92720fff2be27492ca7b216510a27e88a835b526e9e94e46bc36b502ba17d8f0" +url = "https://github.com/rust-secure-code/cargo-auditable/releases/download/v0.7.5/cargo-auditable-aarch64-apple-darwin.tar.xz" +url_api = "https://api.github.com/repos/rust-secure-code/cargo-auditable/releases/assets/426566625" + +[tools."github:rust-secure-code/cargo-auditable"."platforms.windows-x64"] +checksum = "sha256:83a7d5955c7ac96ede5d896ac9ede5f7ecce9ece0e95d9e47acd766b09e2ef1b" +url = "https://github.com/rust-secure-code/cargo-auditable/releases/download/v0.7.5/cargo-auditable-x86_64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/rust-secure-code/cargo-auditable/releases/assets/426566657" + [[tools.go]] version = "1.26.5" backend = "core:go" diff --git a/mise.toml b/mise.toml index 8e1cd4c4d6..ec643ed08e 100644 --- a/mise.toml +++ b/mise.toml @@ -42,6 +42,7 @@ k3d = { version = "5.8.3", os = ["macos"] } "github:EmbarkStudios/cargo-about" = { version = "0.8.4", version_prefix = "" } "github:EmbarkStudios/cargo-deny" = { version = "0.20.2", version_prefix = "" } zig = "0.14.1" +"github:rust-secure-code/cargo-auditable" = "0.7.5" "github:rust-cross/cargo-zigbuild" = "0.22.3" "npm:markdownlint-cli2" = "0.22.0" diff --git a/tasks/scripts/stage-prebuilt-binaries.sh b/tasks/scripts/stage-prebuilt-binaries.sh index b7eb1dad74..757a21298b 100755 --- a/tasks/scripts/stage-prebuilt-binaries.sh +++ b/tasks/scripts/stage-prebuilt-binaries.sh @@ -165,7 +165,8 @@ build_component_for_arch() { local target local stage local features - local cargo_subcommand + local -a cargo_subcommand + local -a cargo_env local build_target local current_host_os local current_host_arch @@ -183,6 +184,7 @@ build_component_for_arch() { current_host_arch="$(host_arch)" cargo_subcommand=(cargo build) + cargo_env=() build_target="$target" build_rustflags="${RUSTFLAGS:-}" @@ -221,6 +223,13 @@ build_component_for_arch() { fi fi + if [[ "${OPENSHELL_AUDITABLE:-0}" == "1" ]]; then + cargo_subcommand=("${cargo_subcommand[0]}" auditable "${cargo_subcommand[@]:1}") + # mise.toml injects RUSTC_WRAPPER=sccache. Unset it after mise constructs + # the environment so it cannot wrap cargo-auditable's workspace wrapper. + cargo_env=(env -u RUSTC_WRAPPER) + fi + echo "Building ${binary} for linux/${arch} (${build_target}, libc: ${target_libc})..." mise x -- rustup target add "$target" >/dev/null 2>&1 || true @@ -245,7 +254,7 @@ build_component_for_arch() { if [[ -n "$build_rustflags" ]]; then export RUSTFLAGS="$build_rustflags" fi - CARGO_INCREMENTAL=0 mise x -- "${cargo_subcommand[@]}" "${args[@]}" + CARGO_INCREMENTAL=0 mise x -- "${cargo_env[@]}" "${cargo_subcommand[@]}" "${args[@]}" ) binary_path="${ROOT}/target/${target}/release/${binary}" @@ -269,6 +278,14 @@ if [[ "$#" -gt 0 ]]; then exit 1 fi +case "${OPENSHELL_AUDITABLE:-0}" in + 0|1) ;; + *) + echo "unsupported OPENSHELL_AUDITABLE: ${OPENSHELL_AUDITABLE} (expected 0 or 1)" >&2 + exit 1 + ;; +esac + restore_cargo_toml=0 trap restore_workspace_version EXIT From 38a94931ffa52f85b493094bcd46ab988016293b Mon Sep 17 00:00:00 2001 From: Russell Bryant Date: Tue, 25 Aug 2026 17:04:43 +0000 Subject: [PATCH 014/505] fix(gateway): let Ready win over stale Suspended in derive_phase (#2933) On Agent Sandbox v1beta1, a `sandbox stop` then `sandbox start` left the sandbox stuck at `Starting` and `start` timed out after 300s, even though the workload pod was `Running 1/1` and the CR reported `Ready=True`. The v1beta1 controller (v0.5.0) sets `Suspended=True (PodTerminated)` on stop and does not clear it on resume, so a resumed CR carries both `Ready=True (DependenciesReady)` and a stale `Suspended=True`. The gateway's `derive_phase` treated any `Suspended=True` condition as `Stopped` before it consulted `Ready`, so a resumed-but-Ready sandbox was read as not-running and the `Starting` phase never advanced to `Ready`. `Ready=True` means the sandbox is usable through this gateway and must win over a `Suspended=True` condition. Only resolve `Stopped` from `Suspended` when `Ready` is not `True`; a genuine stop leaves `Ready` unset or `False`, so it still resolves to `Stopped`. v1alpha1 (which encodes desired state as `spec.replicas` and emits no lingering `Suspended`) is unaffected. The underlying stale condition is an upstream bug in the agent-sandbox controller, fixed by kubernetes-sigs/agent-sandbox#1150 (in v0.5.6). This gateway change keeps OpenShell resilient regardless of controller version. Fixes #2932 Signed-off-by: Russell Bryant --- crates/openshell-server/src/compute/mod.rs | 70 +++++++++++++++++++++- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 7ca5f339d5..51f05a395c 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -3980,10 +3980,24 @@ fn derive_phase(status: Option<&DriverSandboxStatus>) -> SandboxPhase { return SandboxPhase::Deleting; } - if status.conditions.iter().any(|condition| { - condition.r#type.eq_ignore_ascii_case("Suspended") + // `Ready=True` means the sandbox is usable through this gateway and must + // win over a `Suspended=True` condition. Agent Sandbox v1beta1 sets + // `Suspended=True (PodTerminated)` on stop and does not clear it on resume, + // so a resumed CR carries both `Ready=True` and a stale `Suspended=True`. + // Treating any `Suspended=True` as Stopped would pin the resumed sandbox at + // Starting forever (issue #2932). A genuine stop leaves `Ready` unset or + // False, so `Suspended` still resolves to Stopped in that case. + let ready = status.conditions.iter().any(|condition| { + condition.r#type.eq_ignore_ascii_case("Ready") && condition.status.eq_ignore_ascii_case("true") - }) { + }); + + if !ready + && status.conditions.iter().any(|condition| { + condition.r#type.eq_ignore_ascii_case("Suspended") + && condition.status.eq_ignore_ascii_case("true") + }) + { return SandboxPhase::Stopped; } @@ -6458,6 +6472,56 @@ mod tests { assert_eq!(current.phase(), SandboxPhase::Stopped as i32); } + #[tokio::test] + async fn resumed_v1beta1_snapshot_with_stale_suspended_reaches_ready() { + // Reproduces issue #2932: on Agent Sandbox v1beta1 a resumed CR reports + // Ready=True (DependenciesReady) alongside a stale Suspended=True + // (PodTerminated). Starting from the Starting phase that `start` sets, the + // reconciled sandbox must advance to Ready rather than being pinned at + // Starting by the stale Suspended condition. + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let sandbox = sandbox_record("sb-resumed", "sandbox-resumed", SandboxPhase::Starting); + runtime.store.put_message(&sandbox).await.unwrap(); + register_test_supervisor_session(&runtime, sandbox.object_id()); + + let mut resumed = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name()); + resumed.status = Some(DriverSandboxStatus { + sandbox_name: sandbox.object_name().to_string(), + instance_id: format!("{}-pod", sandbox.object_name()), + conditions: vec![ + DriverCondition { + r#type: "Ready".to_string(), + status: "True".to_string(), + reason: "DependenciesReady".to_string(), + message: "Sandbox is ready".to_string(), + last_transition_time: String::new(), + }, + DriverCondition { + r#type: "Suspended".to_string(), + status: "True".to_string(), + reason: "PodTerminated".to_string(), + message: "Pod terminated".to_string(), + last_transition_time: String::new(), + }, + ], + ..Default::default() + }); + + runtime.apply_sandbox_update(resumed).await.unwrap(); + + let current = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!( + current.phase(), + SandboxPhase::Ready as i32, + "a resumed, Ready sandbox must not stay Starting because of a stale Suspended condition" + ); + } + #[tokio::test] async fn stopped_container_snapshot_cannot_error_stopped_sandbox() { let runtime = test_runtime(Arc::new(TestDriver::default())).await; From 5206bc51b2257cf36ac1cdd9fd8e4567db204b3a Mon Sep 17 00:00:00 2001 From: Seth Jennings Date: Tue, 25 Aug 2026 17:53:47 +0000 Subject: [PATCH 015/505] feat(sdk): add OAuth Client Credentials support to SDKs (#2907) * feat(sdk): add renewable client credentials auth Implement lazy OAuth client-credentials acquisition and renewal for the Python, TypeScript, and Go SDK clients, with shared security conformance coverage and service-account documentation.\n\nCloses #2803 Signed-off-by: Seth Jennings * fix(sdk): address client credentials review Signed-off-by: Seth Jennings * fix(sdk): address client credentials review Signed-off-by: Seth Jennings --------- Signed-off-by: Seth Jennings --- architecture/gateway.md | 8 + docs/reference/gateway-auth.mdx | 53 ++ docs/sandboxes/manage-sandboxes.mdx | 12 + e2e/python/oidc/oidc_auth_test.py | 34 +- python/openshell/__init__.py | 2 + python/openshell/sandbox.py | 274 ++++++++++- python/openshell/sandbox_test.py | 454 ++++++++++++++++++ sdk/conformance/oauth-client-credentials.json | 44 ++ sdk/go/README.md | 28 +- sdk/go/docs/src/api/oidc.md | 15 +- sdk/go/openshell/v1/gateway/config.go | 10 + sdk/go/openshell/v1/gateway/config_test.go | 4 + sdk/go/openshell/v1/oidc/credentials.go | 83 +++- sdk/go/openshell/v1/oidc/credentials_auth.go | 74 +++ sdk/go/openshell/v1/oidc/credentials_test.go | 370 ++++++++++++++ sdk/go/openshell/v1/oidc/discovery.go | 14 +- sdk/go/openshell/v1/oidc/doc.go | 2 +- sdk/go/openshell/v1/oidc/options.go | 51 +- sdk/typescript/README.md | 27 +- sdk/typescript/src/index.ts | 7 +- sdk/typescript/src/oidc.test.ts | 262 ++++++++++ sdk/typescript/src/oidc.ts | 208 ++++++++ sdk/typescript/src/transport.test.ts | 33 +- sdk/typescript/src/transport.ts | 26 +- 24 files changed, 2021 insertions(+), 74 deletions(-) create mode 100644 sdk/conformance/oauth-client-credentials.json create mode 100644 sdk/go/openshell/v1/oidc/credentials_auth.go create mode 100644 sdk/typescript/src/oidc.test.ts create mode 100644 sdk/typescript/src/oidc.ts diff --git a/architecture/gateway.md b/architecture/gateway.md index f7c80d1ed8..ed12498e88 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -189,6 +189,14 @@ reuses them when refreshing an access token. This preserves the intended API resource selection for identity providers that bind access-token audiences to OAuth scopes. +Python and Go SDK client-credentials providers can use the same registered +issuer, client ID, audience, and scope metadata; the TypeScript provider accepts +those fields explicitly. All three own a separate in-memory lifecycle, repeat +the grant before expiry, and never persist the client secret or acquired access +token into the CLI token cache. They require TLS when sending renewable bearer +credentials to non-loopback gateways. This keeps non-interactive SDK +authentication independent from refresh-token rotation and shared disk state. + Gateway health and user authentication are separate probes. `OpenShell.Health` remains unauthenticated so deployment and load-balancer health checks do not depend on user credentials. The CLI uses the existing, side-effect-free diff --git a/docs/reference/gateway-auth.mdx b/docs/reference/gateway-auth.mdx index e5dcb65adf..b5157b92a5 100644 --- a/docs/reference/gateway-auth.mdx +++ b/docs/reference/gateway-auth.mdx @@ -122,6 +122,59 @@ openshell gateway add https://gateway.example.com \ When you register or log in to an OIDC gateway, the CLI uses the Authorization Code flow with PKCE. It opens a browser, receives the authorization code on a localhost callback, exchanges the code for tokens, and stores the token bundle under the gateway credential directory. After `openshell gateway logout`, the next browser login asks the identity provider for a fresh login prompt so you can choose a different browser user instead of silently reusing the previous session. If `OPENSHELL_OIDC_CLIENT_SECRET` is set, the CLI uses the client credentials flow instead. Use that mode for CI and other non-interactive automation. +Official Python, TypeScript, and Go SDKs can perform renewable client-credentials +authentication directly. Configure the service account at the identity provider +with the audience, roles, scopes, and workspace membership required by the +gateway. The SDKs discover the token endpoint, attach the bearer token to each +RPC, and repeat the grant before expiry. They keep the client secret and access +token in memory and do not update the CLI's `oidc_token.json`. SDK clients +require TLS when sending these credentials to a non-loopback gateway. + + + + +```python +from openshell import ClientCredentialsAuth, SandboxClient + +auth = ClientCredentialsAuth( + client_secret=lambda: load_secret(), + # Omit issuer/client_id/scopes/audience to use active gateway metadata. +) +client = SandboxClient.from_active_cluster(client_credentials=auth) +``` + + + + +```ts +import { clientCredentials, OpenShellClient } from '@nvidia/openshell-sdk' + +const client = await OpenShellClient.connect({ + gateway: 'https://gateway.example.com', + oidcTokenProvider: clientCredentials({ + issuer: 'https://idp.example.com/realms/openshell', + clientId: 'openshell-service', + clientSecret: () => loadSecret(), + audience: 'openshell-gateway', + scopes: ['sandbox:read', 'sandbox:write'], + }), +}) +``` + + + + +```go +auth, err := oidc.NewClientCredentialsAuth( + oidc.WithGateway("production"), + oidc.WithClientSecretProvider(loadSecret), +) +client, err := v1.NewClient(v1.Config{Address: address, Auth: auth, TLS: tlsConfig}) +``` + + + + The connection flow: For a headless environment, set `OPENSHELL_NO_BROWSER=1` before registering or logging in to the gateway. When this variable is set and `OPENSHELL_OIDC_CLIENT_SECRET` is not configured, the CLI uses the Device Authorization Grant (RFC 8628) with S256 PKCE. This flow prompts the user to visit a verification URL on any device with a browser and enter a displayed code. The CLI polls the token endpoint until the user completes authorization. This requires the OIDC client to have the device authorization grant enabled on the identity provider. diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index ead6533294..e5a27879f3 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -267,6 +267,18 @@ with SandboxClient.from_active_cluster() as client: assert sandbox.id in {s.id for s in matches} ``` +For non-interactive automation, pass a renewable client-credentials provider. +Omitted issuer, client ID, audience, and scopes are read from the active +gateway's metadata. The client requires TLS for non-loopback gateways: + +```python +from openshell import ClientCredentialsAuth, SandboxClient + +auth = ClientCredentialsAuth(client_secret=lambda: load_secret()) +with SandboxClient.from_active_cluster(client_credentials=auth) as client: + sandboxes = client.list(workspace="default") +``` + ## Expose Long Running Services Service forwarding makes a long-running process inside a sandbox reachable through a gateway-managed URL. Use it for development servers, notebooks, dashboards, or other services that keep listening after the sandbox starts. Run the service on loopback inside the sandbox, expose its port, then open the URL printed by OpenShell. diff --git a/e2e/python/oidc/oidc_auth_test.py b/e2e/python/oidc/oidc_auth_test.py index bbea1aac3d..1ed8d56fc2 100644 --- a/e2e/python/oidc/oidc_auth_test.py +++ b/e2e/python/oidc/oidc_auth_test.py @@ -15,17 +15,23 @@ import contextlib import os +import urllib.parse +from pathlib import Path import grpc import pytest +from openshell import ClientCredentialsAuth, SandboxClient, TlsConfig from openshell._proto import datamodel_pb2, openshell_pb2, openshell_pb2_grpc from .helpers import ( + KEYCLOAK_REALM, + _gateway_endpoint, + _mtls_dir, extract_sub, - get_ci_token, get_token, grpc_channel, + keycloak_url, stub_with_token, ) @@ -180,9 +186,28 @@ class TestClientCredentials: def test_ci_token_can_list_sandboxes(self) -> None: admin_token = get_token("admin@test", "admin", scopes="openid openshell:all") admin_stub, admin_md = stub_with_token(admin_token) - ci_token = get_ci_token() + auth = ClientCredentialsAuth( + issuer=f"{keycloak_url()}/realms/{KEYCLOAK_REALM}", + client_id="openshell-ci", + client_secret="ci-test-secret", + ) + ci_token = auth() ci_sub = extract_sub(ci_token) - ci_stub, ci_md = stub_with_token(ci_token) + gateway_endpoint, is_tls = _gateway_endpoint() + parsed = urllib.parse.urlparse(gateway_endpoint) + target = f"{parsed.hostname}:{parsed.port or (443 if is_tls else 80)}" + tls = None + if is_tls: + if ca_path := os.environ.get("OPENSHELL_E2E_GATEWAY_CA_CERT"): + tls = TlsConfig(ca_path=Path(ca_path)) + else: + mtls = _mtls_dir() + tls = TlsConfig( + ca_path=mtls / "ca.crt", + cert_path=mtls / "tls.crt", + key_path=mtls / "tls.key", + ) + ci_client = SandboxClient(target, tls=tls, client_credentials=auth) with contextlib.suppress(grpc.RpcError): admin_stub.AddWorkspaceMember( @@ -194,8 +219,9 @@ def test_ci_token_can_list_sandboxes(self) -> None: metadata=admin_md, ) try: - ci_stub.ListSandboxes(openshell_pb2.ListSandboxesRequest(), metadata=ci_md) + ci_client.list(workspace="default") finally: + ci_client.close() with contextlib.suppress(grpc.RpcError): admin_stub.RemoveWorkspaceMember( openshell_pb2.RemoveWorkspaceMemberRequest( diff --git a/python/openshell/__init__.py b/python/openshell/__init__.py index 0aa343ed60..27f317578b 100644 --- a/python/openshell/__init__.py +++ b/python/openshell/__init__.py @@ -6,6 +6,7 @@ from __future__ import annotations from .sandbox import ( + ClientCredentialsAuth, ExecChunk, ExecResult, InferenceRouteClient, @@ -29,6 +30,7 @@ __version__ = "0.0.0" __all__ = [ + "ClientCredentialsAuth", "ExecChunk", "ExecResult", "InferenceRouteClient", diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index b66ed936b6..94f3c57def 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -6,7 +6,9 @@ import base64 import contextlib import errno +import ipaddress import json +import math import os import pathlib import sys @@ -34,6 +36,8 @@ ("method", "timeout", "metadata", "credentials", "wait_for_ready", "compression"), ) +_OAUTH_MAX_RESPONSE_BYTES = 1 << 20 + class _ClientCallDetails(_ClientCallDetailsBase, grpc.ClientCallDetails): pass @@ -126,6 +130,238 @@ def _normalize_bearer( return lambda: token +def _is_loopback_host(hostname: str | None) -> bool: + if hostname is None: + return False + if hostname.lower() == "localhost": + return True + with contextlib.suppress(ValueError): + return ipaddress.ip_address(hostname).is_loopback + return False + + +def _is_local_grpc_endpoint(endpoint: str) -> bool: + if endpoint.startswith("unix:"): + return True + return _is_loopback_host(urlparse(f"//{endpoint}").hostname) + + +def _validate_oauth_url(name: str, raw: str) -> str: + """Validate an OAuth endpoint without reflecting attacker-controlled URLs.""" + parsed = urlparse(raw) + if not parsed.scheme or not parsed.hostname or parsed.username or parsed.password: + raise SandboxError(f"invalid OAuth {name} URL") + if parsed.fragment: + raise SandboxError(f"OAuth {name} URL must not contain a fragment") + if parsed.scheme == "https": + return raw + if parsed.scheme == "http" and _is_loopback_host(parsed.hostname): + return raw + raise SandboxError( + f"OAuth {name} URL must use HTTPS (HTTP is allowed only for loopback hosts)" + ) + + +def _oauth_json_request( + client: httpx.Client, + method: str, + url: str, + *, + kind: str, + headers: Mapping[str, str], + data: Mapping[str, str] | None = None, +) -> tuple[int, dict[str, object]]: + """Read a bounded JSON response without retaining arbitrary error bodies.""" + with client.stream(method, url, headers=headers, data=data) as response: + if response.status_code != 200: + return response.status_code, {} + content = bytearray() + for chunk in response.iter_bytes(): + content.extend(chunk) + if len(content) > _OAUTH_MAX_RESPONSE_BYTES: + raise SandboxError(f"OAuth {kind} response is too large") + try: + payload = json.loads(content) + except (UnicodeDecodeError, json.JSONDecodeError): + raise SandboxError(f"OAuth {kind} returned invalid JSON") from None + if not isinstance(payload, dict): + raise SandboxError(f"OAuth {kind} returned invalid JSON") + return 200, payload + + +class ClientCredentialsAuth: + """Renewable OAuth 2.0 client-credentials bearer provider. + + Tokens and client secrets remain in memory. The first RPC lazily performs + discovery and an exchange; later RPCs share the cached token until it is + within 30 seconds of expiry. Concurrent callers share one exchange. + """ + + def __init__( + self, + *, + client_secret: str | Callable[[], str], + issuer: str | None = None, + client_id: str | None = None, + scopes: Sequence[str] | None = None, + audience: str | None = None, + insecure: bool = False, + timeout: float = 30.0, + _transport: httpx.BaseTransport | None = None, + ) -> None: + if not isinstance(client_secret, str) and not callable(client_secret): + raise TypeError("client_secret must be a string or zero-argument callable") + if isinstance(client_secret, str) and not client_secret: + raise SandboxError("OAuth client secret must not be empty") + self._secret = client_secret + self._issuer = issuer + self._client_id = client_id + self._scopes = tuple(scopes) if scopes is not None else None + self._audience = audience + self._insecure = insecure + self._timeout = timeout + self._transport = _transport + self._lock = threading.Lock() + self._access_token: str | None = None + self._expires_at = 0.0 + self._token_endpoint: str | None = None + + def __repr__(self) -> str: + return ( + "ClientCredentialsAuth(issuer=" + f"{self._issuer!r}, client_id={self._client_id!r}, " + f"scopes={self._scopes!r}, audience={self._audience!r})" + ) + + def _apply_gateway_metadata( + self, metadata: Mapping[str, object], *, insecure: bool = False + ) -> None: + """Fill omitted fields and apply active-gateway transport settings.""" + if self._issuer is None: + value = metadata.get("oidc_issuer") + self._issuer = value if isinstance(value, str) and value else None + if self._client_id is None: + value = metadata.get("oidc_client_id") + self._client_id = value if isinstance(value, str) and value else None + if self._audience is None: + value = metadata.get("oidc_audience") + self._audience = value if isinstance(value, str) and value else None + if self._scopes is None: + value = metadata.get("oidc_scopes") + if isinstance(value, str): + self._scopes = tuple(value.split()) + # Preserve an explicit provider-level opt-in while also honoring the + # active-gateway construction flag used by the existing OIDC refresher. + self._insecure = self._insecure or insecure + + def __call__(self) -> str: + if ( + self._access_token is not None + and time.time() + _OIDC_TOKEN_EXPIRY_GRACE_SECONDS < self._expires_at + ): + return self._access_token + with self._lock: + if ( + self._access_token is not None + and time.time() + _OIDC_TOKEN_EXPIRY_GRACE_SECONDS < self._expires_at + ): + return self._access_token + token, expires_at = self._exchange() + self._access_token = token + self._expires_at = expires_at + return token + + def _exchange(self) -> tuple[str, float]: + if not self._issuer or not self._client_id: + raise SandboxError("OAuth client credentials require issuer and client_id") + issuer = _validate_oauth_url("issuer", self._issuer).rstrip("/") + headers = {"accept": "application/json"} + try: + with httpx.Client( + verify=not self._insecure, + follow_redirects=False, + timeout=self._timeout, + transport=self._transport, + ) as client: + if self._token_endpoint is None: + status, discovery = _oauth_json_request( + client, + "GET", + f"{issuer}/.well-known/openid-configuration", + kind="discovery", + headers=headers, + ) + if status != 200: + raise SandboxError(f"OAuth discovery failed with HTTP {status}") + discovered = discovery.get("issuer") + if ( + not isinstance(discovered, str) + or discovered.rstrip("/") != issuer + ): + raise SandboxError("OAuth discovery issuer mismatch") + endpoint = discovery.get("token_endpoint") + if not isinstance(endpoint, str) or not endpoint: + raise SandboxError( + "OAuth discovery document is missing token_endpoint" + ) + self._token_endpoint = _validate_oauth_url( + "token endpoint", endpoint + ) + secret = self._resolve_secret() + data = { + "grant_type": "client_credentials", + "client_id": self._client_id, + "client_secret": secret, + } + if self._scopes: + data["scope"] = " ".join(self._scopes) + if self._audience: + data["audience"] = self._audience + status, payload = _oauth_json_request( + client, + "POST", + self._token_endpoint, + kind="client credentials response", + data=data, + headers=headers, + ) + except SandboxError: + raise + except httpx.HTTPError: + raise SandboxError("OAuth client credentials request failed") from None + if status != 200: + raise SandboxError( + f"OAuth client credentials exchange failed with HTTP {status}" + ) + access_token = payload.get("access_token") + expires_in = payload.get("expires_in") + if not isinstance(access_token, str) or not access_token: + raise SandboxError( + "OAuth client credentials response is missing access_token" + ) + if ( + isinstance(expires_in, bool) + or not isinstance(expires_in, (int, float)) + or not math.isfinite(expires_in) + or expires_in <= 0 + ): + raise SandboxError( + "OAuth client credentials response requires a positive finite expires_in" + ) + return access_token, time.time() + float(expires_in) + + def _resolve_secret(self) -> str: + if isinstance(self._secret, str): + return self._secret + try: + value = self._secret() + except Exception: + raise SandboxError("OAuth client secret supplier failed") from None + if not isinstance(value, str) or not value: + raise SandboxError("OAuth client secret supplier returned an empty secret") + return value + + @dataclass(frozen=True) class SandboxStatusRef: phase: int @@ -270,6 +506,7 @@ def __init__( *, tls: TlsConfig | None = None, bearer_token: str | Callable[[], str] | None = None, + client_credentials: ClientCredentialsAuth | None = None, timeout: float = 30.0, cluster_name: str | None = None, _bearer_close: Callable[[], None] | None = None, @@ -284,6 +521,9 @@ def __init__( runtime refresh). Combines with `tls` — pass both when the gateway uses mTLS for transport identity and OIDC for user identity. + client_credentials: renewable OAuth client-credentials provider. + Mutually exclusive with `bearer_token`. A non-loopback endpoint + requires `tls` so the acquired bearer is never sent in cleartext. timeout: default per-call timeout in seconds. cluster_name: optional friendly name for error messages. _bearer_close: internal — wired by `from_active_cluster` @@ -293,6 +533,18 @@ def __init__( lifecycle of any callable they supplied as `bearer_token`. """ + if bearer_token is not None and client_credentials is not None: + raise SandboxError( + "bearer_token and client_credentials are mutually exclusive" + ) + if ( + client_credentials is not None + and tls is None + and not _is_local_grpc_endpoint(endpoint) + ): + raise SandboxError( + "OAuth client credentials require TLS for non-loopback gateway endpoints" + ) self._endpoint = endpoint self._timeout = timeout self._cluster_name = cluster_name @@ -312,7 +564,7 @@ def __init__( ), ) self._channel = grpc.secure_channel(endpoint, credentials) - provider = _normalize_bearer(bearer_token) + provider = _normalize_bearer(client_credentials or bearer_token) if provider is not None: self._channel = grpc.intercept_channel( self._channel, @@ -329,6 +581,7 @@ def from_active_cluster( auto_refresh: bool = True, write_back: bool = True, insecure: bool = False, + client_credentials: ClientCredentialsAuth | None = None, ) -> SandboxClient: """Construct a `SandboxClient` from the active gateway's on-disk state. @@ -357,6 +610,10 @@ def from_active_cluster( for OIDC discovery and refresh calls. Mirrors the Rust CLI's `--insecure` flag for issuers behind self-signed certs. Off by default. + client_credentials: renewable OAuth client-credentials provider. + Omitted issuer, client ID, audience, and scopes are filled from + the registered gateway metadata. This provider does not read or + write `oidc_token.json`. Remote plaintext gateways are rejected. """ cluster_name = cluster or _resolve_active_cluster() gateway_dir = _xdg_config_home() / "openshell" / "gateways" / cluster_name @@ -396,7 +653,13 @@ def from_active_cluster( # a non-OIDC gateway should NOT cause us to attach a bearer. bearer_token: Callable[[], str] | None = None bearer_close: Callable[[], None] | None = None - if metadata.get("auth_mode") == "oidc": + if client_credentials is not None: + if metadata.get("auth_mode") != "oidc": + raise SandboxError( + f"gateway '{cluster_name}' is not configured for OIDC" + ) + client_credentials._apply_gateway_metadata(metadata, insecure=insecure) + elif metadata.get("auth_mode") == "oidc": bearer_token, bearer_close = _make_cluster_bearer_provider( gateway_dir, cluster_name, @@ -409,6 +672,7 @@ def from_active_cluster( endpoint, tls=tls, bearer_token=bearer_token, + client_credentials=client_credentials, timeout=timeout, cluster_name=cluster_name, _bearer_close=bearer_close, @@ -905,10 +1169,12 @@ def __init__( auto_refresh: bool = True, write_back: bool = True, insecure: bool = False, + client_credentials: ClientCredentialsAuth | None = None, ) -> None: """Bind a Sandbox context to the active gateway. - OIDC kwargs (`auto_refresh`, `write_back`, `insecure`) forward + OIDC kwargs (`auto_refresh`, `write_back`, `insecure`, and + `client_credentials`) forward directly to `SandboxClient.from_active_cluster` and have the same semantics. They're surfaced on `Sandbox` so callers using the higher-level wrapper get parity with `SandboxClient` for @@ -928,6 +1194,7 @@ def __init__( self._auto_refresh = auto_refresh self._write_back = write_back self._insecure = insecure + self._client_credentials = client_credentials self._client: SandboxClient | None = None self._session: SandboxSession | None = None @@ -959,6 +1226,7 @@ def __enter__(self) -> Sandbox: auto_refresh=self._auto_refresh, write_back=self._write_back, insecure=self._insecure, + client_credentials=self._client_credentials, ) self._client = client diff --git a/python/openshell/sandbox_test.py b/python/openshell/sandbox_test.py index a059192460..d70aae49cc 100644 --- a/python/openshell/sandbox_test.py +++ b/python/openshell/sandbox_test.py @@ -19,8 +19,10 @@ import openshell.sandbox as sandbox_module from openshell._proto import openshell_pb2 from openshell.sandbox import ( + _OIDC_TOKEN_EXPIRY_GRACE_SECONDS, _PYTHON_CLOUDPICKLE_BOOTSTRAP, _SANDBOX_PYTHON_BIN, + ClientCredentialsAuth, InferenceRouteClient, Sandbox, SandboxClient, @@ -36,9 +38,354 @@ _OidcRefresher, _read_oidc_token_bundle, _sandbox_ref, + _validate_oauth_url, ) +def _client_credentials_fixture() -> dict[str, Any]: + return json.loads( + ( + Path(__file__).parents[2] / "sdk/conformance/oauth-client-credentials.json" + ).read_text() + ) + + +def test_oauth_client_credentials_conformance_fixture() -> None: + fixture = _client_credentials_fixture() + assert fixture["expiry"]["leeway_seconds"] == _OIDC_TOKEN_EXPIRY_GRACE_SECONDS + for value in fixture["urls"]["allowed"]: + assert _validate_oauth_url("issuer", value) == value + for value in fixture["urls"]["rejected"]: + with pytest.raises(SandboxError): + _validate_oauth_url("issuer", value) + + +def test_client_credentials_auth_exact_form_cache_and_redaction() -> None: + fixture = _client_credentials_fixture() + seen: list[tuple[str, bytes]] = [] + + def handler(request: Any) -> Any: + import httpx + + seen.append((str(request.url), bytes(request.content))) + if request.method == "GET": + return httpx.Response( + 200, + json={ + "issuer": "https://issuer.example.com/", + "token_endpoint": "https://issuer.example.com/token", + }, + ) + return httpx.Response( + 200, + json={ + "access_token": "service-token", + "expires_in": fixture["expiry"]["valid_expires_in"], + }, + ) + + import httpx + + auth = ClientCredentialsAuth( + issuer="https://issuer.example.com", + client_id="service-client", + client_secret="conformance-secret", + scopes=("sandbox:read", "sandbox:write"), + audience="openshell-gateway", + _transport=httpx.MockTransport(handler), + ) + assert auth() == "service-token" + assert auth() == "service-token" + assert len(seen) == 2 + form = dict(__import__("urllib.parse").parse.parse_qsl(seen[1][1].decode())) + assert form == { + field: fixture["request"][field] + for field in ( + "grant_type", + "client_id", + "client_secret", + "scope", + "audience", + ) + } + assert "conformance-secret" not in repr(auth) + + +def test_client_credentials_auth_preserves_explicit_empty_scopes() -> None: + import httpx + + form: dict[str, str] = {} + + def handler(request: Any) -> Any: + if request.method == "GET": + return httpx.Response( + 200, + json={ + "issuer": "https://issuer.example.com", + "token_endpoint": "https://issuer.example.com/token", + }, + ) + form.update( + __import__("urllib.parse").parse.parse_qsl(request.content.decode()) + ) + return httpx.Response(200, json={"access_token": "token", "expires_in": 120}) + + auth = ClientCredentialsAuth( + issuer="https://issuer.example.com", + client_id="client", + client_secret="secret", + scopes=(), + _transport=httpx.MockTransport(handler), + ) + auth._apply_gateway_metadata({"oidc_scopes": "sandbox:read sandbox:write"}) + + assert auth() == "token" + assert auth._scopes == () + assert "scope" not in form + + +@pytest.mark.parametrize( + "expires_in", _client_credentials_fixture()["expiry"]["invalid_expires_in"] +) +def test_client_credentials_auth_rejects_invalid_expiry(expires_in: object) -> None: + import httpx + + fixture = _client_credentials_fixture() + + def handler(request: Any) -> Any: + if request.method == "GET": + return httpx.Response( + 200, + json={ + "issuer": fixture["discovery"]["matching_issuer"], + "token_endpoint": "https://issuer.example.com/token", + }, + ) + return httpx.Response( + 200, json={"access_token": "token", "expires_in": expires_in} + ) + + auth = ClientCredentialsAuth( + issuer=fixture["discovery"]["configured_issuer"], + client_id="client", + client_secret="secret", + _transport=httpx.MockTransport(handler), + ) + with pytest.raises(SandboxError, match="positive finite expires_in"): + auth() + + +@pytest.mark.parametrize( + "status", _client_credentials_fixture()["discovery"]["redirect_statuses"] +) +def test_client_credentials_auth_refuses_discovery_redirect(status: int) -> None: + import httpx + + requests = 0 + + def handler(_request: Any) -> Any: + nonlocal requests + requests += 1 + return httpx.Response( + status, + headers={"location": "https://attacker.example.com/discovery"}, + ) + + auth = ClientCredentialsAuth( + issuer="https://issuer.example.com", + client_id="client", + client_secret="secret", + _transport=httpx.MockTransport(handler), + ) + with pytest.raises(SandboxError, match=f"HTTP {status}"): + auth() + assert requests == 1 + + +@pytest.mark.parametrize( + "status", _client_credentials_fixture()["discovery"]["redirect_statuses"] +) +def test_client_credentials_auth_refuses_token_redirect(status: int) -> None: + import httpx + + requests: list[str] = [] + + def handler(request: Any) -> Any: + requests.append(str(request.url)) + if request.method == "GET": + return httpx.Response( + 200, + json={ + "issuer": "https://issuer.example.com", + "token_endpoint": "https://issuer.example.com/token", + }, + ) + return httpx.Response( + status, + headers={"location": "https://attacker.example.com/token"}, + ) + + auth = ClientCredentialsAuth( + issuer="https://issuer.example.com", + client_id="client", + client_secret="secret", + _transport=httpx.MockTransport(handler), + ) + with pytest.raises(SandboxError, match=f"HTTP {status}"): + auth() + assert requests == [ + "https://issuer.example.com/.well-known/openid-configuration", + "https://issuer.example.com/token", + ] + + +def test_client_credentials_auth_rejects_discovery_issuer_mismatch() -> None: + import httpx + + fixture = _client_credentials_fixture() + auth = ClientCredentialsAuth( + issuer=fixture["discovery"]["configured_issuer"], + client_id="client", + client_secret="secret", + _transport=httpx.MockTransport( + lambda _request: httpx.Response( + 200, + json={ + "issuer": fixture["discovery"]["mismatched_issuer"], + "token_endpoint": "https://attacker.example.com/token", + }, + ) + ), + ) + with pytest.raises(SandboxError, match="issuer mismatch"): + auth() + + +def test_client_credentials_auth_rejects_oversized_response() -> None: + import httpx + + fixture = _client_credentials_fixture() + auth = ClientCredentialsAuth( + issuer="https://issuer.example.com", + client_id="client", + client_secret="secret", + _transport=httpx.MockTransport( + lambda _request: httpx.Response( + 200, content=b"x" * (fixture["limits"]["max_response_bytes"] + 1) + ) + ), + ) + with pytest.raises(SandboxError, match="too large"): + auth() + + +def test_client_credentials_auth_single_flight_and_retry() -> None: + import httpx + + token_calls = 0 + release = threading.Event() + + def handler(request: Any) -> Any: + nonlocal token_calls + if request.method == "GET": + return httpx.Response( + 200, + json={ + "issuer": "http://127.0.0.1:8080", + "token_endpoint": "http://127.0.0.1:8080/token", + }, + ) + token_calls += 1 + release.wait(timeout=2) + return httpx.Response(200, json={"access_token": "shared", "expires_in": 120}) + + auth = ClientCredentialsAuth( + issuer="http://127.0.0.1:8080", + client_id="client", + client_secret="secret", + _transport=httpx.MockTransport(handler), + ) + results: list[str] = [] + threads = [ + threading.Thread(target=lambda: results.append(auth())) for _ in range(8) + ] + for thread in threads: + thread.start() + while token_calls == 0: + time.sleep(0.001) + release.set() + for thread in threads: + thread.join() + assert results == ["shared"] * 8 + assert token_calls == 1 + + +def test_client_credentials_auth_fails_closed_and_redacts_errors() -> None: + import httpx + + def supplier() -> str: + raise RuntimeError("supplier-sensitive-detail") + + auth = ClientCredentialsAuth( + issuer="http://localhost:8080", + client_id="client", + client_secret=supplier, + _transport=httpx.MockTransport( + lambda _request: httpx.Response( + 200, + json={ + "issuer": "http://localhost:8080", + "token_endpoint": "http://localhost:8080/token", + }, + ) + ), + ) + with pytest.raises(SandboxError, match="supplier failed") as exc_info: + auth() + assert "supplier-sensitive-detail" not in str(exc_info.value) + with pytest.raises(SandboxError, match="must use HTTPS"): + ClientCredentialsAuth( + issuer="http://remote.example.com", + client_id="client", + client_secret="secret", + )() + + +def test_client_credentials_auth_does_not_use_stale_token_after_renewal_failure() -> ( + None +): + import httpx + + exchanges = 0 + + def handler(request: Any) -> Any: + nonlocal exchanges + if request.method == "GET": + return httpx.Response( + 200, + json={ + "issuer": "http://localhost:8080", + "token_endpoint": "http://localhost:8080/token", + }, + ) + exchanges += 1 + if exchanges == 1: + return httpx.Response(200, json={"access_token": "stale", "expires_in": 30}) + return httpx.Response(503, json={"error": "provider-sensitive-detail"}) + + auth = ClientCredentialsAuth( + issuer="http://localhost:8080", + client_id="client", + client_secret="secret", + _transport=httpx.MockTransport(handler), + ) + assert auth() == "stale" + with pytest.raises(SandboxError, match="HTTP 503") as exc_info: + auth() + assert "stale" not in str(exc_info.value) + assert "provider-sensitive-detail" not in str(exc_info.value) + + class _FakeStub: def __init__(self) -> None: self.request: openshell_pb2.ExecSandboxRequest | None = None @@ -1187,6 +1534,113 @@ def bearer_close() -> None: assert closed[0] == 1 +def test_from_active_cluster_fills_client_credentials_from_metadata( + tmp_path: Path, + monkeypatch: Any, +) -> None: + gateway_dir = _setup_gateway_dir(tmp_path, monkeypatch, auth_mode="oidc") + metadata_path = gateway_dir / "metadata.json" + metadata = json.loads(metadata_path.read_text()) + metadata.update( + { + "oidc_issuer": "https://issuer.example.com", + "oidc_client_id": "service-client", + "oidc_audience": "gateway", + "oidc_scopes": "sandbox:read sandbox:write", + } + ) + metadata_path.write_text(json.dumps(metadata)) + auth = ClientCredentialsAuth(client_secret="secret") + client = SandboxClient.from_active_cluster( + client_credentials=auth, + insecure=True, + ) + try: + assert auth._issuer == "https://issuer.example.com" + assert auth._client_id == "service-client" + assert auth._audience == "gateway" + assert auth._scopes == ("sandbox:read", "sandbox:write") + assert auth._insecure is True + finally: + client.close() + + +def test_sandbox_client_rejects_client_credentials_on_remote_plaintext( + monkeypatch: Any, +) -> None: + channel_opened = False + + def insecure_channel(_endpoint: str) -> Any: + nonlocal channel_opened + channel_opened = True + raise AssertionError("plaintext channel must not be opened") + + monkeypatch.setattr(sandbox_module.grpc, "insecure_channel", insecure_channel) + auth = ClientCredentialsAuth( + issuer="https://issuer.example.com", + client_id="client", + client_secret="secret", + ) + with pytest.raises(SandboxError, match="require TLS"): + SandboxClient("gateway.example.com:50051", client_credentials=auth) + assert not channel_opened + + +@pytest.mark.parametrize( + "endpoint", + ["localhost:50051", "127.42.0.1:50051", "[::1]:50051"], +) +def test_sandbox_client_allows_client_credentials_on_plaintext_loopback( + endpoint: str, +) -> None: + auth = ClientCredentialsAuth( + issuer="https://issuer.example.com", + client_id="client", + client_secret="secret", + ) + client = SandboxClient(endpoint, client_credentials=auth) + client.close() + + +def test_from_active_cluster_rejects_client_credentials_on_remote_plaintext( + tmp_path: Path, + monkeypatch: Any, +) -> None: + gateway_dir = _setup_gateway_dir( + tmp_path, + monkeypatch, + endpoint="http://gateway.example.com:8080", + auth_mode="oidc", + ) + metadata_path = gateway_dir / "metadata.json" + metadata = json.loads(metadata_path.read_text()) + metadata.update( + { + "oidc_issuer": "https://issuer.example.com", + "oidc_client_id": "service-client", + } + ) + metadata_path.write_text(json.dumps(metadata)) + + auth = ClientCredentialsAuth(client_secret="secret") + with pytest.raises(SandboxError, match="require TLS"): + SandboxClient.from_active_cluster(client_credentials=auth) + + +def test_sandbox_client_rejects_ambiguous_bearer_configuration() -> None: + auth = ClientCredentialsAuth( + issuer="https://issuer.example.com", + client_id="client", + client_secret="secret", + ) + with pytest.raises(SandboxError, match="mutually exclusive"): + SandboxClient( + "localhost:50051", + bearer_token="static", + client_credentials=auth, + ) + + def test_sandbox_client_close_releases_refresher_http_client( tmp_path: Path, monkeypatch: Any, diff --git a/sdk/conformance/oauth-client-credentials.json b/sdk/conformance/oauth-client-credentials.json new file mode 100644 index 0000000000..f31d0fe683 --- /dev/null +++ b/sdk/conformance/oauth-client-credentials.json @@ -0,0 +1,44 @@ +{ + "request": { + "grant_type": "client_credentials", + "client_id": "service-client", + "client_secret": "conformance-secret", + "scopes": ["sandbox:read", "sandbox:write"], + "scope": "sandbox:read sandbox:write", + "audience": "openshell-gateway", + "forbidden_implicit_scopes": ["openid", "profile", "email"] + }, + "expiry": { + "leeway_seconds": 30, + "valid_expires_in": 120, + "invalid_expires_in": [0, -1, "NaN", null] + }, + "discovery": { + "configured_issuer": "https://issuer.example.com", + "matching_issuer": "https://issuer.example.com/", + "mismatched_issuer": "https://attacker.example.com", + "redirect_statuses": [301, 302, 307, 308] + }, + "limits": { + "max_response_bytes": 1048576 + }, + "urls": { + "allowed": [ + "https://issuer.example.com", + "http://localhost:8080", + "http://127.0.0.1:8080", + "http://127.42.0.1:8080", + "http://[::1]:8080" + ], + "rejected": [ + "http://issuer.example.com", + "https://user@issuer.example.com", + "https://issuer.example.com/#fragment" + ] + }, + "redaction": { + "secret": "conformance-secret", + "supplier_error": "supplier-sensitive-detail", + "provider_body": "provider-sensitive-detail" + } +} diff --git a/sdk/go/README.md b/sdk/go/README.md index 9357ffa412..42207faf13 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -179,15 +179,35 @@ token, err := oidc.DeviceLogin(ctx, ) ``` -For service accounts, use client credentials: +For a one-shot service-account token exchange, use `ClientCredentials`. For a +long-running SDK client, attach the renewable, memory-only auth provider: ```go -token, err := oidc.ClientCredentials(ctx, +auth, err := oidc.NewClientCredentialsAuth( oidc.WithGateway("my-gateway"), - oidc.WithClientSecret("service-secret"), + oidc.WithClientSecretProvider(func(context.Context) (string, error) { + return os.Getenv("OPENSHELL_OIDC_CLIENT_SECRET"), nil + }), ) +if err != nil { + log.Fatal(err) +} + +client, err := v1.NewClient(v1.Config{ + Address: "gateway.example.com:443", + Auth: auth, + TLS: tlsConfig, +}) +if err != nil { + log.Fatal(err) +} +defer client.Close() ``` +You can use explicit `WithIssuer`, `WithClientID`, `WithScopes`, and +`WithAudience` options instead of `WithGateway`. The provider repeats the grant +before expiry and never writes the secret or access token to disk. + See the [oidc package docs](https://pkg.go.dev/github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/oidc) for all options and flows. See the [Getting Started](https://ro14nd.de/openshell-sdk-go/getting-started.html) guide for the full walkthrough. @@ -281,7 +301,7 @@ consumers import a single package. See the [Architecture](https://ro14nd.de/open | Typed errors (`IsNotFound`, `IsAlreadyExists`, `IsConflict`, ...) | `StatusError` | [Error Handling](https://ro14nd.de/openshell-sdk-go/error-handling.html) | | Real-time watch with auto-stop on terminal phase | `WatchInterface[T]` | [Sandboxes](https://ro14nd.de/openshell-sdk-go/api/sandboxes.html) | | Fake client for testing (no gRPC server needed) | `fake.Client` | [Testing](https://ro14nd.de/openshell-sdk-go/testing.html) | -| OIDC login (browser, keyboard, device code, client credentials) | `oidc.Login`, `oidc.DeviceLogin`, `oidc.ClientCredentials` | [OIDC](https://pkg.go.dev/github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/oidc) | +| OIDC login and renewable service auth | `oidc.Login`, `oidc.DeviceLogin`, `oidc.ClientCredentials`, `oidc.NewClientCredentialsAuth` | [OIDC](https://pkg.go.dev/github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/oidc) | | Gateway config convenience (load CLI gateway configs, auto-wire auth) | `gateway.NewClient`, `gateway.LoadConfig` | [Gateway](https://ro14nd.de/openshell-sdk-go/api/gateway.html) | ## Prerequisites diff --git a/sdk/go/docs/src/api/oidc.md b/sdk/go/docs/src/api/oidc.md index ae30f35a1c..110015ae60 100644 --- a/sdk/go/docs/src/api/oidc.md +++ b/sdk/go/docs/src/api/oidc.md @@ -69,6 +69,17 @@ Performs a non-interactive OAuth2 client credentials grant. Requires combined with `WithClientSecret`). The client secret is never included in error messages. +### NewClientCredentialsAuth + +```go +func NewClientCredentialsAuth(opts ...LoginOption) (types.AuthProvider, error) +``` + +Returns a lazy renewable auth provider for `v1.Config.Auth` or +`gateway.WithAuth`. It caches access tokens in memory, renews within 30 seconds +of expiry, coalesces concurrent exchanges, and fails closed if renewal fails. +Use `WithClientSecretProvider` when the application rotates secret material. + ## Options | Option | Description | @@ -76,7 +87,9 @@ in error messages. | `WithIssuer(url)` | OIDC provider issuer URL | | `WithClientID(id)` | OAuth2 client ID | | `WithClientSecret(secret)` | OAuth2 client secret (for client credentials) | -| `WithScopes(scopes...)` | Custom scopes (default: openid, profile, email) | +| `WithClientSecretProvider(provider)` | Resolve the client secret for each exchange | +| `WithAudience(audience)` | Optional OAuth2 resource-server audience | +| `WithScopes(scopes...)` | Custom scopes (client credentials has no implicit scopes) | | `WithCallbackPort(port)` | Fixed port for localhost callback (default: tries 8000, then 18000) | | `WithTimeout(d)` | Auth flow timeout (default: 2 minutes) | | `WithKeyboardFlow()` | Use keyboard flow instead of browser | diff --git a/sdk/go/openshell/v1/gateway/config.go b/sdk/go/openshell/v1/gateway/config.go index c0813986e9..c35581653b 100644 --- a/sdk/go/openshell/v1/gateway/config.go +++ b/sdk/go/openshell/v1/gateway/config.go @@ -76,6 +76,12 @@ type Config struct { // OIDCClientID is the OAuth2 client ID read from metadata.json. // Empty when the gateway does not use OIDC auth. OIDCClientID string + + // OIDCAudience is the optional resource-server audience. + OIDCAudience string + + // OIDCScopes contains the configured space-separated OAuth2 scopes. + OIDCScopes string } // Info is a lightweight summary of a gateway for listing purposes. @@ -100,6 +106,8 @@ type metadataJSON struct { Name string `json:"name"` OIDCIssuer string `json:"oidc_issuer"` OIDCClientID string `json:"oidc_client_id"` + OIDCAudience string `json:"oidc_audience"` + OIDCScopes string `json:"oidc_scopes"` } // parseAuthMode converts a raw auth_mode string to the typed AuthMode. @@ -153,5 +161,7 @@ func parseMetadata(dir string) (*Config, error) { Dir: dir, OIDCIssuer: meta.OIDCIssuer, OIDCClientID: meta.OIDCClientID, + OIDCAudience: meta.OIDCAudience, + OIDCScopes: meta.OIDCScopes, }, nil } diff --git a/sdk/go/openshell/v1/gateway/config_test.go b/sdk/go/openshell/v1/gateway/config_test.go index 8411d2eef9..8ec604d2fc 100644 --- a/sdk/go/openshell/v1/gateway/config_test.go +++ b/sdk/go/openshell/v1/gateway/config_test.go @@ -158,6 +158,8 @@ func TestParseMetadata_OIDCFields(t *testing.T) { "name":"oidc-gw", "oidc_issuer":"https://auth.example.com", "oidc_client_id":"my-client-id" + ,"oidc_audience":"openshell-api", + "oidc_scopes":"sandbox:read sandbox:write" }`) cfg, err := parseMetadata(dir) @@ -166,6 +168,8 @@ func TestParseMetadata_OIDCFields(t *testing.T) { assert.Equal(t, AuthModeOIDC, cfg.AuthMode) assert.Equal(t, "https://auth.example.com", cfg.OIDCIssuer) assert.Equal(t, "my-client-id", cfg.OIDCClientID) + assert.Equal(t, "openshell-api", cfg.OIDCAudience) + assert.Equal(t, "sandbox:read sandbox:write", cfg.OIDCScopes) } func TestParseMetadata_OIDCFieldsMissing(t *testing.T) { diff --git a/sdk/go/openshell/v1/oidc/credentials.go b/sdk/go/openshell/v1/oidc/credentials.go index b88fe7e3df..e0971442b2 100644 --- a/sdk/go/openshell/v1/oidc/credentials.go +++ b/sdk/go/openshell/v1/oidc/credentials.go @@ -28,6 +28,14 @@ import ( // // The client secret is never included in error messages (FR-014). func ClientCredentials(ctx context.Context, opts ...LoginOption) (*oauth2.Token, error) { + cfg, err := resolveClientCredentialsConfig(opts...) + if err != nil { + return nil, err + } + return exchangeClientCredentials(ctx, cfg, false) +} + +func resolveClientCredentialsConfig(opts ...LoginOption) (*loginConfig, error) { cfg := &loginConfig{} for _, opt := range opts { opt(cfg) @@ -50,14 +58,19 @@ func ClientCredentials(ctx context.Context, opts ...LoginOption) (*oauth2.Token, if err != nil { return nil, fmt.Errorf("failed to load gateway %q: %w", cfg.gateway, err) } - if gwCfg.OIDCIssuer == "" || gwCfg.OIDCClientID == "" { - return nil, fmt.Errorf( - "%w: gateway %q has no OIDC configuration (missing oidc_issuer or oidc_client_id in metadata.json)", - ErrOIDCConfig, cfg.gateway, - ) + if cfg.issuer == "" { + cfg.issuer = gwCfg.OIDCIssuer + } + if cfg.clientID == "" { + cfg.clientID = gwCfg.OIDCClientID + } + if !cfg.audienceSet { + cfg.audience = gwCfg.OIDCAudience + } + if !cfg.scopesSet && gwCfg.OIDCScopes != "" { + cfg.scopes = strings.Fields(gwCfg.OIDCScopes) + cfg.scopesSet = true } - cfg.issuer = gwCfg.OIDCIssuer - cfg.clientID = gwCfg.OIDCClientID } // Validate required configuration. @@ -67,12 +80,27 @@ func ClientCredentials(ctx context.Context, opts ...LoginOption) (*oauth2.Token, ErrOIDCConfig, ) } - if cfg.clientSecret == "" { + if cfg.clientSecret == "" && cfg.secretProvider == nil { return nil, fmt.Errorf( - "%w: client secret is required (use WithClientSecret)", + "%w: client secret is required (use WithClientSecret or WithClientSecretProvider)", ErrClientCredentials, ) } + return cfg, nil +} + +func exchangeClientCredentials(ctx context.Context, cfg *loginConfig, requirePositiveExpiry bool) (*oauth2.Token, error) { + secret := cfg.clientSecret + if cfg.secretProvider != nil { + value, err := cfg.secretProvider(ctx) + if err != nil { + return nil, fmt.Errorf("%w: client secret provider failed", ErrClientCredentials) + } + secret = value + } + if secret == "" { + return nil, fmt.Errorf("%w: client secret must not be empty", ErrClientCredentials) + } // Discover provider endpoints. provider, err := discover(ctx, cfg.issuer) @@ -84,11 +112,14 @@ func ClientCredentials(ctx context.Context, opts ...LoginOption) (*oauth2.Token, data := url.Values{ "grant_type": {"client_credentials"}, "client_id": {cfg.clientID}, - "client_secret": {cfg.clientSecret}, + "client_secret": {secret}, } if len(cfg.scopes) > 0 { data.Set("scope", strings.Join(cfg.scopes, " ")) } + if cfg.audience != "" { + data.Set("audience", cfg.audience) + } req, err := http.NewRequestWithContext(ctx, http.MethodPost, provider.TokenEndpoint, strings.NewReader(data.Encode())) if err != nil { @@ -110,27 +141,25 @@ func ClientCredentials(ctx context.Context, opts ...LoginOption) (*oauth2.Token, defer func() { _ = resp.Body.Close() }() const maxResponseBytes = 1 << 20 - body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1)) if err != nil { return nil, fmt.Errorf("%w: failed to read token response", ErrClientCredentials) } + if len(body) > maxResponseBytes { + return nil, fmt.Errorf("%w: token response is too large", ErrClientCredentials) + } var tokResp tokenResponse if err := json.Unmarshal(body, &tokResp); err != nil { return nil, fmt.Errorf("%w: invalid token response JSON", ErrClientCredentials) } + var fields map[string]json.RawMessage + if err := json.Unmarshal(body, &fields); err != nil { + return nil, fmt.Errorf("%w: invalid token response JSON", ErrClientCredentials) + } if resp.StatusCode != http.StatusOK || tokResp.Error != "" { - // FR-014: Never include the client secret in error messages. - // Only include the provider's error code and description. - msg := "client credentials exchange failed" - if tokResp.Error != "" { - msg = fmt.Sprintf("provider error: %s", tokResp.Error) - if tokResp.ErrorDesc != "" { - msg += ": " + tokResp.ErrorDesc - } - } - return nil, fmt.Errorf("%w: %s", ErrClientCredentials, msg) + return nil, fmt.Errorf("%w: provider rejected the exchange (HTTP %d)", ErrClientCredentials, resp.StatusCode) } if tokResp.AccessToken == "" { @@ -142,9 +171,17 @@ func ClientCredentials(ctx context.Context, opts ...LoginOption) (*oauth2.Token, RefreshToken: tokResp.RefreshToken, TokenType: tokResp.TokenType, } - if tokResp.ExpiresIn > 0 { - tok.Expiry = time.Now().Add(time.Duration(tokResp.ExpiresIn) * time.Second) + _, expiresInPresent := fields["expires_in"] + if expiresInPresent && tokResp.ExpiresIn <= 0 { + return nil, fmt.Errorf("%w: token response requires a positive expires_in", ErrClientCredentials) + } + if !expiresInPresent { + if requirePositiveExpiry { + return nil, fmt.Errorf("%w: token response requires a positive expires_in", ErrClientCredentials) + } + return tok, nil } + tok.Expiry = time.Now().Add(time.Duration(tokResp.ExpiresIn) * time.Second) return tok, nil } diff --git a/sdk/go/openshell/v1/oidc/credentials_auth.go b/sdk/go/openshell/v1/oidc/credentials_auth.go new file mode 100644 index 0000000000..d4a511d900 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/credentials_auth.go @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "sync" + "time" + + "golang.org/x/oauth2" + "golang.org/x/sync/singleflight" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +const clientCredentialsLeeway = 30 * time.Second + +type clientCredentialsAuth struct { + cfg *loginConfig + mu sync.Mutex + token *oauth2.Token + group singleflight.Group +} + +// NewClientCredentialsAuth returns a renewable, memory-only client-credentials +// AuthProvider suitable for v1.Config.Auth or gateway.WithAuth. Acquisition is +// lazy, concurrent RPCs share one exchange, and expired tokens are never used +// when renewal fails. +func NewClientCredentialsAuth(opts ...LoginOption) (types.AuthProvider, error) { + cfg, err := resolveClientCredentialsConfig(opts...) + if err != nil { + return nil, err + } + return &clientCredentialsAuth{cfg: cfg}, nil +} + +func (a *clientCredentialsAuth) GetRequestMetadata(ctx context.Context, _ ...string) (map[string]string, error) { + a.mu.Lock() + if a.token != nil && time.Now().Add(clientCredentialsLeeway).Before(a.token.Expiry) { + accessToken := a.token.AccessToken + a.mu.Unlock() + return map[string]string{"authorization": "Bearer " + accessToken}, nil + } + a.mu.Unlock() + + result := a.group.DoChan("exchange", func() (any, error) { + exchangeCtx, cancel := context.WithTimeout(context.Background(), a.cfg.timeout) + defer cancel() + token, err := exchangeClientCredentials(exchangeCtx, a.cfg, true) + if err != nil { + return nil, err + } + a.mu.Lock() + a.token = token + a.mu.Unlock() + return token.AccessToken, nil + }) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case exchange := <-result: + if exchange.Err != nil { + return nil, exchange.Err + } + return map[string]string{"authorization": "Bearer " + exchange.Val.(string)}, nil + } +} + +func (*clientCredentialsAuth) RequireTransportSecurity() bool { return true } + +func (*clientCredentialsAuth) String() string { return "oidc.ClientCredentialsAuth" } + +func (*clientCredentialsAuth) GoString() string { return "oidc.ClientCredentialsAuth{}" } diff --git a/sdk/go/openshell/v1/oidc/credentials_test.go b/sdk/go/openshell/v1/oidc/credentials_test.go index f87c6b31fb..51266202cb 100644 --- a/sdk/go/openshell/v1/oidc/credentials_test.go +++ b/sdk/go/openshell/v1/oidc/credentials_test.go @@ -7,8 +7,14 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" + "net/url" + "os" + "path/filepath" + "sync" + "sync/atomic" "testing" "time" @@ -18,6 +24,370 @@ import ( "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" ) +type clientCredentialsFixture struct { + Request struct { + GrantType string `json:"grant_type"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + Scopes []string `json:"scopes"` + Scope string `json:"scope"` + Audience string `json:"audience"` + } `json:"request"` + Expiry struct { + LeewaySeconds int `json:"leeway_seconds"` + ValidExpiresIn int64 `json:"valid_expires_in"` + InvalidExpiresIn []json.RawMessage `json:"invalid_expires_in"` + } `json:"expiry"` + Discovery struct { + ConfiguredIssuer string `json:"configured_issuer"` + MatchingIssuer string `json:"matching_issuer"` + MismatchedIssuer string `json:"mismatched_issuer"` + RedirectStatuses []int `json:"redirect_statuses"` + } `json:"discovery"` + Limits struct { + MaxResponseBytes int `json:"max_response_bytes"` + } `json:"limits"` + URLs struct { + Allowed []string `json:"allowed"` + Rejected []string `json:"rejected"` + } `json:"urls"` +} + +func loadClientCredentialsFixture(t *testing.T) clientCredentialsFixture { + t.Helper() + data, err := os.ReadFile(filepath.Join("..", "..", "..", "..", "conformance", "oauth-client-credentials.json")) + require.NoError(t, err) + var fixture clientCredentialsFixture + require.NoError(t, json.Unmarshal(data, &fixture)) + return fixture +} + +func TestClientCredentialsConformanceFixture(t *testing.T) { + fixture := loadClientCredentialsFixture(t) + assert.Equal(t, int(clientCredentialsLeeway/time.Second), fixture.Expiry.LeewaySeconds) + for _, raw := range fixture.URLs.Allowed { + assert.NoError(t, validateSecureURL("issuer", raw), raw) + } + for _, raw := range fixture.URLs.Rejected { + assert.Error(t, validateSecureURL("issuer", raw), raw) + } +} + +func TestClientCredentialsRejectsInvalidExpiryConformance(t *testing.T) { + fixture := loadClientCredentialsFixture(t) + for _, raw := range fixture.Expiry.InvalidExpiresIn { + raw := raw + t.Run(string(raw), func(t *testing.T) { + resetDiscoveryCache() + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/.well-known/openid-configuration" { + _ = json.NewEncoder(w).Encode(map[string]string{ + "issuer": server.URL, + "authorization_endpoint": server.URL + "/authorize", + "token_endpoint": server.URL + "/token", + }) + return + } + _, _ = fmt.Fprintf(w, `{"access_token":"sensitive-access-token","expires_in":%s}`, raw) + })) + t.Cleanup(server.Close) + + _, err := ClientCredentials( + context.Background(), + WithIssuer(server.URL), + WithClientID("client"), + WithClientSecret("secret"), + ) + require.Error(t, err) + assert.NotContains(t, err.Error(), "sensitive-access-token") + }) + } +} + +func TestClientCredentialsMissingExpiryCompatibility(t *testing.T) { + resetDiscoveryCache() + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/.well-known/openid-configuration" { + _ = json.NewEncoder(w).Encode(map[string]string{ + "issuer": server.URL, + "authorization_endpoint": server.URL + "/authorize", + "token_endpoint": server.URL + "/token", + }) + return + } + _, _ = w.Write([]byte(`{"access_token":"token","token_type":"Bearer"}`)) + })) + t.Cleanup(server.Close) + + opts := []LoginOption{ + WithIssuer(server.URL), + WithClientID("client"), + WithClientSecret("secret"), + } + token, err := ClientCredentials(context.Background(), opts...) + require.NoError(t, err) + assert.True(t, token.Expiry.IsZero()) + + auth, err := NewClientCredentialsAuth(opts...) + require.NoError(t, err) + metadata, err := auth.GetRequestMetadata(context.Background()) + require.Error(t, err) + assert.Nil(t, metadata) + assert.Contains(t, err.Error(), "positive expires_in") +} + +func TestClientCredentialsRefusesRedirectsConformance(t *testing.T) { + fixture := loadClientCredentialsFixture(t) + for _, status := range fixture.Discovery.RedirectStatuses { + status := status + for _, endpoint := range []string{"discovery", "token"} { + endpoint := endpoint + t.Run(fmt.Sprintf("%s_%d", endpoint, status), func(t *testing.T) { + resetDiscoveryCache() + var server *httptest.Server + var redirected atomic.Bool + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/openid-configuration": + if endpoint == "discovery" { + w.Header().Set("Location", server.URL+"/redirected") + w.WriteHeader(status) + return + } + _ = json.NewEncoder(w).Encode(map[string]string{ + "issuer": server.URL, + "authorization_endpoint": server.URL + "/authorize", + "token_endpoint": server.URL + "/token", + }) + case "/token": + w.Header().Set("Location", server.URL+"/redirected") + w.WriteHeader(status) + case "/redirected": + redirected.Store(true) + } + })) + t.Cleanup(server.Close) + + _, err := ClientCredentials( + context.Background(), + WithIssuer(server.URL), + WithClientID("client"), + WithClientSecret("secret"), + ) + require.Error(t, err) + assert.False(t, redirected.Load()) + }) + } + } +} + +func TestClientCredentialsRejectsMismatchedIssuerConformance(t *testing.T) { + resetDiscoveryCache() + fixture := loadClientCredentialsFixture(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{ + "issuer": fixture.Discovery.MismatchedIssuer, + "authorization_endpoint": fixture.Discovery.MismatchedIssuer + "/authorize", + "token_endpoint": fixture.Discovery.MismatchedIssuer + "/token", + }) + })) + t.Cleanup(server.Close) + + _, err := ClientCredentials( + context.Background(), + WithIssuer(server.URL), + WithClientID("client"), + WithClientSecret("secret"), + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "issuer") +} + +func TestClientCredentialsRejectsOversizedResponseConformance(t *testing.T) { + resetDiscoveryCache() + fixture := loadClientCredentialsFixture(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(make([]byte, fixture.Limits.MaxResponseBytes+1)) + })) + t.Cleanup(server.Close) + + _, err := ClientCredentials( + context.Background(), + WithIssuer(server.URL), + WithClientID("client"), + WithClientSecret("secret"), + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "too large") +} + +func TestClientCredentialsAuthSingleFlightRenewalAndFields(t *testing.T) { + resetDiscoveryCache() + fixture := loadClientCredentialsFixture(t) + var server *httptest.Server + var calls atomic.Int32 + var formsMu sync.Mutex + var forms []url.Values + release := make(chan struct{}) + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/.well-known/openid-configuration" { + _ = json.NewEncoder(w).Encode(map[string]string{ + "issuer": server.URL, + "authorization_endpoint": server.URL + "/authorize", + "token_endpoint": server.URL + "/token", + }) + return + } + if calls.Add(1) == 1 { + <-release + } + require.NoError(t, r.ParseForm()) + formsMu.Lock() + forms = append(forms, r.Form) + formsMu.Unlock() + _, _ = w.Write([]byte(tokenResponseJSON("token", "", 3600))) + })) + t.Cleanup(server.Close) + + auth, err := NewClientCredentialsAuth( + WithIssuer(server.URL), + WithClientID(fixture.Request.ClientID), + WithClientSecret(fixture.Request.ClientSecret), + WithScopes(fixture.Request.Scopes...), + WithAudience(fixture.Request.Audience), + ) + require.NoError(t, err) + assert.NotContains(t, fmt.Sprintf("%#v", auth), "secret") + + results := make(chan error, 12) + for range 12 { + go func() { + metadata, callErr := auth.GetRequestMetadata(context.Background()) + if callErr == nil && metadata["authorization"] != "Bearer token" { + callErr = errors.New("unexpected authorization metadata") + } + results <- callErr + }() + } + require.Eventually(t, func() bool { return calls.Load() == 1 }, time.Second, time.Millisecond) + close(release) + for range 12 { + require.NoError(t, <-results) + } + assert.Equal(t, int32(1), calls.Load()) + require.Len(t, forms, 1) + assert.Equal(t, fixture.Request.GrantType, forms[0].Get("grant_type")) + assert.Equal(t, fixture.Request.ClientID, forms[0].Get("client_id")) + assert.Equal(t, fixture.Request.ClientSecret, forms[0].Get("client_secret")) + assert.Equal(t, fixture.Request.Scope, forms[0].Get("scope")) + assert.Equal(t, fixture.Request.Audience, forms[0].Get("audience")) + + concrete := auth.(*clientCredentialsAuth) + concrete.mu.Lock() + concrete.token.Expiry = time.Now() + concrete.mu.Unlock() + _, err = auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, int32(2), calls.Load()) +} + +func TestClientCredentialsAuthCancellationDoesNotPoisonSharedExchange(t *testing.T) { + resetDiscoveryCache() + var server *httptest.Server + started := make(chan struct{}) + release := make(chan struct{}) + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/.well-known/openid-configuration" { + _ = json.NewEncoder(w).Encode(map[string]string{ + "issuer": server.URL, + "authorization_endpoint": server.URL + "/authorize", + "token_endpoint": server.URL + "/token", + }) + return + } + close(started) + <-release + _, _ = w.Write([]byte(tokenResponseJSON("token", "", 3600))) + })) + t.Cleanup(server.Close) + auth, err := NewClientCredentialsAuth( + WithIssuer(server.URL), WithClientID("client"), WithClientSecret("secret"), + ) + require.NoError(t, err) + + canceled, cancel := context.WithCancel(context.Background()) + first := make(chan error, 1) + go func() { + _, callErr := auth.GetRequestMetadata(canceled) + first <- callErr + }() + <-started + second := make(chan error, 1) + go func() { + _, callErr := auth.GetRequestMetadata(context.Background()) + second <- callErr + }() + cancel() + assert.ErrorIs(t, <-first, context.Canceled) + close(release) + require.NoError(t, <-second) +} + +func TestClientCredentialsAuthRedactsSecretProviderError(t *testing.T) { + auth, err := NewClientCredentialsAuth( + WithIssuer("http://localhost:8080"), + WithClientID("client"), + WithClientSecretProvider(func(context.Context) (string, error) { + return "", errors.New("supplier-sensitive-detail") + }), + ) + require.NoError(t, err) + _, err = auth.GetRequestMetadata(context.Background()) + require.Error(t, err) + assert.NotContains(t, err.Error(), "supplier-sensitive-detail") +} + +func TestClientCredentialsAuthFailsClosedOnRenewalError(t *testing.T) { + resetDiscoveryCache() + var server *httptest.Server + var fail atomic.Bool + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/.well-known/openid-configuration" { + _ = json.NewEncoder(w).Encode(map[string]string{ + "issuer": server.URL, + "authorization_endpoint": server.URL + "/authorize", + "token_endpoint": server.URL + "/token", + }) + return + } + if fail.Load() { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"error":"provider-sensitive-detail"}`)) + return + } + _, _ = w.Write([]byte(tokenResponseJSON("stale", "", 3600))) + })) + t.Cleanup(server.Close) + auth, err := NewClientCredentialsAuth( + WithIssuer(server.URL), WithClientID("client"), WithClientSecret("secret"), + ) + require.NoError(t, err) + _, err = auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + concrete := auth.(*clientCredentialsAuth) + concrete.mu.Lock() + concrete.token.Expiry = time.Now() + concrete.mu.Unlock() + fail.Store(true) + metadata, err := auth.GetRequestMetadata(context.Background()) + require.Error(t, err) + assert.Nil(t, metadata) + assert.NotContains(t, err.Error(), "stale") + assert.NotContains(t, err.Error(), "provider-sensitive-detail") +} + // --- T023: Client credentials tests --- // setupCredentialsMockProvider creates a mock OIDC provider for client diff --git a/sdk/go/openshell/v1/oidc/discovery.go b/sdk/go/openshell/v1/oidc/discovery.go index d872027e88..1c1dcef2a2 100644 --- a/sdk/go/openshell/v1/oidc/discovery.go +++ b/sdk/go/openshell/v1/oidc/discovery.go @@ -16,7 +16,12 @@ import ( "time" ) -var oidcHTTPClient = &http.Client{Timeout: 30 * time.Second} +var oidcHTTPClient = &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, +} // providerConfig holds parsed fields from an OIDC discovery document // (.well-known/openid-configuration). @@ -115,17 +120,20 @@ func fetchDiscovery(ctx context.Context, issuer string) (*providerConfig, error) } const maxResponseBytes = 1 << 20 - body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1)) if err != nil { return nil, fmt.Errorf("%w: failed to read discovery response: %v", ErrDiscovery, err) } + if len(body) > maxResponseBytes { + return nil, fmt.Errorf("%w: discovery response is too large", ErrDiscovery) + } var cfg providerConfig if err := json.Unmarshal(body, &cfg); err != nil { return nil, fmt.Errorf("%w: invalid discovery JSON: %v", ErrDiscovery, err) } if normalizeIssuer(cfg.Issuer) != normalizeIssuer(issuer) { - return nil, fmt.Errorf("%w: discovery issuer %q does not match configured issuer %q", ErrDiscovery, cfg.Issuer, issuer) + return nil, fmt.Errorf("%w: discovery issuer does not match configured issuer", ErrDiscovery) } if cfg.TokenEndpoint == "" { diff --git a/sdk/go/openshell/v1/oidc/doc.go b/sdk/go/openshell/v1/oidc/doc.go index 2575a09127..1e3c0ac5a4 100644 --- a/sdk/go/openshell/v1/oidc/doc.go +++ b/sdk/go/openshell/v1/oidc/doc.go @@ -48,7 +48,7 @@ // // For non-interactive service accounts: // -// token, err := oidc.ClientCredentials(ctx, +// auth, err := oidc.NewClientCredentialsAuth( // oidc.WithIssuer("https://auth.example.com"), // oidc.WithClientID("my-service"), // oidc.WithClientSecret("secret"), diff --git a/sdk/go/openshell/v1/oidc/options.go b/sdk/go/openshell/v1/oidc/options.go index 00dcd1e741..d4d856ba4b 100644 --- a/sdk/go/openshell/v1/oidc/options.go +++ b/sdk/go/openshell/v1/oidc/options.go @@ -4,6 +4,7 @@ package oidc import ( + "context" "io" "time" @@ -22,17 +23,20 @@ const defaultTimeout = 2 * time.Minute // attempt. It is built by applying [LoginOption] functions to a // zero-value struct and then filling in defaults. type loginConfig struct { - issuer string - clientID string - clientSecret string - scopes []string - scopesSet bool - callbackPort int - timeout time.Duration - keyboardFlow bool - inMemory bool - displayFunc func(verificationURL, userCode string) - gateway string + issuer string + clientID string + clientSecret string + secretProvider func(context.Context) (string, error) + audience string + audienceSet bool + scopes []string + scopesSet bool + callbackPort int + timeout time.Duration + keyboardFlow bool + inMemory bool + displayFunc func(verificationURL, userCode string) + gateway string // Internal fields for testing. Not exposed via public API. tokenDir string // override token directory @@ -75,13 +79,29 @@ func WithClientID(id string) LoginOption { } // WithClientSecret sets the client secret for the client credentials -// grant. Required for [ClientCredentials]. +// grant. Required for [ClientCredentials] and [NewClientCredentialsAuth]. func WithClientSecret(secret string) LoginOption { return func(c *loginConfig) { c.clientSecret = secret } } +// WithClientSecretProvider resolves the client secret for each exchange. +// Provider errors and returned secrets are never included in SDK errors. +func WithClientSecretProvider(provider func(context.Context) (string, error)) LoginOption { + return func(c *loginConfig) { + c.secretProvider = provider + } +} + +// WithAudience sets the optional OAuth2 resource-server audience. +func WithAudience(audience string) LoginOption { + return func(c *loginConfig) { + c.audience = audience + c.audienceSet = true + } +} + // WithScopes overrides the default scopes (openid, profile, email). // The provided scopes replace the defaults entirely. func WithScopes(scopes ...string) LoginOption { @@ -134,10 +154,9 @@ func WithDisplayFunc(fn func(verificationURL, userCode string)) LoginOption { } } -// WithGateway sets the gateway name for [DeviceLogin] and -// [ClientCredentials]. When set, OIDC config is read from the -// gateway's metadata.json and tokens are persisted to the gateway -// directory. +// WithGateway sets the gateway name for [DeviceLogin], [ClientCredentials], +// and [NewClientCredentialsAuth]. OIDC configuration is read from the +// gateway's metadata.json. Client-credentials tokens are never persisted. func WithGateway(name string) LoginOption { return func(c *loginConfig) { c.gateway = name diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index e12353bdeb..b0e7ed696c 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -45,10 +45,29 @@ await client.sandbox.delete(sandbox.name) ``` `connect()` constructs a lazy client; call `health()` when startup must verify -gateway reachability. Authentication material is static for the client's -lifetime, so create a new client after refreshing an OIDC or edge token. The -root client has no explicit close method because Connect does not retain a -dedicated session. Close operation-scoped streams and forward handles instead. +gateway reachability. Static `oidcToken` and `edgeToken` values remain fixed for +the client's lifetime. For long-running service automation, use the renewable +client-credentials provider: + +```ts +import { clientCredentials, OpenShellClient } from '@nvidia/openshell-sdk' + +const client = await OpenShellClient.connect({ + gateway, + oidcTokenProvider: clientCredentials({ + issuer: 'https://idp.example.com/realms/openshell', + clientId: 'openshell-service', + clientSecret: () => process.env.OPENSHELL_OIDC_CLIENT_SECRET!, + scopes: ['sandbox:read', 'sandbox:write'], + audience: 'openshell-gateway', + }), +}) +``` + +The provider discovers and validates the issuer, keeps credentials and tokens +in memory, and renews before expiry. The root client has no explicit close +method because Connect does not retain a dedicated session. Close +operation-scoped streams and forward handles instead. Express the create-time safety boundary with `policy`. Sandbox-scoped `setPolicy` cannot introduce static policy fields later, so set filesystem, landlock, diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 6dae221887..61cdce3871 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -3,11 +3,6 @@ // Public API surface for @nvidia/openshell-sdk. // -// OidcRefresher (single-flight OIDC refresh) is intentionally not yet exported. -// It is the one piece of genuinely shared, cross-language behavior; it will be -// added alongside a conformance suite that pins it byte-identical across the -// TypeScript, Python, and Go SDKs. - export type { ConnectOptions, EffectiveSettingView, @@ -42,3 +37,5 @@ export type { export { errorCode, OpenShellClient, SandboxClient } from './client.js'; export type { SdkErrorCode } from './errors.js'; export { SdkError } from './errors.js'; +export type { ClientCredentialsOptions, OidcTokenProvider } from './oidc.js'; +export { clientCredentials } from './oidc.js'; diff --git a/sdk/typescript/src/oidc.test.ts b/sdk/typescript/src/oidc.test.ts new file mode 100644 index 0000000000..88a8ef0af1 --- /dev/null +++ b/sdk/typescript/src/oidc.test.ts @@ -0,0 +1,262 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { clientCredentials } from './oidc.js'; + +const conformance = JSON.parse( + readFileSync(new URL('../../conformance/oauth-client-credentials.json', import.meta.url), 'utf8'), +) as { + request: Record; + urls: { allowed: string[]; rejected: string[] }; + expiry: { leeway_seconds: number; valid_expires_in: number; invalid_expires_in: unknown[] }; + discovery: { + configured_issuer: string; + matching_issuer: string; + mismatched_issuer: string; + redirect_statuses: number[]; + }; + limits: { max_response_bytes: number }; +}; + +const servers: ReturnType[] = []; +afterEach(async () => { + vi.useRealTimers(); + await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve())))); +}); + +async function providerServer( + expiresIn: unknown = conformance.expiry.valid_expires_in, +): Promise<{ issuer: string; forms: URLSearchParams[] }> { + const forms: URLSearchParams[] = []; + let issuer = ''; + const server = createServer((req, res) => { + if (req.url === '/.well-known/openid-configuration') { + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ issuer, token_endpoint: `${issuer}/token` })); + return; + } + let body = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => (body += chunk)); + req.on('end', () => { + forms.push(new URLSearchParams(body)); + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ access_token: `token-${forms.length}`, expires_in: expiresIn })); + }); + }); + servers.push(server); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('missing address'); + issuer = `http://127.0.0.1:${address.port}`; + return { issuer, forms }; +} + +describe('clientCredentials', () => { + it('accepts and rejects the shared URL vectors', () => { + for (const issuer of conformance.urls.allowed) { + expect(() => clientCredentials({ issuer, clientId: 'client', clientSecret: 'secret' })).not.toThrow(); + } + for (const issuer of conformance.urls.rejected) { + expect(() => clientCredentials({ issuer, clientId: 'client', clientSecret: 'secret' })).toThrow(); + } + expect(conformance.expiry.leeway_seconds).toBe(30); + }); + + it('sends exact explicit fields without interactive scopes and caches the token', async () => { + const { issuer, forms } = await providerServer(); + const provider = clientCredentials({ + issuer, + clientId: conformance.request.client_id as string, + clientSecret: conformance.request.client_secret as string, + scopes: conformance.request.scopes as string[], + audience: conformance.request.audience as string, + }); + expect(await provider.getToken()).toBe('token-1'); + expect(await provider.getToken()).toBe('token-1'); + expect(forms).toHaveLength(1); + expect(Object.fromEntries(forms[0])).toEqual( + Object.fromEntries( + ['grant_type', 'client_id', 'client_secret', 'scope', 'audience'].map((field) => [ + field, + conformance.request[field], + ]), + ), + ); + }); + + it('coalesces concurrent acquisition', async () => { + const { issuer, forms } = await providerServer(); + const provider = clientCredentials({ issuer, clientId: 'client', clientSecret: 'secret' }); + expect(await Promise.all(Array.from({ length: 12 }, () => provider.getToken()))).toEqual(Array(12).fill('token-1')); + expect(forms).toHaveLength(1); + }); + + it('renews inside the leeway instead of returning a stale token', async () => { + const { issuer, forms } = await providerServer(30); + const provider = clientCredentials({ issuer, clientId: 'client', clientSecret: 'secret' }); + expect(await provider.getToken()).toBe('token-1'); + expect(await provider.getToken()).toBe('token-2'); + expect(forms).toHaveLength(2); + }); + + it.each(conformance.expiry.invalid_expires_in)('rejects invalid expires_in value %j', async (expiresIn) => { + const { issuer } = await providerServer(expiresIn); + const provider = clientCredentials({ issuer, clientId: 'client', clientSecret: 'secret' }); + await expect(provider.getToken()).rejects.toThrow(/positive finite expires_in/); + }); + + it.each(conformance.discovery.redirect_statuses)('refuses discovery redirect status %i', async (status) => { + let issuer = ''; + let redirected = false; + const server = createServer((req, res) => { + if (req.url === '/redirected') redirected = true; + res.writeHead(status, { location: `${issuer}/redirected` }); + res.end(); + }); + servers.push(server); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('missing address'); + issuer = `http://127.0.0.1:${address.port}`; + const provider = clientCredentials({ issuer, clientId: 'client', clientSecret: 'secret' }); + await expect(provider.getToken()).rejects.toThrow(new RegExp(`HTTP ${status}`)); + expect(redirected).toBe(false); + }); + + it.each(conformance.discovery.redirect_statuses)('refuses token redirect status %i', async (status) => { + let issuer = ''; + let redirected = false; + const server = createServer((req, res) => { + if (req.url === '/.well-known/openid-configuration') { + res.end(JSON.stringify({ issuer, token_endpoint: `${issuer}/token` })); + return; + } + if (req.url === '/redirected') redirected = true; + res.writeHead(status, { location: `${issuer}/redirected` }); + res.end(); + }); + servers.push(server); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('missing address'); + issuer = `http://127.0.0.1:${address.port}`; + const provider = clientCredentials({ issuer, clientId: 'client', clientSecret: 'secret' }); + await expect(provider.getToken()).rejects.toThrow(new RegExp(`HTTP ${status}`)); + expect(redirected).toBe(false); + }); + + it('rejects a mismatched discovery issuer', async () => { + let issuer = ''; + const server = createServer((_req, res) => { + res.end( + JSON.stringify({ + issuer: conformance.discovery.mismatched_issuer, + token_endpoint: `${issuer}/token`, + }), + ); + }); + servers.push(server); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('missing address'); + issuer = `http://127.0.0.1:${address.port}`; + const provider = clientCredentials({ issuer, clientId: 'client', clientSecret: 'secret' }); + await expect(provider.getToken()).rejects.toThrow(/issuer mismatch/); + }); + + it('rejects responses larger than the shared bound', async () => { + let issuer = ''; + const server = createServer((req, res) => { + if (req.url === '/.well-known/openid-configuration') { + res.end('x'.repeat(conformance.limits.max_response_bytes + 1)); + return; + } + res.end(JSON.stringify({ issuer, token_endpoint: `${issuer}/token` })); + }); + servers.push(server); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('missing address'); + issuer = `http://127.0.0.1:${address.port}`; + const provider = clientCredentials({ issuer, clientId: 'client', clientSecret: 'secret' }); + await expect(provider.getToken()).rejects.toThrow(/too large/); + }); + + it('times out while reading a stalled body and retries cleanly', async () => { + let issuer = ''; + let discoveryCalls = 0; + const server = createServer((req, res) => { + if (req.url === '/.well-known/openid-configuration') { + discoveryCalls += 1; + if (discoveryCalls === 1) { + res.writeHead(200, { 'content-type': 'application/json' }); + res.write('{"issuer":'); + return; + } + res.end(JSON.stringify({ issuer, token_endpoint: `${issuer}/token` })); + return; + } + res.end(JSON.stringify({ access_token: 'recovered', expires_in: 120 })); + }); + servers.push(server); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('missing address'); + issuer = `http://127.0.0.1:${address.port}`; + const provider = clientCredentials({ issuer, clientId: 'client', clientSecret: 'secret', timeoutMs: 100 }); + + await expect(provider.getToken()).rejects.toThrow(/discovery request timed out/); + await expect(provider.getToken()).resolves.toBe('recovered'); + expect(discoveryCalls).toBe(2); + }); + + it('lets one waiter cancel without canceling the shared exchange', async () => { + let issuer = ''; + let releaseToken!: () => void; + let markStarted!: () => void; + const release = new Promise((resolve) => (releaseToken = resolve)); + const started = new Promise((resolve) => (markStarted = resolve)); + const server = createServer(async (req, res) => { + if (req.url === '/.well-known/openid-configuration') { + res.end(JSON.stringify({ issuer, token_endpoint: `${issuer}/token` })); + return; + } + markStarted(); + await release; + res.end(JSON.stringify({ access_token: 'shared', expires_in: 120 })); + }); + servers.push(server); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('missing address'); + issuer = `http://127.0.0.1:${address.port}`; + const provider = clientCredentials({ issuer, clientId: 'client', clientSecret: 'secret' }); + const controller = new AbortController(); + const canceled = provider.getToken(controller.signal); + await started; + const remaining = provider.getToken(); + controller.abort(); + await expect(canceled).rejects.toThrow(/canceled/); + releaseToken(); + await expect(remaining).resolves.toBe('shared'); + }); + + it('rejects remote plaintext and redacts supplier errors', async () => { + expect(() => clientCredentials({ issuer: 'http://example.com', clientId: 'c', clientSecret: 's' })).toThrow( + /HTTPS/, + ); + const { issuer } = await providerServer(); + const provider = clientCredentials({ + issuer, + clientId: 'client', + clientSecret: () => { + throw new Error('supplier-sensitive-detail'); + }, + }); + await expect(provider.getToken()).rejects.not.toThrow(/supplier-sensitive-detail/); + }); +}); diff --git a/sdk/typescript/src/oidc.ts b/sdk/typescript/src/oidc.ts new file mode 100644 index 0000000000..3a117a26b1 --- /dev/null +++ b/sdk/typescript/src/oidc.ts @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isIP } from 'node:net'; +import { SdkError } from './errors.js'; + +const EXPIRY_LEEWAY_MS = 30_000; +const MAX_RESPONSE_BYTES = 1 << 20; + +export interface OidcTokenProvider { + getToken(signal?: AbortSignal): Promise; +} + +export interface ClientCredentialsOptions { + issuer: string; + clientId: string; + clientSecret: string | (() => string | Promise); + scopes?: readonly string[]; + audience?: string; + /** Timeout for each complete discovery or token response, including its body. */ + timeoutMs?: number; +} + +interface CachedToken { + accessToken: string; + expiresAt: number; +} + +function isLoopback(hostname: string): boolean { + const host = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname; + if (host.toLowerCase() === 'localhost' || host === '::1') return true; + if (isIP(host) === 4) return host.startsWith('127.'); + return false; +} + +function secureUrl(name: string, raw: string): URL { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new SdkError('invalid_config', `invalid OAuth ${name} URL`); + } + if (url.username || url.password || url.hash) { + throw new SdkError('invalid_config', `OAuth ${name} URL must not contain userinfo or a fragment`); + } + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopback(url.hostname))) { + throw new SdkError('invalid_config', `OAuth ${name} URL must use HTTPS (HTTP is allowed only for loopback hosts)`); + } + return url; +} + +function withoutTrailingSlash(raw: string): string { + return raw.replace(/\/+$/, ''); +} + +async function boundedJson(response: Response, kind: string): Promise> { + if (!response.body) throw new SdkError('auth', `OAuth ${kind} returned an empty response`); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_RESPONSE_BYTES) { + await reader.cancel(); + throw new SdkError('auth', `OAuth ${kind} response is too large`); + } + chunks.push(value); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + const value: unknown = JSON.parse(new TextDecoder().decode(bytes)); + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('not an object'); + return value as Record; + } catch { + throw new SdkError('auth', `OAuth ${kind} returned invalid JSON`); + } +} + +function waitFor(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) return promise; + if (signal.aborted) return Promise.reject(new SdkError('canceled', 'OAuth token request was canceled')); + return new Promise((resolve, reject) => { + const canceled = () => reject(new SdkError('canceled', 'OAuth token request was canceled')); + signal.addEventListener('abort', canceled, { once: true }); + promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', canceled)); + }); +} + +class ClientCredentialsProvider implements OidcTokenProvider { + readonly #options: ClientCredentialsOptions; + readonly #issuer: string; + #tokenEndpoint?: string; + #cached?: CachedToken; + #inFlight?: Promise; + + constructor(options: ClientCredentialsOptions) { + if (!options.clientId) throw new SdkError('invalid_config', 'OAuth clientId is required'); + if (typeof options.clientSecret === 'string' && !options.clientSecret) { + throw new SdkError('invalid_config', 'OAuth clientSecret must not be empty'); + } + this.#issuer = withoutTrailingSlash(secureUrl('issuer', options.issuer).toString()); + this.#options = { ...options, scopes: options.scopes ? [...options.scopes] : undefined }; + } + + async getToken(signal?: AbortSignal): Promise { + if (this.#cached && Date.now() + EXPIRY_LEEWAY_MS < this.#cached.expiresAt) return this.#cached.accessToken; + if (!this.#inFlight) { + this.#inFlight = this.#exchange().finally(() => { + this.#inFlight = undefined; + }); + } + return waitFor(this.#inFlight, signal); + } + + async #secret(): Promise { + try { + const value = + typeof this.#options.clientSecret === 'string' + ? this.#options.clientSecret + : await this.#options.clientSecret(); + if (!value) throw new Error('empty'); + return value; + } catch { + throw new SdkError('auth', 'OAuth client secret supplier failed'); + } + } + + async #requestJson( + url: string, + init: RequestInit, + responseKind: string, + operation: string, + ): Promise> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.#options.timeoutMs ?? 30_000); + try { + const response = await fetch(url, { ...init, redirect: 'manual', signal: controller.signal }); + if (response.status !== 200) { + await response.body?.cancel(); + throw new SdkError('auth', `OAuth ${operation} failed with HTTP ${response.status}`); + } + return await boundedJson(response, responseKind); + } catch (error) { + if (error instanceof SdkError) throw error; + if (controller.signal.aborted) { + throw new SdkError('auth', `OAuth ${operation} request timed out`); + } + throw new SdkError('auth', 'OAuth client credentials request failed'); + } finally { + clearTimeout(timeout); + } + } + + async #exchange(): Promise { + if (!this.#tokenEndpoint) { + const discovery = await this.#requestJson( + `${this.#issuer}/.well-known/openid-configuration`, + { headers: { accept: 'application/json' } }, + 'discovery', + 'discovery', + ); + if (typeof discovery.issuer !== 'string' || withoutTrailingSlash(discovery.issuer) !== this.#issuer) { + throw new SdkError('auth', 'OAuth discovery issuer mismatch'); + } + if (typeof discovery.token_endpoint !== 'string') { + throw new SdkError('auth', 'OAuth discovery document is missing token_endpoint'); + } + this.#tokenEndpoint = secureUrl('token endpoint', discovery.token_endpoint).toString(); + } + + const form = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: this.#options.clientId, + client_secret: await this.#secret(), + }); + if (this.#options.scopes?.length) form.set('scope', this.#options.scopes.join(' ')); + if (this.#options.audience) form.set('audience', this.#options.audience); + const token = await this.#requestJson( + this.#tokenEndpoint, + { + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, + body: form, + }, + 'client credentials response', + 'client credentials exchange', + ); + if (typeof token.access_token !== 'string' || !token.access_token) { + throw new SdkError('auth', 'OAuth client credentials response is missing access_token'); + } + if (typeof token.expires_in !== 'number' || !Number.isFinite(token.expires_in) || token.expires_in <= 0) { + throw new SdkError('auth', 'OAuth client credentials response requires a positive finite expires_in'); + } + this.#cached = { accessToken: token.access_token, expiresAt: Date.now() + token.expires_in * 1000 }; + return token.access_token; + } +} + +export function clientCredentials(options: ClientCredentialsOptions): OidcTokenProvider { + return new ClientCredentialsProvider(options); +} diff --git a/sdk/typescript/src/transport.test.ts b/sdk/typescript/src/transport.test.ts index ff4469d893..7961b72516 100644 --- a/sdk/typescript/src/transport.test.ts +++ b/sdk/typescript/src/transport.test.ts @@ -5,9 +5,9 @@ // contract without a live gateway: only PEM bytes are validated, no handshake // is performed. -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { errorCode } from './errors.js'; -import { buildTransport } from './transport.js'; +import { authInterceptor, buildTransport } from './transport.js'; const pem = (label: string) => Buffer.from(`-----BEGIN ${label}-----\ntest\n-----END ${label}-----\n`); @@ -63,6 +63,12 @@ describe('buildTransport token exclusivity', () => { } }); + it('rejects a renewable provider combined with another token', () => { + const oidcTokenProvider = { getToken: async () => 'token' }; + const fn = () => buildTransport({ gateway: 'https://gw.local', oidcToken: 'a', oidcTokenProvider }); + expect(fn).toThrow(/mutually exclusive/); + }); + it('rejects edge tokens that could inject cookies or headers', () => { for (const edgeToken of ['', 'jwt; other=value', 'jwt\r\nx-injected: yes', 'jwt with spaces']) { const fn = () => buildTransport({ gateway: 'https://gw.local', edgeToken }); @@ -82,6 +88,29 @@ describe('buildTransport token exclusivity', () => { }); }); +describe('renewable bearer interceptor', () => { + it('awaits the provider and attaches its current token to every request', async () => { + const signal = new AbortController().signal; + const getToken = vi.fn().mockResolvedValueOnce('token-1').mockResolvedValueOnce('token-2'); + const seen: string[] = []; + const next = vi.fn(async (req: { header: Headers }) => { + seen.push(req.header.get('authorization') ?? ''); + return {} as never; + }); + const invoke = authInterceptor({ + gateway: 'https://gw.local', + oidcTokenProvider: { getToken }, + })(next as never); + + await invoke({ header: new Headers(), signal } as never); + await invoke({ header: new Headers(), signal } as never); + + expect(seen).toEqual(['Bearer token-1', 'Bearer token-2']); + expect(getToken).toHaveBeenNthCalledWith(1, signal); + expect(getToken).toHaveBeenNthCalledWith(2, signal); + }); +}); + describe('buildTransport plaintext auth guard', () => { it('rejects a token over http:// to a non-loopback host', () => { const fn = () => buildTransport({ gateway: 'http://gw.remote:8080', oidcToken: 'a' }); diff --git a/sdk/typescript/src/transport.ts b/sdk/typescript/src/transport.ts index 2d5092b894..7820d2c11b 100644 --- a/sdk/typescript/src/transport.ts +++ b/sdk/typescript/src/transport.ts @@ -13,6 +13,7 @@ import type { Interceptor, Transport } from '@connectrpc/connect'; import { createGrpcTransport } from '@connectrpc/connect-node'; import { SdkError } from './errors.js'; +import type { OidcTokenProvider } from './oidc.js'; export interface ConnectOptions { /** Gateway URL (`http://...` or `https://...`). */ @@ -29,23 +30,29 @@ export interface ConnectOptions { clientKey?: Buffer; /** Bearer token for direct OIDC auth. Mutually exclusive with edgeToken. */ oidcToken?: string; + /** Renewable OIDC bearer provider. Mutually exclusive with oidcToken and edgeToken. */ + oidcTokenProvider?: OidcTokenProvider; /** Cloudflare Access token. See the sidecar note above for CF-fronted gateways. */ edgeToken?: string; /** Disable TLS verification (dev/debug only). */ insecureSkipVerify?: boolean; /** - * Permit sending an auth token (oidcToken/edgeToken) over plaintext `http://` - * to a non-loopback host. Off by default: tokens over cleartext to a remote - * host leak credentials on the wire. Loopback hosts are always allowed. + * Permit sending an auth token (oidcToken/oidcTokenProvider/edgeToken) over + * plaintext `http://` to a non-loopback host. Off by default: tokens over + * cleartext to a remote host leak credentials on the wire. Loopback hosts are + * always allowed. */ allowInsecureAuth?: boolean; } // OIDC bearer takes precedence; otherwise attach the Cloudflare Access header + // cookie. No-op when neither token is set. -function authInterceptor(opts: ConnectOptions): Interceptor { +/** @internal Exported for transport contract tests; not part of the package root API. */ +export function authInterceptor(opts: ConnectOptions): Interceptor { return (next) => async (req) => { - if (opts.oidcToken) { + if (opts.oidcTokenProvider) { + req.header.set('authorization', `Bearer ${await opts.oidcTokenProvider.getToken(req.signal)}`); + } else if (opts.oidcToken) { req.header.set('authorization', `Bearer ${opts.oidcToken}`); } else if (opts.edgeToken) { req.header.set('cf-access-jwt-assertion', opts.edgeToken); @@ -71,8 +78,11 @@ function assertMtlsPair(opts: ConnectOptions): void { // silently prefers OIDC when both are set. Reject that ambiguity up front so a // caller does not think an edge token is in effect when it is being ignored. function assertTokenExclusivity(opts: ConnectOptions): void { - if (opts.oidcToken !== undefined && opts.edgeToken !== undefined) { - throw new SdkError('invalid_config', 'oidcToken and edgeToken are mutually exclusive'); + const configured = [opts.oidcToken, opts.oidcTokenProvider, opts.edgeToken].filter( + (value) => value !== undefined, + ).length; + if (configured > 1) { + throw new SdkError('invalid_config', 'oidcToken, oidcTokenProvider, and edgeToken are mutually exclusive'); } } @@ -96,7 +106,7 @@ function isLoopbackHost(host: string): boolean { // host puts the credential on the wire in the clear. Refuse it unless the caller // explicitly opts in. Loopback (local-dev / edge-sidecar) is always fine. function assertTokenTransportSecurity(opts: ConnectOptions): void { - const hasToken = opts.oidcToken !== undefined || opts.edgeToken !== undefined; + const hasToken = opts.oidcToken !== undefined || opts.oidcTokenProvider !== undefined || opts.edgeToken !== undefined; if (!hasToken || opts.allowInsecureAuth || opts.gateway.startsWith('https://')) return; let host: string; try { From 18ce13b9b1ab2a7e342073119dd9b9d7db5d08c1 Mon Sep 17 00:00:00 2001 From: Mrunal Patel Date: Tue, 25 Aug 2026 18:00:00 +0000 Subject: [PATCH 016/505] feat(providers): expose actionable OAuth refresh failures (#2887) * fix(providers): classify OAuth refresh failures Signed-off-by: Mrunal Patel * test(providers): add Keycloak refresh e2e lane Signed-off-by: Mrunal Patel * fix(providers): harden OAuth refresh recovery Signed-off-by: Mrunal Patel * fix(providers): classify post-mint refresh failures Signed-off-by: Mrunal Patel * fix(providers): tolerate malformed OAuth subtypes Signed-off-by: Mrunal Patel --------- Signed-off-by: Mrunal Patel --- .agents/skills/openshell-cli/SKILL.md | 12 + .github/workflows/e2e-test.yml | 6 + architecture/gateway.md | 9 + crates/openshell-cli/src/run.rs | 55 +- .../tests/provider_commands_integration.rs | 4 + crates/openshell-server/src/grpc/provider.rs | 20 +- .../openshell-server/src/provider_refresh.rs | 993 ++++++++++++- docs/sandboxes/providers-v2.mdx | 30 +- e2e/rust/Cargo.toml | 6 + e2e/rust/tests/provider_refresh_keycloak.rs | 417 ++++++ proto/openshell.proto | 33 + .../v1/internal/converter/refresh.go | 50 +- .../v1/internal/converter/refresh_test.go | 62 +- sdk/go/openshell/v1/types/refresh.go | 46 +- sdk/go/proto/openshellv1/openshell.pb.go | 1264 +++++++++-------- tasks/test.toml | 6 + 16 files changed, 2358 insertions(+), 655 deletions(-) create mode 100644 e2e/rust/tests/provider_refresh_keycloak.rs diff --git a/.agents/skills/openshell-cli/SKILL.md b/.agents/skills/openshell-cli/SKILL.md index 69a889a8e2..313e239883 100644 --- a/.agents/skills/openshell-cli/SKILL.md +++ b/.agents/skills/openshell-cli/SKILL.md @@ -207,6 +207,18 @@ output. Use `provider refresh rotate`, reconfigure refresh, or delete refresh before returning those keys to manual management. Unrelated provider fields remain updateable. +When OAuth refresh fails, inspect the `RECOVERY` and `FAILURE_CODE` columns from +`provider refresh status`; do not infer the remedy from HTTP status or parse +`LAST_ERROR`. `retry` means the worker will try again, `reauthorize` means the +user must obtain a new OAuth grant and run `provider refresh configure`, +`fix_configuration` means an operator must repair the OAuth client, scopes, or +administrator policy, and `investigate` means the issuer returned an +unrecognized response. The gateway parks `reauthorize` records until a manual +rotate or reconfiguration. It retries +`fix_configuration` records hourly so externally repaired configuration can +recover without rapid token-endpoint traffic. The existing access credential +remains usable only until its recorded expiry. + --- ## Workflow 3: Sandbox Lifecycle diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 3e6a972874..f176953048 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -157,6 +157,12 @@ jobs: podman_package_version: "5.7.0+ds2-3build1" conmon_package_version: "2.1.13+ds1-2" cmd: "env -u OPENSHELL_GATEWAY_BIN mise run --no-deps --skip-deps e2e:podman:external-driver" + - suite: provider-refresh-keycloak + 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:provider-refresh-keycloak" env: IMAGE_TAG: ${{ inputs.image-tag }} MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/architecture/gateway.md b/architecture/gateway.md index ed12498e88..f86f855845 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -351,6 +351,15 @@ 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. +OAuth refresh failures retain a gateway-owned recovery classification alongside +the refresh state. The gateway reads only a bounded error response and maps +recognized OAuth codes to retry, reauthorization, configuration repair, or +investigation without persisting issuer-controlled descriptions. Terminal +reauthorization failures remain parked until a manual retry or explicit refresh +reconfiguration. Configuration failures retry hourly so an externally repaired +clock, policy, or stored credential can recover without rapid endpoint traffic; +short-lived credentials still fail closed at their recorded expiry. + Credential handles remain bound to the driver that created them. Before the 0.1.0 compatibility boundary, gateways do not migrate inline refresh material or move handles between credential drivers; operators reconfigure affected diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index af8b7d5fd2..0a0b21a7f4 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -46,7 +46,8 @@ use openshell_core::proto::{ GetSandboxRequest, GetServiceRequest, GpuResourceRequirements, ImportProviderProfilesRequest, LintProviderProfilesRequest, ListProviderProfilesRequest, ListProvidersRequest, ListSandboxPoliciesRequest, ListSandboxProvidersRequest, ListSandboxesRequest, - ListServicesRequest, PolicySource, PolicyStatus, Provider, ProviderCredentialRefreshStatus, + ListServicesRequest, PolicySource, PolicyStatus, Provider, + ProviderCredentialRefreshRecoveryAction, ProviderCredentialRefreshStatus, ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrantType, ProviderProfile, ProviderProfileDiagnostic, ProviderProfileImportItem, RejectDraftChunkRequest, ResourceRequirements, RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox, @@ -4338,14 +4339,16 @@ pub async fn provider_refresh_status( fn refresh_status_header() -> String { format!( - "{:<24} {:<28} {:<28} {:<18} {:<20} {:<20} {:<20} {}", + "{:<24} {:<28} {:<28} {:<24} {:<18} {:<20} {:<20} {:<20} {:<44} {}", "PROVIDER".bold(), "CREDENTIAL_KEY".bold(), "STRATEGY".bold(), "STATUS".bold(), + "RECOVERY".bold(), "EXPIRES_AT".bold(), "NEXT_REFRESH".bold(), "LAST_REFRESH".bold(), + "FAILURE_CODE".bold(), "LAST_ERROR".bold(), ) } @@ -4497,19 +4500,43 @@ fn print_refresh_status_row(status: &ProviderCredentialRefreshStatus) { fn refresh_status_row(status: &ProviderCredentialRefreshStatus) -> String { let strategy = ProviderCredentialRefreshStrategy::try_from(status.strategy) .unwrap_or(ProviderCredentialRefreshStrategy::Unspecified); + let recovery_action = ProviderCredentialRefreshRecoveryAction::try_from(status.recovery_action) + .unwrap_or(ProviderCredentialRefreshRecoveryAction::Unspecified); format!( - "{:<24} {:<28} {:<28} {:<18} {:<20} {:<20} {:<20} {}", + "{:<24} {:<28} {:<28} {:<24} {:<18} {:<20} {:<20} {:<20} {:<44} {}", status.provider_name, status.credential_key, provider_refresh_strategy_name(strategy), status.status, + provider_refresh_recovery_action_name(recovery_action), format_optional_epoch_ms(status.expires_at_ms), - format_optional_epoch_ms(status.next_refresh_at_ms), + format_refresh_next_at_ms(status.next_refresh_at_ms), format_optional_epoch_ms(status.last_refresh_at_ms), + status.failure_code, truncate_status_field(&status.last_error, 72), ) } +fn format_refresh_next_at_ms(next_refresh_at_ms: i64) -> String { + if next_refresh_at_ms == i64::MAX { + "-".to_string() + } else { + format_optional_epoch_ms(next_refresh_at_ms) + } +} + +fn provider_refresh_recovery_action_name( + action: ProviderCredentialRefreshRecoveryAction, +) -> &'static str { + match action { + ProviderCredentialRefreshRecoveryAction::Retry => "retry", + ProviderCredentialRefreshRecoveryAction::Reauthorize => "reauthorize", + ProviderCredentialRefreshRecoveryAction::FixConfiguration => "fix_configuration", + ProviderCredentialRefreshRecoveryAction::Investigate => "investigate", + ProviderCredentialRefreshRecoveryAction::Unspecified => "-", + } +} + fn provider_refresh_strategy_name(strategy: ProviderCredentialRefreshStrategy) -> &'static str { match strategy { ProviderCredentialRefreshStrategy::Static => "static", @@ -7403,10 +7430,11 @@ mod tests { }; use openshell_core::proto::{ GetSandboxConfigResponse, GpuResourceRequirements, PolicySource, PolicyStatus, Provider, - ProviderCredentialRefresh, ProviderCredentialRefreshStatus, - ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrant, ProviderProfile, - ProviderProfileCredential, ResourceRequirements, Sandbox, SandboxCondition, SandboxPhase, - SandboxPolicyRevision, SandboxStatus, datamodel::v1::ObjectMeta, + ProviderCredentialRefresh, ProviderCredentialRefreshRecoveryAction, + ProviderCredentialRefreshStatus, ProviderCredentialRefreshStrategy, + ProviderCredentialTokenGrant, ProviderProfile, ProviderProfileCredential, + ResourceRequirements, Sandbox, SandboxCondition, SandboxPhase, SandboxPolicyRevision, + SandboxStatus, datamodel::v1::ObjectMeta, }; #[test] @@ -7645,6 +7673,8 @@ mod tests { let header = refresh_status_header(); assert!(header.contains("NEXT_REFRESH")); assert!(header.contains("LAST_REFRESH")); + assert!(header.contains("RECOVERY")); + assert!(header.contains("FAILURE_CODE")); assert!(header.contains("LAST_ERROR")); let row = refresh_status_row(&ProviderCredentialRefreshStatus { @@ -7654,17 +7684,24 @@ mod tests { strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, status: "error".to_string(), expires_at_ms: 1_767_225_600_000, - next_refresh_at_ms: 1_767_225_660_000, + next_refresh_at_ms: i64::MAX, last_refresh_at_ms: 1_767_225_000_000, last_error: "token endpoint returned a very long error message that should be truncated for table readability" .to_string(), + recovery_action: ProviderCredentialRefreshRecoveryAction::Reauthorize as i32, + failure_code: "oauth_rotated_refresh_token_handle_missing".to_string(), + provider_error_subtype: "invalid_rapt".to_string(), + last_error_at_ms: 1_767_225_000_000, }); assert!(row.contains("my-graph")); assert!(row.contains("MS_GRAPH_ACCESS_TOKEN")); assert!(row.contains("oauth2_client_credentials")); assert!(row.contains("error")); + assert!(row.contains("reauthorize")); + assert!(row.contains("oauth_rotated_refresh_token_handle_missing")); assert!(row.contains("2026-01-01 00:00:00")); + assert!(!row.contains("292278994")); assert!(row.contains("...")); } diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index c349bbdbb4..2c71e0b39f 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -750,6 +750,10 @@ impl OpenShell for TestOpenShell { next_refresh_at_ms: 0, last_refresh_at_ms: 0, last_error: String::new(), + recovery_action: 0, + failure_code: String::new(), + provider_error_subtype: String::new(), + last_error_at_ms: 0, }; drop(providers); self.state diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index e20edc055f..c8c8149a82 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -4755,10 +4755,11 @@ mod tests { ImportProviderProfilesRequest, L7Allow, L7Rule, LintProviderProfilesRequest, ListProviderProfilesRequest, ListProvidersRequest, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProviderCredentialRefresh, ProviderCredentialRefreshMaterial, - ProviderCredentialTokenGrant, ProviderCredentialTokenGrantAudienceOverride, - ProviderProfile, ProviderProfileCategory, ProviderProfileCredential, - ProviderProfileImportItem, RotateProviderCredentialRequest, Sandbox, SandboxPolicy, - SandboxSpec, StoredProviderProfile, UpdateProviderProfilesRequest, UpdateProviderRequest, + ProviderCredentialRefreshRecoveryAction, ProviderCredentialTokenGrant, + ProviderCredentialTokenGrantAudienceOverride, ProviderProfile, ProviderProfileCategory, + ProviderProfileCredential, ProviderProfileImportItem, RotateProviderCredentialRequest, + Sandbox, SandboxPolicy, SandboxSpec, StoredProviderProfile, UpdateProviderProfilesRequest, + UpdateProviderRequest, }; use openshell_core::{ObjectId, ObjectName}; use tonic::{Code, Request}; @@ -11988,7 +11989,16 @@ mod tests { .await .unwrap() .expect("refresh state should exist"); - assert_eq!(refresh_state.status, "error"); + assert_eq!(refresh_state.status, "configuration_required"); + assert_eq!( + refresh_state.recovery_action, + ProviderCredentialRefreshRecoveryAction::FixConfiguration as i32 + ); + assert_eq!(refresh_state.failure_code, "refresh_configuration_invalid"); + assert_eq!( + refresh_state.next_refresh_at_ms - refresh_state.last_error_at_ms, + 60 * 60 * 1000 + ); } #[tokio::test] diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index f562e1d18e..176f86ae22 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -9,7 +9,8 @@ use crate::credentials::RefreshMaterialScope; use crate::persistence::{ObjectType, PersistenceError, Store, WriteCondition, current_time_ms}; use openshell_core::ObjectWorkspace; use openshell_core::proto::{ - CredentialHandle, Provider, ProviderCredentialRefreshStatus, ProviderCredentialRefreshStrategy, + CredentialHandle, Provider, ProviderCredentialRefreshRecoveryAction, + ProviderCredentialRefreshStatus, ProviderCredentialRefreshStrategy, StoredProviderCredentialRefreshState, StoredRefreshMaterialDeletion, }; use openshell_core::{ObjectId, ObjectName, SetResourceVersion}; @@ -17,13 +18,15 @@ use prost::Message; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::time::Duration; -use tonic::Status; +use tonic::{Code, Status}; use tracing::{info, warn}; const DEFAULT_REFRESH_BEFORE_SECONDS: i64 = 300; const DEFAULT_MAX_LIFETIME_SECONDS: i64 = 3600; const REFRESH_ERROR_RETRY_SECONDS: i64 = 60; +const REFRESH_CONFIGURATION_RETRY_SECONDS: i64 = 60 * 60; const REFRESH_WORKER_PAGE_SIZE: u32 = 1000; +const MAX_OAUTH_ERROR_RESPONSE_BYTES: usize = 8 * 1024; pub fn refresh_material_scope( state: &StoredProviderCredentialRefreshState, @@ -321,6 +324,10 @@ pub fn refresh_status_from_state( next_refresh_at_ms: state.next_refresh_at_ms, last_refresh_at_ms: state.last_refresh_at_ms, last_error: state.last_error.clone(), + recovery_action: state.recovery_action, + failure_code: state.failure_code.clone(), + provider_error_subtype: state.provider_error_subtype.clone(), + last_error_at_ms: state.last_error_at_ms, } } @@ -385,6 +392,10 @@ pub fn new_refresh_state( authorization_epoch: uuid::Uuid::new_v4().to_string(), secret_material_handles: HashMap::new(), pending_secret_deletions: Vec::new(), + recovery_action: ProviderCredentialRefreshRecoveryAction::Unspecified as i32, + failure_code: String::new(), + provider_error_subtype: String::new(), + last_error_at_ms: 0, }) } @@ -403,6 +414,130 @@ struct TokenResponse { refresh_token: Option, } +#[derive(Debug, Deserialize)] +struct OAuthErrorResponse { + error: String, + #[serde(default, deserialize_with = "deserialize_optional_oauth_string")] + error_subtype: Option, +} + +fn deserialize_optional_oauth_string<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = serde_json::Value::deserialize(deserializer)?; + Ok(value.as_str().map(str::to_owned)) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum OAuthGrantKind { + UserRefreshToken, + NonInteractive, +} + +#[derive(Debug)] +struct RefreshFailure { + status: Status, + recovery_action: ProviderCredentialRefreshRecoveryAction, + failure_code: &'static str, + provider_error_subtype: Option<&'static str>, + retry_schedule: RefreshRetrySchedule, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RefreshRetrySchedule { + Short, + Configuration, + Parked, +} + +impl RefreshFailure { + fn retryable(status: Status, failure_code: &'static str) -> Self { + Self { + status, + recovery_action: ProviderCredentialRefreshRecoveryAction::Retry, + failure_code, + provider_error_subtype: None, + retry_schedule: RefreshRetrySchedule::Short, + } + } + + fn investigate(status: Status, failure_code: &'static str) -> Self { + Self { + status, + recovery_action: ProviderCredentialRefreshRecoveryAction::Investigate, + failure_code, + provider_error_subtype: None, + retry_schedule: RefreshRetrySchedule::Short, + } + } + + fn reauthorize( + status: Status, + failure_code: &'static str, + provider_error_subtype: Option<&'static str>, + ) -> Self { + Self { + status, + recovery_action: ProviderCredentialRefreshRecoveryAction::Reauthorize, + failure_code, + provider_error_subtype, + retry_schedule: RefreshRetrySchedule::Parked, + } + } + + fn fix_configuration(status: Status, failure_code: &'static str) -> Self { + Self { + status, + recovery_action: ProviderCredentialRefreshRecoveryAction::FixConfiguration, + failure_code, + provider_error_subtype: None, + retry_schedule: RefreshRetrySchedule::Configuration, + } + } + + fn fix_configuration_with_subtype( + status: Status, + failure_code: &'static str, + provider_error_subtype: &'static str, + ) -> Self { + Self { + status, + recovery_action: ProviderCredentialRefreshRecoveryAction::FixConfiguration, + failure_code, + provider_error_subtype: Some(provider_error_subtype), + retry_schedule: RefreshRetrySchedule::Configuration, + } + } + + fn into_status(self) -> Status { + self.status + } + + fn from_status(status: &Status) -> Self { + Self::from(Status::new(status.code(), status.message().to_string())) + } +} + +impl From for RefreshFailure { + fn from(status: Status) -> Self { + match status.code() { + Code::InvalidArgument + | Code::FailedPrecondition + | Code::PermissionDenied + | Code::Unauthenticated => { + Self::fix_configuration(status, "refresh_configuration_invalid") + } + Code::Unavailable + | Code::DeadlineExceeded + | Code::ResourceExhausted + | Code::Aborted + | Code::Internal => Self::retryable(status, "refresh_failed"), + _ => Self::investigate(status, "refresh_failed"), + } + } +} + #[derive(Debug, Serialize)] struct GoogleServiceAccountClaims<'a> { iss: &'a str, @@ -554,17 +689,51 @@ async fn cleanup_pending_secret_deletions( Ok(new_version) } -async fn persist_refresh_error_state( +async fn persist_retryable_refresh_error_state( store: &Store, state: &mut StoredProviderCredentialRefreshState, expected_version: u64, error: &Status, +) -> Result { + let failure = RefreshFailure::retryable( + Status::new(error.code(), error.message().to_string()), + "refresh_failed", + ); + persist_refresh_failure_state(store, state, expected_version, &failure).await +} + +async fn persist_refresh_failure_state( + store: &Store, + state: &mut StoredProviderCredentialRefreshState, + expected_version: u64, + failure: &RefreshFailure, ) -> Result { let now_ms = current_time_ms(); - state.status = "error".to_string(); - state.last_error = error.message().to_string(); - state.next_refresh_at_ms = - now_ms.saturating_add(REFRESH_ERROR_RETRY_SECONDS.saturating_mul(1000)); + state.status = match failure.recovery_action { + ProviderCredentialRefreshRecoveryAction::Retry + | ProviderCredentialRefreshRecoveryAction::Unspecified => "error", + ProviderCredentialRefreshRecoveryAction::Reauthorize => "reauthorization_required", + ProviderCredentialRefreshRecoveryAction::FixConfiguration => "configuration_required", + ProviderCredentialRefreshRecoveryAction::Investigate => "investigation_required", + } + .to_string(); + state.last_error = failure.status.message().to_string(); + state.recovery_action = failure.recovery_action as i32; + state.failure_code = failure.failure_code.to_string(); + state.provider_error_subtype = failure + .provider_error_subtype + .unwrap_or_default() + .to_string(); + state.last_error_at_ms = now_ms; + state.next_refresh_at_ms = match failure.retry_schedule { + RefreshRetrySchedule::Short => { + now_ms.saturating_add(REFRESH_ERROR_RETRY_SECONDS.saturating_mul(1000)) + } + RefreshRetrySchedule::Configuration => { + now_ms.saturating_add(REFRESH_CONFIGURATION_RETRY_SECONDS.saturating_mul(1000)) + } + RefreshRetrySchedule::Parked => i64::MAX, + }; let new_version = persist_refresh_state_if_current(store, state, expected_version) .await? .ok_or_else(|| { @@ -670,7 +839,8 @@ pub async fn refresh_provider_credential( // Otherwise disabling providers_v2_enabled leaves already-configured refresh // states that the worker and manual rotation keep minting from. if let Err(err) = ensure_refresh_providers_v2_gate(store, &state).await { - persist_refresh_error_state(store, &mut state, expected_version, &err).await?; + let failure = RefreshFailure::from_status(&err); + persist_refresh_failure_state(store, &mut state, expected_version, &failure).await?; warn!( provider = %state.provider_name, credential_key = %state.credential_key, @@ -684,7 +854,7 @@ pub async fn refresh_provider_credential( let mint_result = match resolve_refresh_material(Some(credentials), &state).await { Ok(transient_state) => mint_credential(&transient_state).await, - Err(err) => Err(err), + Err(err) => Err(err.into()), }; match mint_result { Ok(minted) => { @@ -716,19 +886,32 @@ pub async fn refresh_provider_credential( { Ok(handles) => handles, Err(store_err) => { - let err = Status::failed_precondition(format!( - "the OAuth provider rotated the refresh token, but the replacement could not be stored; the grant must be re-authorized: {}", - store_err.message() - )); - persist_refresh_error_state(store, &mut state, expected_version, &err) - .await?; - return Err(err); + let failure = RefreshFailure::reauthorize( + Status::failed_precondition(format!( + "the OAuth provider rotated the refresh token, but the replacement could not be stored; the grant must be re-authorized: {}", + store_err.message() + )), + "oauth_rotated_refresh_token_store_failed", + None, + ); + persist_refresh_failure_state( + store, + &mut state, + expected_version, + &failure, + ) + .await?; + return Err(failure.into_status()); } }; let Some(handle) = staged_refresh_token_handles.get("refresh_token").cloned() else { - let err = Status::failed_precondition( - "the OAuth provider rotated the refresh token, but the credential driver returned no replacement handle; the grant must be re-authorized", + let failure = RefreshFailure::reauthorize( + Status::failed_precondition( + "the OAuth provider rotated the refresh token, but the credential driver returned no replacement handle; the grant must be re-authorized", + ), + "oauth_rotated_refresh_token_handle_missing", + None, ); cleanup_staged_refresh_material_handles( credentials, @@ -736,8 +919,9 @@ pub async fn refresh_provider_credential( &staged_refresh_token_handles, ) .await; - persist_refresh_error_state(store, &mut state, expected_version, &err).await?; - return Err(err); + persist_refresh_failure_state(store, &mut state, expected_version, &failure) + .await?; + return Err(failure.into_status()); }; if let Some(previous) = state .secret_material_handles @@ -757,6 +941,10 @@ pub async fn refresh_provider_credential( state.last_refresh_at_ms = now_ms; state.status = "refreshed".to_string(); state.last_error.clear(); + state.recovery_action = ProviderCredentialRefreshRecoveryAction::Unspecified as i32; + state.failure_code.clear(); + state.provider_error_subtype.clear(); + state.last_error_at_ms = 0; // Claim the refresh generation with a version-matched write BEFORE // touching the provider. It succeeds only if the refresh still holds @@ -800,7 +988,13 @@ pub async fn refresh_provider_credential( // upstream-valid grant. If that also fails, leave the // staged object intact for operator recovery rather than // deleting an irreplaceable rotated token. - persist_refresh_error_state(store, &mut state, expected_version, &err).await?; + persist_retryable_refresh_error_state( + store, + &mut state, + expected_version, + &err, + ) + .await?; return Err(err); } }; @@ -819,7 +1013,8 @@ pub async fn refresh_provider_credential( { // Reflect the failure on the state we just wrote; skip silently // if it was deleted concurrently (it is not recreated). - persist_refresh_error_state(store, &mut state, new_version, &err).await?; + let failure = RefreshFailure::from_status(&err); + persist_refresh_failure_state(store, &mut state, new_version, &failure).await?; warn!( provider = %state.provider_name, credential_key = %state.credential_key, @@ -860,9 +1055,9 @@ pub async fn refresh_provider_credential( } Ok(state) } - Err(err) => { + Err(failure) => { let now_ms = current_time_ms(); - persist_refresh_error_state(store, &mut state, expected_version, &err).await?; + persist_refresh_failure_state(store, &mut state, expected_version, &failure).await?; warn!( provider = %state.provider_name, credential_key = %state.credential_key, @@ -870,10 +1065,12 @@ pub async fn refresh_provider_credential( status = %state.status, next_refresh_at_ms = state.next_refresh_at_ms, seconds_until_refresh = seconds_until_ms(now_ms, state.next_refresh_at_ms), - error = %err, + recovery_action = ?failure.recovery_action, + failure_code = failure.failure_code, + error = %failure.status, "provider credential refresh errored" ); - Err(err) + Err(failure.into_status()) } } } @@ -1106,7 +1303,7 @@ async fn ensure_refresh_providers_v2_gate( async fn mint_credential( state: &StoredProviderCredentialRefreshState, -) -> Result { +) -> Result { let strategy = ProviderCredentialRefreshStrategy::try_from(state.strategy) .unwrap_or(ProviderCredentialRefreshStrategy::Unspecified); match strategy { @@ -1126,13 +1323,14 @@ async fn mint_credential( | ProviderCredentialRefreshStrategy::Static | ProviderCredentialRefreshStrategy::Unspecified => Err(Status::failed_precondition( format!("refresh strategy '{strategy:?}' cannot be minted by the gateway"), - )), + ) + .into()), } } async fn mint_oauth2_refresh_token( state: &StoredProviderCredentialRefreshState, -) -> Result { +) -> Result { let token_url = oauth2_token_url(state)?; let client_id = required_material(&state.material, "client_id")?; let refresh_token = required_material(&state.material, "refresh_token")?; @@ -1149,12 +1347,18 @@ async fn mint_oauth2_refresh_token( form.push(("scope".to_string(), scope)); } - request_token(&token_url, &form, state.max_lifetime_seconds).await + request_token( + &token_url, + &form, + state.max_lifetime_seconds, + OAuthGrantKind::UserRefreshToken, + ) + .await } async fn mint_oauth2_client_credentials( state: &StoredProviderCredentialRefreshState, -) -> Result { +) -> Result { let token_url = oauth2_token_url(state)?; let client_id = required_material(&state.material, "client_id")?; let client_secret = required_material(&state.material, "client_secret")?; @@ -1168,14 +1372,19 @@ async fn mint_oauth2_client_credentials( form.push(("scope".to_string(), scope)); } - request_token(&token_url, &form, state.max_lifetime_seconds).await + request_token( + &token_url, + &form, + state.max_lifetime_seconds, + OAuthGrantKind::NonInteractive, + ) + .await } async fn mint_google_service_account_jwt( state: &StoredProviderCredentialRefreshState, -) -> Result { +) -> Result { crate::install_jsonwebtoken_crypto_provider(); - let token_url = google_token_url(state); let client_email = required_material(&state.material, "client_email")?; let private_key = required_material(&state.material, "private_key")?; @@ -1183,7 +1392,8 @@ async fn mint_google_service_account_jwt( if scopes.is_empty() { return Err(Status::invalid_argument( "google_service_account_jwt requires at least one scope", - )); + ) + .into()); } let now_ms = current_time_ms(); let now_secs = now_ms / 1000; @@ -1216,12 +1426,18 @@ async fn mint_google_service_account_jwt( ), ("assertion".to_string(), assertion), ]; - request_token(&token_url, &form, lifetime_secs).await + request_token( + &token_url, + &form, + lifetime_secs, + OAuthGrantKind::NonInteractive, + ) + .await } async fn mint_aws_sts_assume_role( state: &StoredProviderCredentialRefreshState, -) -> Result { +) -> Result { let role_arn = required_material(&state.material, "role_arn")?; let session_name = material_value(&state.material, &["session_name"]) .unwrap_or_else(|| "openshell-sandbox".to_string()); @@ -1256,13 +1472,15 @@ async fn mint_aws_sts_assume_role( (None, None) if session_token.is_some() => { return Err(Status::invalid_argument( "aws_session_token requires aws_access_key_id and aws_secret_access_key", - )); + ) + .into()); } (None, None) => {} _ => { return Err(Status::invalid_argument( "aws_access_key_id and aws_secret_access_key must both be set or both omitted", - )); + ) + .into()); } } @@ -1346,7 +1564,8 @@ async fn request_token( token_url: &str, form: &[(String, String)], max_lifetime_seconds: i64, -) -> Result { + grant_kind: OAuthGrantKind, +) -> Result { let parsed = reqwest::Url::parse(token_url) .map_err(|_| Status::invalid_argument("token_url must be an absolute URL"))?; match parsed.scheme() { @@ -1355,7 +1574,8 @@ async fn request_token( _ => { return Err(Status::invalid_argument( "token_url must use https, except loopback http for local tests", - )); + ) + .into()); } } @@ -1368,20 +1588,41 @@ async fn request_token( .form(form) .send() .await - .map_err(|e| Status::unavailable(format!("token endpoint request failed: {e}")))?; + .map_err(|error| { + let error_kind = if error.is_timeout() { + "timeout" + } else if error.is_connect() { + "connect" + } else if error.is_request() { + "request" + } else { + "other" + }; + warn!( + error = %error.without_url(), + error_kind, + "OAuth token endpoint request failed" + ); + RefreshFailure::retryable( + Status::unavailable("token endpoint request failed"), + "oauth_token_endpoint_unavailable", + ) + })?; let status = response.status(); if !status.is_success() { - return Err(Status::failed_precondition(format!( - "token endpoint returned HTTP {status}" - ))); + let body = read_bounded_oauth_error_body(response).await; + return Err(classify_oauth_token_error(status, &body, grant_kind)); } - let token = response - .json::() - .await - .map_err(|_| Status::failed_precondition("token endpoint returned invalid JSON"))?; + let token = response.json::().await.map_err(|_| { + RefreshFailure::investigate( + Status::failed_precondition("token endpoint returned invalid JSON"), + "oauth_invalid_success_response", + ) + })?; if token.access_token.trim().is_empty() { - return Err(Status::failed_precondition( - "token endpoint returned empty access_token", + return Err(RefreshFailure::investigate( + Status::failed_precondition("token endpoint returned empty access_token"), + "oauth_empty_access_token", )); } let now_ms = current_time_ms(); @@ -1405,6 +1646,135 @@ async fn request_token( }) } +async fn read_bounded_oauth_error_body(mut response: reqwest::Response) -> Vec { + let mut body = Vec::new(); + while body.len() < MAX_OAUTH_ERROR_RESPONSE_BYTES { + let Ok(Some(chunk)) = response.chunk().await else { + break; + }; + let remaining = MAX_OAUTH_ERROR_RESPONSE_BYTES.saturating_sub(body.len()); + body.extend_from_slice(&chunk[..chunk.len().min(remaining)]); + if chunk.len() >= remaining { + break; + } + } + body +} + +fn classify_oauth_token_error( + status: reqwest::StatusCode, + body: &[u8], + grant_kind: OAuthGrantKind, +) -> RefreshFailure { + if status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error() { + return RefreshFailure::retryable( + Status::unavailable(format!("token endpoint returned HTTP {status}")), + "oauth_token_endpoint_retryable", + ); + } + + let Ok(error_response) = serde_json::from_slice::(body) else { + return RefreshFailure::investigate( + Status::failed_precondition(format!( + "token endpoint returned HTTP {status} without a recognized OAuth error" + )), + "oauth_unrecognized_error_response", + ); + }; + + match error_response.error.as_str() { + "invalid_grant" if grant_kind == OAuthGrantKind::UserRefreshToken => { + let subtype = error_response + .error_subtype + .as_deref() + .filter(|subtype| *subtype == "invalid_rapt") + .map(|_| "invalid_rapt"); + let message = if subtype.is_some() { + "OAuth refresh grant requires interactive reauthorization (invalid_grant/invalid_rapt)" + } else { + "OAuth refresh grant is no longer usable (invalid_grant); user reauthorization is required" + }; + RefreshFailure::reauthorize( + Status::failed_precondition(message), + "oauth_invalid_grant", + subtype, + ) + } + "invalid_client" => RefreshFailure::fix_configuration( + Status::failed_precondition( + "OAuth token endpoint rejected the client configuration (invalid_client)", + ), + "oauth_invalid_client", + ), + "unauthorized_client" => RefreshFailure::fix_configuration( + Status::failed_precondition( + "OAuth token endpoint rejected the client grant (unauthorized_client)", + ), + "oauth_unauthorized_client", + ), + "invalid_scope" => RefreshFailure::fix_configuration( + Status::failed_precondition( + "OAuth token endpoint rejected the configured scopes (invalid_scope)", + ), + "oauth_invalid_scope", + ), + "unsupported_grant_type" => RefreshFailure::fix_configuration( + Status::failed_precondition( + "OAuth token endpoint rejected the configured grant type (unsupported_grant_type)", + ), + "oauth_unsupported_grant_type", + ), + "admin_policy_enforced" => RefreshFailure::fix_configuration( + Status::failed_precondition( + "OAuth access is blocked by an administrator policy (admin_policy_enforced)", + ), + "oauth_admin_policy_enforced", + ), + "access_denied" + if error_response.error_subtype.as_deref() == Some("admin_policy_enforced") => + { + RefreshFailure::fix_configuration_with_subtype( + Status::failed_precondition( + "OAuth access is blocked by an administrator policy (access_denied/admin_policy_enforced)", + ), + "oauth_admin_policy_enforced", + "admin_policy_enforced", + ) + } + "access_denied" if grant_kind == OAuthGrantKind::UserRefreshToken => { + RefreshFailure::reauthorize( + Status::failed_precondition( + "OAuth access was denied; user reauthorization is required (access_denied)", + ), + "oauth_access_denied", + None, + ) + } + "access_denied" => RefreshFailure::fix_configuration( + Status::failed_precondition( + "OAuth access was denied for the non-interactive grant (access_denied)", + ), + "oauth_access_denied", + ), + "invalid_grant" => RefreshFailure::fix_configuration( + Status::failed_precondition( + "OAuth token endpoint rejected the non-interactive grant (invalid_grant)", + ), + "oauth_invalid_grant", + ), + "server_error" | "temporarily_unavailable" => RefreshFailure::retryable( + Status::unavailable("OAuth token endpoint reported a temporary failure"), + "oauth_token_endpoint_retryable", + ), + _ => RefreshFailure::investigate( + Status::failed_precondition(format!( + "token endpoint returned HTTP {status} with an unrecognized OAuth error" + )), + "oauth_unrecognized_error", + ), + } +} + pub fn refresh_scopes(state: &StoredProviderCredentialRefreshState) -> Vec { if !state.scopes.is_empty() { return state.scopes.clone(); @@ -1677,11 +2047,13 @@ async fn run_refresh_worker_tick( #[cfg(test)] mod tests { use super::{ - NewRefreshStateConfig, delete_refresh_state_with_credentials, - effective_authorization_epoch, enqueue_pending_secret_deletion, get_refresh_state, - list_all_refresh_states, list_refresh_states_for_provider, new_refresh_state, - put_refresh_state, refresh_material_scope, refresh_provider_credential, refresh_state_name, - refresh_strategy_name, run_refresh_worker_tick, seconds_until_ms, + MAX_OAUTH_ERROR_RESPONSE_BYTES, NewRefreshStateConfig, OAuthGrantKind, RefreshFailure, + RefreshRetrySchedule, Status, classify_oauth_token_error, + delete_refresh_state_with_credentials, effective_authorization_epoch, + enqueue_pending_secret_deletion, get_refresh_state, list_all_refresh_states, + list_refresh_states_for_provider, new_refresh_state, put_refresh_state, + read_bounded_oauth_error_body, refresh_material_scope, refresh_provider_credential, + refresh_state_name, refresh_strategy_name, run_refresh_worker_tick, seconds_until_ms, validate_secret_material_references, }; use crate::credentials::CredentialRuntime; @@ -1689,7 +2061,8 @@ mod tests { use openshell_core::Config; use openshell_core::proto::datamodel::v1::ObjectMeta; use openshell_core::proto::{ - CredentialHandle, Provider, ProviderCredentialRefreshStrategy, Sandbox, SandboxSpec, + CredentialHandle, Provider, ProviderCredentialRefreshRecoveryAction, + ProviderCredentialRefreshStrategy, Sandbox, SandboxSpec, StoredProviderCredentialRefreshState, }; use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; @@ -1806,6 +2179,37 @@ mod tests { assert_eq!(refresh_strategy_name(i32::MAX), "unspecified"); } + #[test] + fn local_refresh_statuses_default_to_safe_recovery_actions() { + for status in [ + Status::invalid_argument("missing material"), + Status::failed_precondition("strategy cannot be minted"), + Status::permission_denied("client is not allowed"), + ] { + let failure = RefreshFailure::from(status); + assert_eq!( + failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::FixConfiguration + ); + assert_eq!(failure.failure_code, "refresh_configuration_invalid"); + assert_eq!(failure.retry_schedule, RefreshRetrySchedule::Configuration); + } + + let retry = RefreshFailure::from(Status::unavailable("temporary backend outage")); + assert_eq!( + retry.recovery_action, + ProviderCredentialRefreshRecoveryAction::Retry + ); + assert_eq!(retry.retry_schedule, RefreshRetrySchedule::Short); + + let investigate = RefreshFailure::from(Status::unknown("unclassified failure")); + assert_eq!( + investigate.recovery_action, + ProviderCredentialRefreshRecoveryAction::Investigate + ); + assert_eq!(investigate.retry_schedule, RefreshRetrySchedule::Short); + } + #[test] fn pending_secret_deletions_preserve_multiple_generations_for_one_key() { let mut state = StoredProviderCredentialRefreshState::default(); @@ -1862,6 +2266,388 @@ mod tests { validate_secret_material_references(&state).unwrap(); } + #[test] + fn oauth_invalid_grant_requires_user_reauthorization_without_exposing_description() { + let failure = classify_oauth_token_error( + reqwest::StatusCode::BAD_REQUEST, + br#"{"error":"invalid_grant","error_subtype":"invalid_rapt","error_description":"sensitive provider detail"}"#, + OAuthGrantKind::UserRefreshToken, + ); + + assert_eq!( + failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::Reauthorize + ); + assert_eq!(failure.failure_code, "oauth_invalid_grant"); + assert_eq!(failure.provider_error_subtype, Some("invalid_rapt")); + assert_eq!(failure.retry_schedule, RefreshRetrySchedule::Parked); + assert!( + failure + .status + .message() + .contains("interactive reauthorization") + ); + assert!( + !failure + .status + .message() + .contains("sensitive provider detail") + ); + } + + #[test] + fn oauth_invalid_grant_ignores_non_string_optional_subtype() { + let failure = classify_oauth_token_error( + reqwest::StatusCode::BAD_REQUEST, + br#"{"error":"invalid_grant","error_subtype":{"vendor":"value"}}"#, + OAuthGrantKind::UserRefreshToken, + ); + + assert_eq!( + failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::Reauthorize + ); + assert_eq!(failure.failure_code, "oauth_invalid_grant"); + assert_eq!(failure.provider_error_subtype, None); + assert_eq!(failure.retry_schedule, RefreshRetrySchedule::Parked); + } + + #[test] + fn oauth_noninteractive_invalid_grant_requires_configuration_fix() { + let failure = classify_oauth_token_error( + reqwest::StatusCode::BAD_REQUEST, + br#"{"error":"invalid_grant"}"#, + OAuthGrantKind::NonInteractive, + ); + + assert_eq!( + failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::FixConfiguration + ); + assert_eq!(failure.failure_code, "oauth_invalid_grant"); + assert_eq!(failure.retry_schedule, RefreshRetrySchedule::Configuration); + } + + #[test] + fn oauth_admin_policy_subtype_requires_configuration_fix() { + let failure = classify_oauth_token_error( + reqwest::StatusCode::BAD_REQUEST, + br#"{"error":"access_denied","error_subtype":"admin_policy_enforced"}"#, + OAuthGrantKind::UserRefreshToken, + ); + + assert_eq!( + failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::FixConfiguration + ); + assert_eq!(failure.failure_code, "oauth_admin_policy_enforced"); + assert_eq!( + failure.provider_error_subtype, + Some("admin_policy_enforced") + ); + assert_eq!(failure.retry_schedule, RefreshRetrySchedule::Configuration); + } + + #[test] + fn oauth_access_denied_recovery_depends_on_grant_kind() { + let user_failure = classify_oauth_token_error( + reqwest::StatusCode::FORBIDDEN, + br#"{"error":"access_denied"}"#, + OAuthGrantKind::UserRefreshToken, + ); + assert_eq!( + user_failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::Reauthorize + ); + assert_eq!(user_failure.failure_code, "oauth_access_denied"); + assert_eq!(user_failure.retry_schedule, RefreshRetrySchedule::Parked); + + let service_failure = classify_oauth_token_error( + reqwest::StatusCode::FORBIDDEN, + br#"{"error":"access_denied"}"#, + OAuthGrantKind::NonInteractive, + ); + assert_eq!( + service_failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::FixConfiguration + ); + assert_eq!(service_failure.failure_code, "oauth_access_denied"); + assert_eq!( + service_failure.retry_schedule, + RefreshRetrySchedule::Configuration + ); + } + + #[test] + fn oauth_server_failure_remains_retryable() { + let failure = classify_oauth_token_error( + reqwest::StatusCode::SERVICE_UNAVAILABLE, + br#"{"error":"invalid_grant"}"#, + OAuthGrantKind::UserRefreshToken, + ); + + assert_eq!( + failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::Retry + ); + assert_eq!(failure.failure_code, "oauth_token_endpoint_retryable"); + assert_eq!(failure.retry_schedule, RefreshRetrySchedule::Short); + } + + #[test] + fn unrecognized_oauth_error_requests_investigation_without_echoing_body() { + let failure = classify_oauth_token_error( + reqwest::StatusCode::BAD_REQUEST, + br#"{"error":"vendor_secret_error","error_description":"do not expose me"}"#, + OAuthGrantKind::UserRefreshToken, + ); + + assert_eq!( + failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::Investigate + ); + assert_eq!(failure.failure_code, "oauth_unrecognized_error"); + assert_eq!(failure.retry_schedule, RefreshRetrySchedule::Short); + assert!(!failure.status.message().contains("vendor_secret_error")); + assert!(!failure.status.message().contains("do not expose me")); + } + + #[test] + fn html_and_malformed_oauth_errors_are_investigated_without_echoing_body() { + for body in [b"issuer failure".as_slice(), b"{".as_slice()] { + let failure = classify_oauth_token_error( + reqwest::StatusCode::BAD_REQUEST, + body, + OAuthGrantKind::UserRefreshToken, + ); + + assert_eq!( + failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::Investigate + ); + assert_eq!(failure.failure_code, "oauth_unrecognized_error_response"); + assert!(!failure.status.message().contains("issuer failure")); + } + } + + #[tokio::test] + async fn oversized_oauth_error_body_is_bounded_and_investigated() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/oversized")) + .respond_with(ResponseTemplate::new(400).set_body_bytes(vec![ + b'x'; + MAX_OAUTH_ERROR_RESPONSE_BYTES + + 512 + ])) + .mount(&mock_server) + .await; + + let response = reqwest::get(format!("{}/oversized", mock_server.uri())) + .await + .unwrap(); + let status = response.status(); + let body = read_bounded_oauth_error_body(response).await; + assert_eq!(body.len(), MAX_OAUTH_ERROR_RESPONSE_BYTES); + + let failure = classify_oauth_token_error(status, &body, OAuthGrantKind::UserRefreshToken); + assert_eq!( + failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::Investigate + ); + assert_eq!(failure.failure_code, "oauth_unrecognized_error_response"); + } + + #[tokio::test] + async fn invalid_local_oauth_configuration_retries_hourly() { + let store = test_store().await; + let provider = provider("invalid-local-config", "outlook"); + store.put_message(&provider).await.unwrap(); + let state = new_refresh_state( + &provider, + "default", + "MS_GRAPH_ACCESS_TOKEN", + NewRefreshStateConfig { + strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken, + material: HashMap::from([ + ("client_id".to_string(), "client-id".to_string()), + ("refresh_token".to_string(), "refresh-token".to_string()), + ]), + secret_material_keys: vec!["refresh_token".to_string()], + expires_at_ms: 0, + token_url: "not-an-absolute-url".to_string(), + scopes: Vec::new(), + refresh_before_seconds: 30, + max_lifetime_seconds: 60, + additional_output_keys: HashMap::new(), + }, + ) + .unwrap(); + put_refresh_state(&store, &state).await.unwrap(); + + let err = refresh_provider_credential( + &store, + "default", + &test_credentials(), + None, + "invalid-local-config", + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), tonic::Code::InvalidArgument); + let stored = get_refresh_state( + &store, + "default", + provider.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.status, "configuration_required"); + assert_eq!( + stored.recovery_action, + ProviderCredentialRefreshRecoveryAction::FixConfiguration as i32 + ); + assert_eq!(stored.failure_code, "refresh_configuration_invalid"); + assert_eq!( + stored.next_refresh_at_ms - stored.last_error_at_ms, + 60 * 60 * 1000 + ); + } + + #[tokio::test] + async fn disabled_provider_gate_requires_configuration_and_retries_hourly() { + let store = test_store().await; + let provider = provider("disabled-provider-gate", "aws-s3"); + store.put_message(&provider).await.unwrap(); + let state = new_refresh_state( + &provider, + "default", + "AWS_ACCESS_KEY_ID", + NewRefreshStateConfig { + strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole, + material: HashMap::from([( + "role_arn".to_string(), + "arn:aws:iam::123456789012:role/test".to_string(), + )]), + secret_material_keys: Vec::new(), + expires_at_ms: 0, + token_url: String::new(), + scopes: Vec::new(), + refresh_before_seconds: 30, + max_lifetime_seconds: 60, + additional_output_keys: HashMap::new(), + }, + ) + .unwrap(); + put_refresh_state(&store, &state).await.unwrap(); + + let err = refresh_provider_credential( + &store, + "default", + &test_credentials(), + None, + "disabled-provider-gate", + "AWS_ACCESS_KEY_ID", + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + let stored = + get_refresh_state(&store, "default", provider.object_id(), "AWS_ACCESS_KEY_ID") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.status, "configuration_required"); + assert_eq!( + stored.recovery_action, + ProviderCredentialRefreshRecoveryAction::FixConfiguration as i32 + ); + assert_eq!(stored.failure_code, "refresh_configuration_invalid"); + assert_eq!( + stored.next_refresh_at_ms - stored.last_error_at_ms, + 60 * 60 * 1000 + ); + } + + #[tokio::test] + async fn oauth_invalid_grant_persists_terminal_reauthorization_status() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "error": "invalid_grant", + "error_subtype": "invalid_rapt", + "error_description": "provider-controlled detail" + }))) + .mount(&mock_server) + .await; + + let store = test_store().await; + let provider = provider("expired-grant", "outlook"); + store.put_message(&provider).await.unwrap(); + let state = new_refresh_state( + &provider, + "default", + "MS_GRAPH_ACCESS_TOKEN", + NewRefreshStateConfig { + strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken, + material: HashMap::from([ + ("client_id".to_string(), "client-id".to_string()), + ( + "refresh_token".to_string(), + "expired-refresh-token".to_string(), + ), + ]), + secret_material_keys: vec!["refresh_token".to_string()], + expires_at_ms: 0, + token_url: format!("{}/token", mock_server.uri()), + scopes: Vec::new(), + refresh_before_seconds: 30, + max_lifetime_seconds: 60, + additional_output_keys: HashMap::new(), + }, + ) + .unwrap(); + put_refresh_state(&store, &state).await.unwrap(); + + let err = refresh_provider_credential( + &store, + "default", + &test_credentials(), + None, + "expired-grant", + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + let stored = get_refresh_state( + &store, + "default", + provider.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.status, "reauthorization_required"); + assert_eq!(stored.next_refresh_at_ms, i64::MAX); + assert_eq!( + stored.recovery_action, + ProviderCredentialRefreshRecoveryAction::Reauthorize as i32 + ); + assert_eq!(stored.failure_code, "oauth_invalid_grant"); + assert_eq!(stored.provider_error_subtype, "invalid_rapt"); + assert!(stored.last_error_at_ms > 0); + assert!(!stored.last_error.contains("provider-controlled detail")); + } + #[tokio::test] async fn oauth2_client_credentials_refresh_mints_and_persists_access_token() { let mock_server = MockServer::start().await; @@ -1884,7 +2670,7 @@ mod tests { let provider = provider("my-graph", "outlook"); store.put_message(&provider).await.unwrap(); let before_refresh_ms = current_time_ms(); - let state = new_refresh_state( + let mut state = new_refresh_state( &provider, "default", "MS_GRAPH_ACCESS_TOKEN", @@ -1904,6 +2690,12 @@ mod tests { }, ) .unwrap(); + state.status = "investigation_required".to_string(); + state.last_error = "safe prior error".to_string(); + state.recovery_action = ProviderCredentialRefreshRecoveryAction::Investigate as i32; + state.failure_code = "oauth_unrecognized_error".to_string(); + state.provider_error_subtype = "prior_subtype".to_string(); + state.last_error_at_ms = current_time_ms(); put_refresh_state(&store, &state).await.unwrap(); let authorization_epoch = state.authorization_epoch.clone(); let credentials = test_credentials(); @@ -1924,6 +2716,13 @@ mod tests { assert!(refreshed.next_refresh_at_ms > 0); assert!(refreshed.expires_at_ms <= before_refresh_ms + 120_000); assert!(refreshed.last_error.is_empty()); + assert_eq!( + refreshed.recovery_action, + ProviderCredentialRefreshRecoveryAction::Unspecified as i32 + ); + assert!(refreshed.failure_code.is_empty()); + assert!(refreshed.provider_error_subtype.is_empty()); + assert_eq!(refreshed.last_error_at_ms, 0); let stored = store .get_message_by_name::("default", "my-graph") @@ -2135,7 +2934,16 @@ mod tests { .await .unwrap() .unwrap(); - assert_eq!(stored_state.status, "error"); + assert_eq!(stored_state.status, "configuration_required"); + assert_eq!( + stored_state.recovery_action, + ProviderCredentialRefreshRecoveryAction::FixConfiguration as i32 + ); + assert_eq!(stored_state.failure_code, "refresh_configuration_invalid"); + assert_eq!( + stored_state.next_refresh_at_ms - stored_state.last_error_at_ms, + 60 * 60 * 1000 + ); assert!(stored_state.last_error.contains("MS_GRAPH_ACCESS_TOKEN")); let stored_provider = store .get_message_by_name::("default", "refreshing-graph") @@ -2366,7 +3174,7 @@ mod tests { } #[tokio::test] - async fn rotated_refresh_token_store_failure_persists_error_and_backoff() { + async fn rotated_refresh_token_store_failure_requires_reauthorization() { let mock_server = MockServer::start().await; Mock::given(method("POST")) .and(path("/token")) @@ -2415,7 +3223,6 @@ mod tests { state.material.remove("refresh_token"); put_refresh_state(&store, &state).await.unwrap(); credentials.fail_next_store(); - let before = current_time_ms(); let err = refresh_provider_credential( &store, @@ -2439,9 +3246,17 @@ mod tests { .await .unwrap() .unwrap(); - assert_eq!(stored.status, "error"); + assert_eq!(stored.status, "reauthorization_required"); assert!(stored.last_error.contains("must be re-authorized")); - assert!(stored.next_refresh_at_ms > before); + assert_eq!(stored.next_refresh_at_ms, i64::MAX); + assert_eq!( + stored.recovery_action, + ProviderCredentialRefreshRecoveryAction::Reauthorize as i32 + ); + assert_eq!( + stored.failure_code, + "oauth_rotated_refresh_token_store_failed" + ); assert_eq!(credentials.stored_credential_count(), Some(1)); assert_eq!( credentials @@ -2586,6 +3401,58 @@ mod tests { ); } + #[tokio::test] + async fn refresh_worker_skips_parked_reauthorization_state() { + let store = test_store().await; + let provider = provider("parked-refresh", "outlook"); + store.put_message(&provider).await.unwrap(); + let mut state = new_refresh_state( + &provider, + "default", + "MS_GRAPH_ACCESS_TOKEN", + NewRefreshStateConfig { + strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken, + material: HashMap::new(), + secret_material_keys: Vec::new(), + expires_at_ms: 0, + token_url: "https://issuer.example/token".to_string(), + scopes: Vec::new(), + refresh_before_seconds: 30, + max_lifetime_seconds: 60, + additional_output_keys: HashMap::new(), + }, + ) + .unwrap(); + state.status = "reauthorization_required".to_string(); + state.last_error = "OAuth refresh grant is no longer usable".to_string(); + state.recovery_action = ProviderCredentialRefreshRecoveryAction::Reauthorize as i32; + state.failure_code = "oauth_invalid_grant".to_string(); + state.last_error_at_ms = current_time_ms(); + state.next_refresh_at_ms = i64::MAX; + put_refresh_state(&store, &state).await.unwrap(); + + run_refresh_worker_tick(&store, Some(&test_credentials()), None) + .await + .unwrap(); + + let stored = get_refresh_state( + &store, + "default", + provider.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.status, "reauthorization_required"); + assert_eq!(stored.next_refresh_at_ms, i64::MAX); + assert_eq!(stored.failure_code, "oauth_invalid_grant"); + assert_eq!( + stored.recovery_action, + ProviderCredentialRefreshRecoveryAction::Reauthorize as i32 + ); + } + #[tokio::test] async fn refresh_worker_finalizes_tombstoned_refresh_material() { let store = test_store().await; diff --git a/docs/sandboxes/providers-v2.mdx b/docs/sandboxes/providers-v2.mdx index 96ce68ab32..7a0f96b5ac 100644 --- a/docs/sandboxes/providers-v2.mdx +++ b/docs/sandboxes/providers-v2.mdx @@ -663,6 +663,21 @@ The gateway sends a complete host, port, and path binding for every emitted stat Refresh configuration is stored separately from the current injectable credential value. Non-secret refresh configuration remains in the refresh-state record. Secret material is resolved from the active credential driver only while the gateway mints a new short-lived token. The gateway writes the token back through credential storage and updates credential expiry metadata. If an OAuth issuer rotates its refresh token, the gateway stages the replacement through credential storage before committing the refresh generation and removes the previous handle afterward. +For OAuth token endpoint failures, the gateway parses the bounded standard OAuth +error response and reports a structured recovery action through `provider +refresh status`. It does not retain provider error descriptions or raw response +bodies. The recovery actions are: + +- `retry`: a network, rate-limit, or temporary issuer failure; the worker retries automatically. +- `reauthorize`: a user refresh grant is no longer usable, such as `invalid_grant`; obtain a new grant and run `provider refresh configure`. +- `fix_configuration`: the OAuth client, scopes, grant type, or administrator policy must be corrected. +- `investigate`: the issuer returned an unrecognized response; the worker retries, but the operator should investigate persistent failures. + +The gateway parks `reauthorize` and `fix_configuration` failures instead of +retrying them every worker tick. A manual `provider refresh rotate` still +attempts the exchange. The current access token remains available only until +its recorded expiry, after which credential resolution fails closed. + Before OpenShell 0.1.0, refresh-state and credential-driver migrations are not supported. Upgrading from a build that stored refresh material inline requires reconfiguring the refresh grant. To change credential drivers, delete or @@ -770,11 +785,22 @@ Check refresh status: openshell provider refresh status my-graph ``` +The status table includes `RECOVERY` and `FAILURE_CODE`. Clients should use the +structured recovery action rather than parsing `LAST_ERROR`. For example, +`oauth_invalid_grant` with recovery action `reauthorize` means the user must +complete OAuth authorization again; `oauth_invalid_client` with +`fix_configuration` means changing the user login alone will not repair the +grant. OpenShell parks `reauthorize` failures until an explicit rotation or +reconfiguration. It retries `fix_configuration` failures hourly so externally +repaired clocks, policies, or credential-store values can recover without +creating rapid token-endpoint traffic. Transient and unrecognized failures use +the existing bounded 60-second retry interval. + The status table reports operational state without printing token values or refresh material: ```text -PROVIDER CREDENTIAL_KEY STRATEGY STATUS EXPIRES_AT NEXT_REFRESH LAST_REFRESH LAST_ERROR -my-graph MS_GRAPH_ACCESS_TOKEN oauth2_refresh_token refreshed 2026-06-01 00:00:00 2026-05-31 23:50:00 2026-05-31 23:00:00 - +PROVIDER CREDENTIAL_KEY STRATEGY STATUS RECOVERY EXPIRES_AT NEXT_REFRESH LAST_REFRESH FAILURE_CODE LAST_ERROR +my-graph MS_GRAPH_ACCESS_TOKEN oauth2_refresh_token reauthorization_required reauthorize 2026-06-01 00:00:00 - 2026-05-31 23:00:00 oauth_invalid_grant OAuth refresh grant is no longer usable ``` When no refresh configuration exists, the CLI distinguishes whole-provider checks from credential-specific checks: diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 42f989ce42..44881e1682 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -34,6 +34,7 @@ e2e-kubernetes-workspace-operator = ["e2e-kubernetes"] e2e-podman = ["e2e", "e2e-host-gateway", "e2e-local-container-driver"] e2e-podman-gpu = ["e2e-podman", "e2e-gpu"] e2e-oidc-pkce = [] +e2e-provider-refresh-keycloak = [] e2e-vm = ["e2e", "e2e-host-gateway"] [[test]] @@ -41,6 +42,11 @@ name = "oidc_pkce" path = "tests/oidc_pkce.rs" required-features = ["e2e-oidc-pkce"] +[[test]] +name = "provider_refresh_keycloak" +path = "tests/provider_refresh_keycloak.rs" +required-features = ["e2e-provider-refresh-keycloak"] + [[test]] name = "custom_image" path = "tests/custom_image.rs" diff --git a/e2e/rust/tests/provider_refresh_keycloak.rs b/e2e/rust/tests/provider_refresh_keycloak.rs new file mode 100644 index 0000000000..57bcb45fb7 --- /dev/null +++ b/e2e/rust/tests/provider_refresh_keycloak.rs @@ -0,0 +1,417 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(target_os = "linux")] + +//! End-to-end coverage for provider OAuth refresh recovery against Keycloak. +//! +//! OpenShell itself uses the local gateway's mTLS authentication. Keycloak is +//! only the provider token issuer: the test refreshes a valid grant, revokes +//! its Keycloak session, and verifies that the gateway reports the next +//! refresh as requiring user reauthorization. + +use std::io::Write as _; +use std::process::{Output, Stdio}; + +use openshell_e2e::harness::binary::openshell_cmd; +use serde_json::Value; +use tempfile::{Builder as TempFileBuilder, NamedTempFile}; +use tokio::io::AsyncWriteExt as _; +use tokio::process::Command; + +const PROVIDER_NAME: &str = "e2e-keycloak-refresh"; +const PROFILE_ID: &str = "e2e-keycloak-refresh"; +const CREDENTIAL_KEY: &str = "KEYCLOAK_ACCESS_TOKEN"; + +fn combined_output(output: &Output) -> String { + format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} + +async fn run_cli(args: &[&str], env: &[(&str, &str)]) -> Result { + openshell_cmd() + .args(args) + .env("NO_COLOR", "1") + .envs(env.iter().copied()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|error| format!("run openshell command: {error}")) +} + +async fn run_cli_success(args: &[&str], env: &[(&str, &str)]) -> Result { + let output = run_cli(args, env).await?; + let combined = combined_output(&output); + if !output.status.success() { + return Err(format!( + "openshell command failed (exit {:?}):\n{combined}", + output.status.code() + )); + } + Ok(combined) +} + +async fn acquire_keycloak_grant( + issuer: &str, + username: &str, + password: &str, +) -> Result<(String, String), String> { + let token_endpoint = format!("{issuer}/protocol/openid-connect/token"); + let username_form = format!("username={username}"); + let password_form = format!("password={password}"); + let output = Command::new("curl") + .args([ + "--fail", + "--silent", + "--show-error", + "--request", + "POST", + &token_endpoint, + "--data-urlencode", + "grant_type=password", + "--data-urlencode", + "client_id=openshell-cli", + "--data-urlencode", + &username_form, + "--data-urlencode", + &password_form, + "--data-urlencode", + "scope=openid", + ]) + .output() + .await + .map_err(|error| format!("request Keycloak grant: {error}"))?; + if !output.status.success() { + return Err(format!( + "Keycloak grant request failed (exit {:?}): {}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + )); + } + + let response: Value = serde_json::from_slice(&output.stdout) + .map_err(|error| format!("decode Keycloak grant response: {error}"))?; + let access_token = response + .get("access_token") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "Keycloak grant response omitted access_token".to_string())?; + let refresh_token = response + .get("refresh_token") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "Keycloak grant response omitted refresh_token".to_string())?; + Ok((access_token.to_string(), refresh_token.to_string())) +} + +async fn revoke_keycloak_grant(issuer: &str, refresh_token: &str) -> Result<(), String> { + let logout_endpoint = format!("{issuer}/protocol/openid-connect/logout"); + let mut child = Command::new("curl") + .args([ + "--fail", + "--silent", + "--show-error", + "--output", + "/dev/null", + "--request", + "POST", + &logout_endpoint, + "--data-urlencode", + "client_id=openshell-cli", + "--data-urlencode", + "refresh_token@-", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("start Keycloak logout request: {error}"))?; + child + .stdin + .take() + .ok_or_else(|| "Keycloak logout stdin was not piped".to_string())? + .write_all(refresh_token.as_bytes()) + .await + .map_err(|error| format!("write Keycloak logout request: {error}"))?; + let output = child + .wait_with_output() + .await + .map_err(|error| format!("wait for Keycloak logout request: {error}"))?; + if !output.status.success() { + return Err(format!( + "Keycloak logout failed (exit {:?}): {}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + )); + } + Ok(()) +} + +fn write_profile(issuer: &str) -> Result { + let mut file = TempFileBuilder::new() + .suffix(".yaml") + .tempfile() + .map_err(|error| format!("create provider profile: {error}"))?; + let profile = format!( + r#"id: {PROFILE_ID} +display_name: Keycloak provider refresh E2E +category: other +credentials: + - name: access_token + env_vars: [{CREDENTIAL_KEY}] + required: true + auth_style: bearer + header_name: authorization + refresh: + strategy: oauth2_refresh_token + token_url: {issuer}/protocol/openid-connect/token + scopes: [openid] + refresh_before_seconds: 60 + max_lifetime_seconds: 3600 + material: + - name: client_id + required: true + - name: refresh_token + required: true + secret: true +endpoints: + - host: keycloak.test.invalid + port: 443 + protocol: rest + access: read-only + enforcement: enforce +binaries: + - /usr/bin/curl +"# + ); + file.write_all(profile.as_bytes()) + .map_err(|error| format!("write provider profile: {error}"))?; + file.flush() + .map_err(|error| format!("flush provider profile: {error}"))?; + Ok(file) +} + +async fn delete_provider_resources() { + let _ = run_cli(&["provider", "delete", PROVIDER_NAME], &[]).await; + let _ = run_cli(&["provider", "profile", "delete", PROFILE_ID], &[]).await; +} + +async fn read_providers_v2_setting() -> Result, String> { + let output = run_cli(&["settings", "get", "--global", "--json"], &[]).await?; + if !output.status.success() { + return Err(format!( + "read global settings failed (exit {:?}):\n{}", + output.status.code(), + combined_output(&output) + )); + } + let response: Value = serde_json::from_slice(&output.stdout) + .map_err(|error| format!("decode global settings: {error}"))?; + let value = response + .get("settings") + .and_then(|settings| settings.get("providers_v2_enabled")) + .and_then(Value::as_str) + .filter(|value| *value != "") + .map(ToString::to_string); + Ok(value) +} + +async fn restore_providers_v2_setting(previous: Option<&str>) -> Result<(), String> { + if let Some(value) = previous { + run_cli_success( + &[ + "settings", + "set", + "--global", + "--key", + "providers_v2_enabled", + "--value", + value, + "--yes", + ], + &[], + ) + .await?; + } else { + run_cli_success( + &[ + "settings", + "delete", + "--global", + "--key", + "providers_v2_enabled", + "--yes", + ], + &[], + ) + .await?; + } + Ok(()) +} + +#[tokio::test] +async fn revoked_refresh_grant_requires_user_reauthorization() -> Result<(), String> { + let issuer = std::env::var("OPENSHELL_E2E_OIDC_ISSUER") + .map_err(|_| "OPENSHELL_E2E_OIDC_ISSUER is required".to_string())?; + let username = std::env::var("OPENSHELL_E2E_OIDC_USERNAME") + .map_err(|_| "OPENSHELL_E2E_OIDC_USERNAME is required".to_string())?; + let password = std::env::var("OPENSHELL_E2E_OIDC_PASSWORD") + .map_err(|_| "OPENSHELL_E2E_OIDC_PASSWORD is required".to_string())?; + let previous_providers_v2_setting = read_providers_v2_setting().await?; + let (access_token, refresh_token) = + acquire_keycloak_grant(&issuer, &username, &password).await?; + let profile = write_profile(&issuer)?; + let profile_path = profile.path().to_string_lossy().into_owned(); + + delete_provider_resources().await; + let result = async { + run_cli_success( + &[ + "settings", + "set", + "--global", + "--key", + "providers_v2_enabled", + "--value", + "true", + "--yes", + ], + &[], + ) + .await?; + run_cli_success( + &["provider", "profile", "import", "--file", &profile_path], + &[], + ) + .await?; + run_cli_success( + &[ + "provider", + "create", + "--name", + PROVIDER_NAME, + "--type", + PROFILE_ID, + "--credential", + CREDENTIAL_KEY, + ], + &[(CREDENTIAL_KEY, &access_token)], + ) + .await?; + run_cli_success( + &[ + "provider", + "refresh", + "configure", + PROVIDER_NAME, + "--credential-key", + CREDENTIAL_KEY, + "--strategy", + "oauth2-refresh-token", + "--material", + "client_id=openshell-cli", + "--secret-material-env", + "refresh_token=KEYCLOAK_REFRESH_TOKEN", + ], + &[("KEYCLOAK_REFRESH_TOKEN", &refresh_token)], + ) + .await?; + + run_cli_success( + &[ + "provider", + "refresh", + "rotate", + PROVIDER_NAME, + "--credential-key", + CREDENTIAL_KEY, + ], + &[], + ) + .await?; + let valid_status = run_cli_success( + &[ + "provider", + "refresh", + "status", + PROVIDER_NAME, + "--credential-key", + CREDENTIAL_KEY, + ], + &[], + ) + .await?; + if !valid_status.contains("refreshed") { + return Err(format!( + "valid Keycloak refresh did not reach refreshed state:\n{valid_status}" + )); + } + + revoke_keycloak_grant(&issuer, &refresh_token).await?; + let failed_rotation = run_cli( + &[ + "provider", + "refresh", + "rotate", + PROVIDER_NAME, + "--credential-key", + CREDENTIAL_KEY, + ], + &[], + ) + .await?; + let failed_rotation_output = combined_output(&failed_rotation); + if failed_rotation.status.success() || !failed_rotation_output.contains("invalid_grant") { + return Err(format!( + "revoked Keycloak refresh did not fail with invalid_grant:\n{failed_rotation_output}" + )); + } + + let revoked_status = run_cli_success( + &[ + "provider", + "refresh", + "status", + PROVIDER_NAME, + "--credential-key", + CREDENTIAL_KEY, + ], + &[], + ) + .await?; + for expected in [ + "reauthorization_required", + "reauthorize", + "oauth_invalid_grant", + ] { + if !revoked_status.contains(expected) { + return Err(format!( + "revoked refresh status omitted {expected}:\n{revoked_status}" + )); + } + } + if revoked_status.contains("292278994") { + return Err(format!( + "parked refresh rendered the i64::MAX scheduling sentinel as a date:\n{revoked_status}" + )); + } + Ok(()) + } + .await; + + delete_provider_resources().await; + let cleanup_result = + restore_providers_v2_setting(previous_providers_v2_setting.as_deref()).await; + match (result, cleanup_result) { + (Ok(()), Ok(())) => Ok(()), + (Err(test_error), Ok(())) => Err(test_error), + (Ok(()), Err(cleanup_error)) => Err(cleanup_error), + (Err(test_error), Err(cleanup_error)) => Err(format!( + "{test_error}\ncleanup also failed: {cleanup_error}" + )), + } +} diff --git a/proto/openshell.proto b/proto/openshell.proto index 6f1481b521..246fe0626f 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -1620,9 +1620,23 @@ message ProviderCredentialRefreshStatus { ProviderCredentialRefreshStrategy strategy = 4; string status = 5; int64 expires_at_ms = 6; + // Next automatic refresh time in Unix epoch milliseconds. A value of + // 9223372036854775807 (int64 max) means no automatic retry is scheduled; + // consumers should render it as unset and use recovery_action to determine + // the required recovery workflow. int64 next_refresh_at_ms = 7; int64 last_refresh_at_ms = 8; string last_error = 9; + ProviderCredentialRefreshRecoveryAction recovery_action = 10; + // Stable gateway-owned failure identifier, for example + // "oauth_invalid_grant". This is not provider-controlled prose and + // incorporates any recognized top-level OAuth error classification. + string failure_code = 11; + // A bounded, recognized provider subtype that refines failure_code; clients + // do not need a separate provider_error field. Unknown provider-controlled + // values are not persisted or returned. + string provider_error_subtype = 12; + int64 last_error_at_ms = 13; } // Provider profile local discovery declaration. @@ -1643,6 +1657,7 @@ message StoredProviderCredentialRefreshState { // are not automatically migrated before OpenShell 0.1.0. repeated string secret_material_keys = 7; int64 expires_at_ms = 8; + // int64 max parks the refresh until an explicit rotation or reconfiguration. int64 next_refresh_at_ms = 9; int64 last_refresh_at_ms = 10; string status = 11; @@ -1671,6 +1686,12 @@ message StoredProviderCredentialRefreshState { // Cleanup is retried by the refresh worker so a gateway crash or temporary // credential-backend outage does not lose the deletion reference. repeated StoredRefreshMaterialDeletion pending_secret_deletions = 20; + // Structured recovery details for the most recent refresh failure. These + // fields contain only gateway-owned codes and recognized bounded values. + ProviderCredentialRefreshRecoveryAction recovery_action = 21; + string failure_code = 22; + string provider_error_subtype = 23; + int64 last_error_at_ms = 24; } message StoredRefreshMaterialDeletion { @@ -2787,6 +2808,18 @@ enum WorkspaceRole { WORKSPACE_ROLE_ADMIN = 2; } +// Stable recovery action for the most recent provider credential refresh +// failure. Kept after the pre-existing enums so adding it does not renumber +// their generated descriptors. Clients should use this field instead of +// parsing last_error. +enum ProviderCredentialRefreshRecoveryAction { + PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_UNSPECIFIED = 0; + PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_RETRY = 1; + PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_REAUTHORIZE = 2; + PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_FIX_CONFIGURATION = 3; + PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_INVESTIGATE = 4; +} + // Workspace membership record. message WorkspaceMember { openshell.datamodel.v1.ObjectMeta metadata = 1; diff --git a/sdk/go/openshell/v1/internal/converter/refresh.go b/sdk/go/openshell/v1/internal/converter/refresh.go index b417718a5b..be5ee8d03a 100644 --- a/sdk/go/openshell/v1/internal/converter/refresh.go +++ b/sdk/go/openshell/v1/internal/converter/refresh.go @@ -4,6 +4,9 @@ package converter import ( + "math" + "time" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" ) @@ -30,6 +33,31 @@ func RefreshStrategyFromProto(s pb.ProviderCredentialRefreshStrategy) types.Refr } } +// --- RefreshRecoveryAction enum mapping --- + +// RefreshRecoveryActionFromProto converts a proto recovery action to the curated SDK type. +func RefreshRecoveryActionFromProto(a pb.ProviderCredentialRefreshRecoveryAction) types.RefreshRecoveryAction { + switch a { + case pb.ProviderCredentialRefreshRecoveryAction_PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_RETRY: + return types.RefreshRecoveryActionRetry + case pb.ProviderCredentialRefreshRecoveryAction_PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_REAUTHORIZE: + return types.RefreshRecoveryActionReauthorize + case pb.ProviderCredentialRefreshRecoveryAction_PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_FIX_CONFIGURATION: + return types.RefreshRecoveryActionFixConfiguration + case pb.ProviderCredentialRefreshRecoveryAction_PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_INVESTIGATE: + return types.RefreshRecoveryActionInvestigate + default: + return types.RefreshRecoveryActionUnspecified + } +} + +func refreshNextTimeFromMillis(ms int64) time.Time { + if ms == math.MaxInt64 { + return time.Time{} + } + return TimeFromMillis(ms) +} + // RefreshStrategyToProto converts an SDK RefreshStrategy to a proto ProviderCredentialRefreshStrategy. func RefreshStrategyToProto(s types.RefreshStrategy) pb.ProviderCredentialRefreshStrategy { switch s { @@ -58,15 +86,19 @@ func RefreshStatusFromProto(s *pb.ProviderCredentialRefreshStatus) *types.Refres return nil } return &types.RefreshStatus{ - ProviderName: s.GetProviderName(), - ProviderID: s.GetProviderId(), - CredentialKey: s.GetCredentialKey(), - Strategy: RefreshStrategyFromProto(s.GetStrategy()), - Status: s.GetStatus(), - ExpiresAt: TimeFromMillis(s.GetExpiresAtMs()), - NextRefreshAt: TimeFromMillis(s.GetNextRefreshAtMs()), - LastRefreshAt: TimeFromMillis(s.GetLastRefreshAtMs()), - LastError: s.GetLastError(), + ProviderName: s.GetProviderName(), + ProviderID: s.GetProviderId(), + CredentialKey: s.GetCredentialKey(), + Strategy: RefreshStrategyFromProto(s.GetStrategy()), + Status: s.GetStatus(), + ExpiresAt: TimeFromMillis(s.GetExpiresAtMs()), + NextRefreshAt: refreshNextTimeFromMillis(s.GetNextRefreshAtMs()), + LastRefreshAt: TimeFromMillis(s.GetLastRefreshAtMs()), + LastError: s.GetLastError(), + RecoveryAction: RefreshRecoveryActionFromProto(s.GetRecoveryAction()), + FailureCode: s.GetFailureCode(), + ProviderErrorSubtype: s.GetProviderErrorSubtype(), + LastErrorAt: TimeFromMillis(s.GetLastErrorAtMs()), } } diff --git a/sdk/go/openshell/v1/internal/converter/refresh_test.go b/sdk/go/openshell/v1/internal/converter/refresh_test.go index b426857c96..9ffa6e590a 100644 --- a/sdk/go/openshell/v1/internal/converter/refresh_test.go +++ b/sdk/go/openshell/v1/internal/converter/refresh_test.go @@ -4,6 +4,7 @@ package converter import ( + "math" "testing" "time" @@ -35,6 +36,23 @@ func TestRefreshStrategyFromProto(t *testing.T) { } } +func TestRefreshRecoveryActionFromProto(t *testing.T) { + tests := []struct { + proto pb.ProviderCredentialRefreshRecoveryAction + want v1.RefreshRecoveryAction + }{ + {pb.ProviderCredentialRefreshRecoveryAction_PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_UNSPECIFIED, v1.RefreshRecoveryActionUnspecified}, + {pb.ProviderCredentialRefreshRecoveryAction_PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_RETRY, v1.RefreshRecoveryActionRetry}, + {pb.ProviderCredentialRefreshRecoveryAction_PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_REAUTHORIZE, v1.RefreshRecoveryActionReauthorize}, + {pb.ProviderCredentialRefreshRecoveryAction_PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_FIX_CONFIGURATION, v1.RefreshRecoveryActionFixConfiguration}, + {pb.ProviderCredentialRefreshRecoveryAction_PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_INVESTIGATE, v1.RefreshRecoveryActionInvestigate}, + {pb.ProviderCredentialRefreshRecoveryAction(999), v1.RefreshRecoveryActionUnspecified}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, RefreshRecoveryActionFromProto(tt.proto)) + } +} + func TestRefreshStrategyToProto(t *testing.T) { tests := []struct { sdk v1.RefreshStrategy @@ -60,15 +78,19 @@ func TestRefreshStrategyToProto(t *testing.T) { func TestRefreshStatusFromProto(t *testing.T) { proto := &pb.ProviderCredentialRefreshStatus{ - ProviderName: "anthropic", - ProviderId: "prov-1", - CredentialKey: "API_KEY", - Strategy: pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN, - Status: "active", - ExpiresAtMs: 1700000000000, - NextRefreshAtMs: 1699999000000, - LastRefreshAtMs: 1699998000000, - LastError: "none", + ProviderName: "anthropic", + ProviderId: "prov-1", + CredentialKey: "API_KEY", + Strategy: pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN, + Status: "active", + ExpiresAtMs: 1700000000000, + NextRefreshAtMs: 1699999000000, + LastRefreshAtMs: 1699998000000, + LastError: "none", + RecoveryAction: pb.ProviderCredentialRefreshRecoveryAction_PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_REAUTHORIZE, + FailureCode: "oauth_invalid_grant", + ProviderErrorSubtype: "invalid_rapt", + LastErrorAtMs: 1699997000000, } status := RefreshStatusFromProto(proto) @@ -83,6 +105,10 @@ func TestRefreshStatusFromProto(t *testing.T) { assert.Equal(t, TimeFromMillis(1699999000000), status.NextRefreshAt) assert.Equal(t, TimeFromMillis(1699998000000), status.LastRefreshAt) assert.Equal(t, "none", status.LastError) + assert.Equal(t, v1.RefreshRecoveryActionReauthorize, status.RecoveryAction) + assert.Equal(t, "oauth_invalid_grant", status.FailureCode) + assert.Equal(t, "invalid_rapt", status.ProviderErrorSubtype) + assert.Equal(t, TimeFromMillis(1699997000000), status.LastErrorAt) } func TestRefreshStatusFromProto_Nil(t *testing.T) { @@ -104,6 +130,24 @@ func TestRefreshStatusFromProto_ZeroTimestamps(t *testing.T) { assert.True(t, status.ExpiresAt.IsZero()) assert.True(t, status.NextRefreshAt.IsZero()) assert.True(t, status.LastRefreshAt.IsZero()) + assert.True(t, status.LastErrorAt.IsZero()) + assert.Equal(t, v1.RefreshRecoveryActionUnspecified, status.RecoveryAction) +} + +func TestRefreshStatusFromProto_ParkedRefreshHasNoNextTime(t *testing.T) { + proto := &pb.ProviderCredentialRefreshStatus{ + ProviderName: "test", + CredentialKey: "KEY", + NextRefreshAtMs: math.MaxInt64, + RecoveryAction: pb.ProviderCredentialRefreshRecoveryAction_PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_REAUTHORIZE, + } + + status := RefreshStatusFromProto(proto) + + require.NotNil(t, status) + assert.True(t, status.NextRefreshAt.IsZero()) + assert.Equal(t, v1.RefreshRecoveryActionReauthorize, status.RecoveryAction) + assert.False(t, TimeFromMillis(math.MaxInt64).IsZero()) } // --- RefreshConfig --- diff --git a/sdk/go/openshell/v1/types/refresh.go b/sdk/go/openshell/v1/types/refresh.go index b67ba35849..6ca49310e4 100644 --- a/sdk/go/openshell/v1/types/refresh.go +++ b/sdk/go/openshell/v1/types/refresh.go @@ -18,6 +18,40 @@ const ( RefreshStrategyAWSStsAssumeRole RefreshStrategy = "AWSStsAssumeRole" ) +// RefreshRecoveryAction describes the action required after a refresh failure. +type RefreshRecoveryAction int + +const ( + // RefreshRecoveryActionUnspecified means no recovery action is required. + RefreshRecoveryActionUnspecified RefreshRecoveryAction = iota + // RefreshRecoveryActionRetry means OpenShell will retry automatically. + RefreshRecoveryActionRetry + // RefreshRecoveryActionReauthorize means the user must replace the OAuth grant. + RefreshRecoveryActionReauthorize + // RefreshRecoveryActionFixConfiguration means an operator must repair configuration. + RefreshRecoveryActionFixConfiguration + // RefreshRecoveryActionInvestigate means the failure is not recognized. + RefreshRecoveryActionInvestigate +) + +// String returns the provider-neutral recovery action name. +func (a RefreshRecoveryAction) String() string { + switch a { + case RefreshRecoveryActionUnspecified: + return "unspecified" + case RefreshRecoveryActionRetry: + return "retry" + case RefreshRecoveryActionReauthorize: + return "reauthorize" + case RefreshRecoveryActionFixConfiguration: + return "fix_configuration" + case RefreshRecoveryActionInvestigate: + return "investigate" + default: + return "unknown" + } +} + // RefreshStatus reports the current state of credential refresh for a specific // provider credential. type RefreshStatus struct { @@ -27,9 +61,15 @@ type RefreshStatus struct { Strategy RefreshStrategy Status string ExpiresAt time.Time - NextRefreshAt time.Time - LastRefreshAt time.Time - LastError string + // NextRefreshAt is zero when no automatic refresh is scheduled. Use + // RecoveryAction to distinguish a parked refresh from an unset timestamp. + NextRefreshAt time.Time + LastRefreshAt time.Time + LastError string + RecoveryAction RefreshRecoveryAction + FailureCode string + ProviderErrorSubtype string + LastErrorAt time.Time } // RefreshConfig holds configuration parameters for gateway-owned credential diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index 778bf4f985..6b3b740a0b 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -439,6 +439,65 @@ func (WorkspaceRole) EnumDescriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{6} } +// Stable recovery action for the most recent provider credential refresh +// failure. Kept after the pre-existing enums so adding it does not renumber +// their generated descriptors. Clients should use this field instead of +// parsing last_error. +type ProviderCredentialRefreshRecoveryAction int32 + +const ( + ProviderCredentialRefreshRecoveryAction_PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_UNSPECIFIED ProviderCredentialRefreshRecoveryAction = 0 + ProviderCredentialRefreshRecoveryAction_PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_RETRY ProviderCredentialRefreshRecoveryAction = 1 + ProviderCredentialRefreshRecoveryAction_PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_REAUTHORIZE ProviderCredentialRefreshRecoveryAction = 2 + ProviderCredentialRefreshRecoveryAction_PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_FIX_CONFIGURATION ProviderCredentialRefreshRecoveryAction = 3 + ProviderCredentialRefreshRecoveryAction_PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_INVESTIGATE ProviderCredentialRefreshRecoveryAction = 4 +) + +// Enum value maps for ProviderCredentialRefreshRecoveryAction. +var ( + ProviderCredentialRefreshRecoveryAction_name = map[int32]string{ + 0: "PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_UNSPECIFIED", + 1: "PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_RETRY", + 2: "PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_REAUTHORIZE", + 3: "PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_FIX_CONFIGURATION", + 4: "PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_INVESTIGATE", + } + ProviderCredentialRefreshRecoveryAction_value = map[string]int32{ + "PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_UNSPECIFIED": 0, + "PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_RETRY": 1, + "PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_REAUTHORIZE": 2, + "PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_FIX_CONFIGURATION": 3, + "PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_INVESTIGATE": 4, + } +) + +func (x ProviderCredentialRefreshRecoveryAction) Enum() *ProviderCredentialRefreshRecoveryAction { + p := new(ProviderCredentialRefreshRecoveryAction) + *p = x + return p +} + +func (x ProviderCredentialRefreshRecoveryAction) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ProviderCredentialRefreshRecoveryAction) Descriptor() protoreflect.EnumDescriptor { + return file_openshell_proto_enumTypes[7].Descriptor() +} + +func (ProviderCredentialRefreshRecoveryAction) Type() protoreflect.EnumType { + return &file_openshell_proto_enumTypes[7] +} + +func (x ProviderCredentialRefreshRecoveryAction) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ProviderCredentialRefreshRecoveryAction.Descriptor instead. +func (ProviderCredentialRefreshRecoveryAction) EnumDescriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{7} +} + // IssueSandboxToken request. Empty body; identity is established by the // authentication credentials carried in the request headers (a projected // Kubernetes ServiceAccount JWT in the K8s driver path). @@ -5861,18 +5920,32 @@ func (x *ProviderCredentialRefresh) GetAdditionalOutputs() []*ProviderCredential } type ProviderCredentialRefreshStatus struct { - state protoimpl.MessageState `protogen:"open.v1"` - ProviderName string `protobuf:"bytes,1,opt,name=provider_name,json=providerName,proto3" json:"provider_name,omitempty"` - ProviderId string `protobuf:"bytes,2,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` - CredentialKey string `protobuf:"bytes,3,opt,name=credential_key,json=credentialKey,proto3" json:"credential_key,omitempty"` - Strategy ProviderCredentialRefreshStrategy `protobuf:"varint,4,opt,name=strategy,proto3,enum=openshell.v1.ProviderCredentialRefreshStrategy" json:"strategy,omitempty"` - Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"` - ExpiresAtMs int64 `protobuf:"varint,6,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` - NextRefreshAtMs int64 `protobuf:"varint,7,opt,name=next_refresh_at_ms,json=nextRefreshAtMs,proto3" json:"next_refresh_at_ms,omitempty"` - LastRefreshAtMs int64 `protobuf:"varint,8,opt,name=last_refresh_at_ms,json=lastRefreshAtMs,proto3" json:"last_refresh_at_ms,omitempty"` - LastError string `protobuf:"bytes,9,opt,name=last_error,json=lastError,proto3" json:"last_error,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + ProviderName string `protobuf:"bytes,1,opt,name=provider_name,json=providerName,proto3" json:"provider_name,omitempty"` + ProviderId string `protobuf:"bytes,2,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` + CredentialKey string `protobuf:"bytes,3,opt,name=credential_key,json=credentialKey,proto3" json:"credential_key,omitempty"` + Strategy ProviderCredentialRefreshStrategy `protobuf:"varint,4,opt,name=strategy,proto3,enum=openshell.v1.ProviderCredentialRefreshStrategy" json:"strategy,omitempty"` + Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"` + ExpiresAtMs int64 `protobuf:"varint,6,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` + // Next automatic refresh time in Unix epoch milliseconds. A value of + // 9223372036854775807 (int64 max) means no automatic retry is scheduled; + // consumers should render it as unset and use recovery_action to determine + // the required recovery workflow. + NextRefreshAtMs int64 `protobuf:"varint,7,opt,name=next_refresh_at_ms,json=nextRefreshAtMs,proto3" json:"next_refresh_at_ms,omitempty"` + LastRefreshAtMs int64 `protobuf:"varint,8,opt,name=last_refresh_at_ms,json=lastRefreshAtMs,proto3" json:"last_refresh_at_ms,omitempty"` + LastError string `protobuf:"bytes,9,opt,name=last_error,json=lastError,proto3" json:"last_error,omitempty"` + RecoveryAction ProviderCredentialRefreshRecoveryAction `protobuf:"varint,10,opt,name=recovery_action,json=recoveryAction,proto3,enum=openshell.v1.ProviderCredentialRefreshRecoveryAction" json:"recovery_action,omitempty"` + // Stable gateway-owned failure identifier, for example + // "oauth_invalid_grant". This is not provider-controlled prose and + // incorporates any recognized top-level OAuth error classification. + FailureCode string `protobuf:"bytes,11,opt,name=failure_code,json=failureCode,proto3" json:"failure_code,omitempty"` + // A bounded, recognized provider subtype that refines failure_code; clients + // do not need a separate provider_error field. Unknown provider-controlled + // values are not persisted or returned. + ProviderErrorSubtype string `protobuf:"bytes,12,opt,name=provider_error_subtype,json=providerErrorSubtype,proto3" json:"provider_error_subtype,omitempty"` + LastErrorAtMs int64 `protobuf:"varint,13,opt,name=last_error_at_ms,json=lastErrorAtMs,proto3" json:"last_error_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ProviderCredentialRefreshStatus) Reset() { @@ -5968,6 +6041,34 @@ func (x *ProviderCredentialRefreshStatus) GetLastError() string { return "" } +func (x *ProviderCredentialRefreshStatus) GetRecoveryAction() ProviderCredentialRefreshRecoveryAction { + if x != nil { + return x.RecoveryAction + } + return ProviderCredentialRefreshRecoveryAction_PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_UNSPECIFIED +} + +func (x *ProviderCredentialRefreshStatus) GetFailureCode() string { + if x != nil { + return x.FailureCode + } + return "" +} + +func (x *ProviderCredentialRefreshStatus) GetProviderErrorSubtype() string { + if x != nil { + return x.ProviderErrorSubtype + } + return "" +} + +func (x *ProviderCredentialRefreshStatus) GetLastErrorAtMs() int64 { + if x != nil { + return x.LastErrorAtMs + } + return 0 +} + // Provider profile local discovery declaration. type ProviderProfileDiscovery struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -6025,8 +6126,9 @@ type StoredProviderCredentialRefreshState struct { // Material names classified as secret. Newly configured values live in the // active credential driver and are absent from material. Legacy inline values // are not automatically migrated before OpenShell 0.1.0. - SecretMaterialKeys []string `protobuf:"bytes,7,rep,name=secret_material_keys,json=secretMaterialKeys,proto3" json:"secret_material_keys,omitempty"` - ExpiresAtMs int64 `protobuf:"varint,8,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` + SecretMaterialKeys []string `protobuf:"bytes,7,rep,name=secret_material_keys,json=secretMaterialKeys,proto3" json:"secret_material_keys,omitempty"` + ExpiresAtMs int64 `protobuf:"varint,8,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` + // int64 max parks the refresh until an explicit rotation or reconfiguration. NextRefreshAtMs int64 `protobuf:"varint,9,opt,name=next_refresh_at_ms,json=nextRefreshAtMs,proto3" json:"next_refresh_at_ms,omitempty"` LastRefreshAtMs int64 `protobuf:"varint,10,opt,name=last_refresh_at_ms,json=lastRefreshAtMs,proto3" json:"last_refresh_at_ms,omitempty"` Status string `protobuf:"bytes,11,opt,name=status,proto3" json:"status,omitempty"` @@ -6055,8 +6157,14 @@ type StoredProviderCredentialRefreshState struct { // Cleanup is retried by the refresh worker so a gateway crash or temporary // credential-backend outage does not lose the deletion reference. PendingSecretDeletions []*StoredRefreshMaterialDeletion `protobuf:"bytes,20,rep,name=pending_secret_deletions,json=pendingSecretDeletions,proto3" json:"pending_secret_deletions,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Structured recovery details for the most recent refresh failure. These + // fields contain only gateway-owned codes and recognized bounded values. + RecoveryAction ProviderCredentialRefreshRecoveryAction `protobuf:"varint,21,opt,name=recovery_action,json=recoveryAction,proto3,enum=openshell.v1.ProviderCredentialRefreshRecoveryAction" json:"recovery_action,omitempty"` + FailureCode string `protobuf:"bytes,22,opt,name=failure_code,json=failureCode,proto3" json:"failure_code,omitempty"` + ProviderErrorSubtype string `protobuf:"bytes,23,opt,name=provider_error_subtype,json=providerErrorSubtype,proto3" json:"provider_error_subtype,omitempty"` + LastErrorAtMs int64 `protobuf:"varint,24,opt,name=last_error_at_ms,json=lastErrorAtMs,proto3" json:"last_error_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *StoredProviderCredentialRefreshState) Reset() { @@ -6229,6 +6337,34 @@ func (x *StoredProviderCredentialRefreshState) GetPendingSecretDeletions() []*St return nil } +func (x *StoredProviderCredentialRefreshState) GetRecoveryAction() ProviderCredentialRefreshRecoveryAction { + if x != nil { + return x.RecoveryAction + } + return ProviderCredentialRefreshRecoveryAction_PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_UNSPECIFIED +} + +func (x *StoredProviderCredentialRefreshState) GetFailureCode() string { + if x != nil { + return x.FailureCode + } + return "" +} + +func (x *StoredProviderCredentialRefreshState) GetProviderErrorSubtype() string { + if x != nil { + return x.ProviderErrorSubtype + } + return "" +} + +func (x *StoredProviderCredentialRefreshState) GetLastErrorAtMs() int64 { + if x != nil { + return x.LastErrorAtMs + } + return 0 +} + type StoredRefreshMaterialDeletion struct { state protoimpl.MessageState `protogen:"open.v1"` // Original material name used to derive the credential driver's storage key. @@ -14233,7 +14369,7 @@ const file_openshell_proto_rawDesc = "" + "\x16refresh_before_seconds\x18\x04 \x01(\x03R\x14refreshBeforeSeconds\x120\n" + "\x14max_lifetime_seconds\x18\x05 \x01(\x03R\x12maxLifetimeSeconds\x12K\n" + "\bmaterial\x18\x06 \x03(\v2/.openshell.v1.ProviderCredentialRefreshMaterialR\bmaterial\x12\\\n" + - "\x12additional_outputs\x18\a \x03(\v2-.openshell.v1.ProviderCredentialRefreshOutputR\x11additionalOutputs\"\x90\x03\n" + + "\x12additional_outputs\x18\a \x03(\v2-.openshell.v1.ProviderCredentialRefreshOutputR\x11additionalOutputs\"\xf2\x04\n" + "\x1fProviderCredentialRefreshStatus\x12#\n" + "\rprovider_name\x18\x01 \x01(\tR\fproviderName\x12\x1f\n" + "\vprovider_id\x18\x02 \x01(\tR\n" + @@ -14245,9 +14381,14 @@ const file_openshell_proto_rawDesc = "" + "\x12next_refresh_at_ms\x18\a \x01(\x03R\x0fnextRefreshAtMs\x12+\n" + "\x12last_refresh_at_ms\x18\b \x01(\x03R\x0flastRefreshAtMs\x12\x1d\n" + "\n" + - "last_error\x18\t \x01(\tR\tlastError\"<\n" + + "last_error\x18\t \x01(\tR\tlastError\x12^\n" + + "\x0frecovery_action\x18\n" + + " \x01(\x0e25.openshell.v1.ProviderCredentialRefreshRecoveryActionR\x0erecoveryAction\x12!\n" + + "\ffailure_code\x18\v \x01(\tR\vfailureCode\x124\n" + + "\x16provider_error_subtype\x18\f \x01(\tR\x14providerErrorSubtype\x12'\n" + + "\x10last_error_at_ms\x18\r \x01(\x03R\rlastErrorAtMs\"<\n" + "\x18ProviderProfileDiscovery\x12 \n" + - "\vcredentials\x18\x01 \x03(\tR\vcredentials\"\xa7\v\n" + + "\vcredentials\x18\x01 \x03(\tR\vcredentials\"\x89\r\n" + "$StoredProviderCredentialRefreshState\x12>\n" + "\bmetadata\x18\x01 \x01(\v2\".openshell.datamodel.v1.ObjectMetaR\bmetadata\x12\x1f\n" + "\vprovider_id\x18\x02 \x01(\tR\n" + @@ -14271,7 +14412,11 @@ const file_openshell_proto_rawDesc = "" + "\x16additional_output_keys\x18\x11 \x03(\v2L.openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntryR\x14additionalOutputKeys\x12/\n" + "\x13authorization_epoch\x18\x12 \x01(\tR\x12authorizationEpoch\x12\x85\x01\n" + "\x17secret_material_handles\x18\x13 \x03(\v2M.openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntryR\x15secretMaterialHandles\x12e\n" + - "\x18pending_secret_deletions\x18\x14 \x03(\v2+.openshell.v1.StoredRefreshMaterialDeletionR\x16pendingSecretDeletions\x1a;\n" + + "\x18pending_secret_deletions\x18\x14 \x03(\v2+.openshell.v1.StoredRefreshMaterialDeletionR\x16pendingSecretDeletions\x12^\n" + + "\x0frecovery_action\x18\x15 \x01(\x0e25.openshell.v1.ProviderCredentialRefreshRecoveryActionR\x0erecoveryAction\x12!\n" + + "\ffailure_code\x18\x16 \x01(\tR\vfailureCode\x124\n" + + "\x16provider_error_subtype\x18\x17 \x01(\tR\x14providerErrorSubtype\x12'\n" + + "\x10last_error_at_ms\x18\x18 \x01(\x03R\rlastErrorAtMs\x1a;\n" + "\rMaterialEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1aG\n" + @@ -14930,7 +15075,13 @@ const file_openshell_proto_rawDesc = "" + "\rWorkspaceRole\x12\x1e\n" + "\x1aWORKSPACE_ROLE_UNSPECIFIED\x10\x00\x12\x17\n" + "\x13WORKSPACE_ROLE_USER\x10\x01\x12\x18\n" + - "\x14WORKSPACE_ROLE_ADMIN\x10\x022\xacF\n" + + "\x14WORKSPACE_ROLE_ADMIN\x10\x02*\xda\x02\n" + + "'ProviderCredentialRefreshRecoveryAction\x12;\n" + + "7PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_UNSPECIFIED\x10\x00\x125\n" + + "1PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_RETRY\x10\x01\x12;\n" + + "7PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_REAUTHORIZE\x10\x02\x12A\n" + + "=PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_FIX_CONFIGURATION\x10\x03\x12;\n" + + "7PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_INVESTIGATE\x10\x042\xacF\n" + "\tOpenShell\x12Z\n" + "\x06Health\x12\x1b.openshell.v1.HealthRequest\x1a\x1c.openshell.v1.HealthResponse\"\x15\x82\xb5\x18\x11\n" + "\x0funauthenticated\x12i\n" + @@ -15084,7 +15235,7 @@ func file_openshell_proto_rawDescGZIP() []byte { return file_openshell_proto_rawDescData } -var file_openshell_proto_enumTypes = make([]protoimpl.EnumInfo, 7) +var file_openshell_proto_enumTypes = make([]protoimpl.EnumInfo, 8) var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 217) var file_openshell_proto_goTypes = []any{ (SandboxPhase)(0), // 0: openshell.v1.SandboxPhase @@ -15094,546 +15245,549 @@ var file_openshell_proto_goTypes = []any{ (PolicyStatus)(0), // 4: openshell.v1.PolicyStatus (ServiceStatus)(0), // 5: openshell.v1.ServiceStatus (WorkspaceRole)(0), // 6: openshell.v1.WorkspaceRole - (*IssueSandboxTokenRequest)(nil), // 7: openshell.v1.IssueSandboxTokenRequest - (*IssueSandboxTokenResponse)(nil), // 8: openshell.v1.IssueSandboxTokenResponse - (*RefreshSandboxTokenRequest)(nil), // 9: openshell.v1.RefreshSandboxTokenRequest - (*RefreshSandboxTokenResponse)(nil), // 10: openshell.v1.RefreshSandboxTokenResponse - (*HealthRequest)(nil), // 11: openshell.v1.HealthRequest - (*HealthResponse)(nil), // 12: openshell.v1.HealthResponse - (*GetCurrentUserRequest)(nil), // 13: openshell.v1.GetCurrentUserRequest - (*GetCurrentUserResponse)(nil), // 14: openshell.v1.GetCurrentUserResponse - (*GetGatewayInfoRequest)(nil), // 15: openshell.v1.GetGatewayInfoRequest - (*GetGatewayInfoResponse)(nil), // 16: openshell.v1.GetGatewayInfoResponse - (*ComputeDriverInfo)(nil), // 17: openshell.v1.ComputeDriverInfo - (*ComputeDriverCapabilities)(nil), // 18: openshell.v1.ComputeDriverCapabilities - (*Sandbox)(nil), // 19: openshell.v1.Sandbox - (*SandboxSpec)(nil), // 20: openshell.v1.SandboxSpec - (*ResourceRequirements)(nil), // 21: openshell.v1.ResourceRequirements - (*GpuResourceRequirements)(nil), // 22: openshell.v1.GpuResourceRequirements - (*SandboxTemplate)(nil), // 23: openshell.v1.SandboxTemplate - (*SandboxStatus)(nil), // 24: openshell.v1.SandboxStatus - (*SandboxCondition)(nil), // 25: openshell.v1.SandboxCondition - (*PlatformEvent)(nil), // 26: openshell.v1.PlatformEvent - (*CreateSandboxRequest)(nil), // 27: openshell.v1.CreateSandboxRequest - (*GetSandboxRequest)(nil), // 28: openshell.v1.GetSandboxRequest - (*ListSandboxesRequest)(nil), // 29: openshell.v1.ListSandboxesRequest - (*ListSandboxProvidersRequest)(nil), // 30: openshell.v1.ListSandboxProvidersRequest - (*AttachSandboxProviderRequest)(nil), // 31: openshell.v1.AttachSandboxProviderRequest - (*DetachSandboxProviderRequest)(nil), // 32: openshell.v1.DetachSandboxProviderRequest - (*DeleteSandboxRequest)(nil), // 33: openshell.v1.DeleteSandboxRequest - (*StopSandboxRequest)(nil), // 34: openshell.v1.StopSandboxRequest - (*StartSandboxRequest)(nil), // 35: openshell.v1.StartSandboxRequest - (*SandboxResponse)(nil), // 36: openshell.v1.SandboxResponse - (*ListSandboxesResponse)(nil), // 37: openshell.v1.ListSandboxesResponse - (*ListSandboxProvidersResponse)(nil), // 38: openshell.v1.ListSandboxProvidersResponse - (*AttachSandboxProviderResponse)(nil), // 39: openshell.v1.AttachSandboxProviderResponse - (*DetachSandboxProviderResponse)(nil), // 40: openshell.v1.DetachSandboxProviderResponse - (*DeleteSandboxResponse)(nil), // 41: openshell.v1.DeleteSandboxResponse - (*CreateSshSessionRequest)(nil), // 42: openshell.v1.CreateSshSessionRequest - (*CreateSshSessionResponse)(nil), // 43: openshell.v1.CreateSshSessionResponse - (*ExposeServiceRequest)(nil), // 44: openshell.v1.ExposeServiceRequest - (*GetServiceRequest)(nil), // 45: openshell.v1.GetServiceRequest - (*ListServicesRequest)(nil), // 46: openshell.v1.ListServicesRequest - (*ListServicesResponse)(nil), // 47: openshell.v1.ListServicesResponse - (*DeleteServiceRequest)(nil), // 48: openshell.v1.DeleteServiceRequest - (*DeleteServiceResponse)(nil), // 49: openshell.v1.DeleteServiceResponse - (*ServiceEndpoint)(nil), // 50: openshell.v1.ServiceEndpoint - (*ServiceEndpointResponse)(nil), // 51: openshell.v1.ServiceEndpointResponse - (*RevokeSshSessionRequest)(nil), // 52: openshell.v1.RevokeSshSessionRequest - (*RevokeSshSessionResponse)(nil), // 53: openshell.v1.RevokeSshSessionResponse - (*ExecSandboxRequest)(nil), // 54: openshell.v1.ExecSandboxRequest - (*ExecSandboxStdout)(nil), // 55: openshell.v1.ExecSandboxStdout - (*ExecSandboxStderr)(nil), // 56: openshell.v1.ExecSandboxStderr - (*ExecSandboxExit)(nil), // 57: openshell.v1.ExecSandboxExit - (*ExecSandboxEvent)(nil), // 58: openshell.v1.ExecSandboxEvent - (*TcpForwardInit)(nil), // 59: openshell.v1.TcpForwardInit - (*TcpForwardFrame)(nil), // 60: openshell.v1.TcpForwardFrame - (*ExecSandboxInput)(nil), // 61: openshell.v1.ExecSandboxInput - (*ExecSandboxWindowResize)(nil), // 62: openshell.v1.ExecSandboxWindowResize - (*SshSession)(nil), // 63: openshell.v1.SshSession - (*WatchSandboxRequest)(nil), // 64: openshell.v1.WatchSandboxRequest - (*SandboxStreamEvent)(nil), // 65: openshell.v1.SandboxStreamEvent - (*SandboxLogLine)(nil), // 66: openshell.v1.SandboxLogLine - (*SandboxStreamWarning)(nil), // 67: openshell.v1.SandboxStreamWarning - (*CreateProviderRequest)(nil), // 68: openshell.v1.CreateProviderRequest - (*GetProviderRequest)(nil), // 69: openshell.v1.GetProviderRequest - (*ListProvidersRequest)(nil), // 70: openshell.v1.ListProvidersRequest - (*UpdateProviderRequest)(nil), // 71: openshell.v1.UpdateProviderRequest - (*DeleteProviderRequest)(nil), // 72: openshell.v1.DeleteProviderRequest - (*ProviderResponse)(nil), // 73: openshell.v1.ProviderResponse - (*ListProvidersResponse)(nil), // 74: openshell.v1.ListProvidersResponse - (*ListProviderProfilesRequest)(nil), // 75: openshell.v1.ListProviderProfilesRequest - (*GetProviderProfileRequest)(nil), // 76: openshell.v1.GetProviderProfileRequest - (*ProviderProfileImportItem)(nil), // 77: openshell.v1.ProviderProfileImportItem - (*ProviderProfileDiagnostic)(nil), // 78: openshell.v1.ProviderProfileDiagnostic - (*ProviderCredentialTokenGrantAudienceOverride)(nil), // 79: openshell.v1.ProviderCredentialTokenGrantAudienceOverride - (*ProviderCredentialTokenGrantSubjectToken)(nil), // 80: openshell.v1.ProviderCredentialTokenGrantSubjectToken - (*ProviderCredentialTokenGrant)(nil), // 81: openshell.v1.ProviderCredentialTokenGrant - (*ProviderProfileCredential)(nil), // 82: openshell.v1.ProviderProfileCredential - (*ProviderCredentialRefreshMaterial)(nil), // 83: openshell.v1.ProviderCredentialRefreshMaterial - (*ProviderCredentialRefreshOutput)(nil), // 84: openshell.v1.ProviderCredentialRefreshOutput - (*ProviderCredentialRefresh)(nil), // 85: openshell.v1.ProviderCredentialRefresh - (*ProviderCredentialRefreshStatus)(nil), // 86: openshell.v1.ProviderCredentialRefreshStatus - (*ProviderProfileDiscovery)(nil), // 87: openshell.v1.ProviderProfileDiscovery - (*StoredProviderCredentialRefreshState)(nil), // 88: openshell.v1.StoredProviderCredentialRefreshState - (*StoredRefreshMaterialDeletion)(nil), // 89: openshell.v1.StoredRefreshMaterialDeletion - (*GetProviderRefreshStatusRequest)(nil), // 90: openshell.v1.GetProviderRefreshStatusRequest - (*GetProviderRefreshStatusResponse)(nil), // 91: openshell.v1.GetProviderRefreshStatusResponse - (*ConfigureProviderRefreshRequest)(nil), // 92: openshell.v1.ConfigureProviderRefreshRequest - (*ConfigureProviderRefreshResponse)(nil), // 93: openshell.v1.ConfigureProviderRefreshResponse - (*RotateProviderCredentialRequest)(nil), // 94: openshell.v1.RotateProviderCredentialRequest - (*RotateProviderCredentialResponse)(nil), // 95: openshell.v1.RotateProviderCredentialResponse - (*DeleteProviderRefreshRequest)(nil), // 96: openshell.v1.DeleteProviderRefreshRequest - (*DeleteProviderRefreshResponse)(nil), // 97: openshell.v1.DeleteProviderRefreshResponse - (*ProviderProfile)(nil), // 98: openshell.v1.ProviderProfile - (*StoredProviderProfile)(nil), // 99: openshell.v1.StoredProviderProfile - (*ProviderProfileResponse)(nil), // 100: openshell.v1.ProviderProfileResponse - (*ListProviderProfilesResponse)(nil), // 101: openshell.v1.ListProviderProfilesResponse - (*ImportProviderProfilesRequest)(nil), // 102: openshell.v1.ImportProviderProfilesRequest - (*ImportProviderProfilesResponse)(nil), // 103: openshell.v1.ImportProviderProfilesResponse - (*UpdateProviderProfilesRequest)(nil), // 104: openshell.v1.UpdateProviderProfilesRequest - (*UpdateProviderProfilesResponse)(nil), // 105: openshell.v1.UpdateProviderProfilesResponse - (*LintProviderProfilesRequest)(nil), // 106: openshell.v1.LintProviderProfilesRequest - (*LintProviderProfilesResponse)(nil), // 107: openshell.v1.LintProviderProfilesResponse - (*DeleteProviderResponse)(nil), // 108: openshell.v1.DeleteProviderResponse - (*DeleteProviderProfileRequest)(nil), // 109: openshell.v1.DeleteProviderProfileRequest - (*DeleteProviderProfileResponse)(nil), // 110: openshell.v1.DeleteProviderProfileResponse - (*GetSandboxProviderEnvironmentRequest)(nil), // 111: openshell.v1.GetSandboxProviderEnvironmentRequest - (*StaticCredentialEndpointBinding)(nil), // 112: openshell.v1.StaticCredentialEndpointBinding - (*StaticCredentialBinding)(nil), // 113: openshell.v1.StaticCredentialBinding - (*GetSandboxProviderEnvironmentResponse)(nil), // 114: openshell.v1.GetSandboxProviderEnvironmentResponse - (*ExchangeProviderSubjectTokenRequest)(nil), // 115: openshell.v1.ExchangeProviderSubjectTokenRequest - (*ExchangeProviderSubjectTokenResponse)(nil), // 116: openshell.v1.ExchangeProviderSubjectTokenResponse - (*UpdateConfigRequest)(nil), // 117: openshell.v1.UpdateConfigRequest - (*PolicyMergeOperation)(nil), // 118: openshell.v1.PolicyMergeOperation - (*AddNetworkRule)(nil), // 119: openshell.v1.AddNetworkRule - (*RemoveNetworkEndpoint)(nil), // 120: openshell.v1.RemoveNetworkEndpoint - (*RemoveNetworkRule)(nil), // 121: openshell.v1.RemoveNetworkRule - (*AddDenyRules)(nil), // 122: openshell.v1.AddDenyRules - (*AddAllowRules)(nil), // 123: openshell.v1.AddAllowRules - (*RemoveNetworkBinary)(nil), // 124: openshell.v1.RemoveNetworkBinary - (*UpdateConfigResponse)(nil), // 125: openshell.v1.UpdateConfigResponse - (*GetSandboxPolicyStatusRequest)(nil), // 126: openshell.v1.GetSandboxPolicyStatusRequest - (*GetSandboxPolicyStatusResponse)(nil), // 127: openshell.v1.GetSandboxPolicyStatusResponse - (*ListSandboxPoliciesRequest)(nil), // 128: openshell.v1.ListSandboxPoliciesRequest - (*ListSandboxPoliciesResponse)(nil), // 129: openshell.v1.ListSandboxPoliciesResponse - (*ReportPolicyStatusRequest)(nil), // 130: openshell.v1.ReportPolicyStatusRequest - (*ReportPolicyStatusResponse)(nil), // 131: openshell.v1.ReportPolicyStatusResponse - (*SandboxPolicyRevision)(nil), // 132: openshell.v1.SandboxPolicyRevision - (*GetSandboxLogsRequest)(nil), // 133: openshell.v1.GetSandboxLogsRequest - (*PushSandboxLogsRequest)(nil), // 134: openshell.v1.PushSandboxLogsRequest - (*PushSandboxLogsResponse)(nil), // 135: openshell.v1.PushSandboxLogsResponse - (*GetSandboxLogsResponse)(nil), // 136: openshell.v1.GetSandboxLogsResponse - (*SupervisorMessage)(nil), // 137: openshell.v1.SupervisorMessage - (*GatewayMessage)(nil), // 138: openshell.v1.GatewayMessage - (*SupervisorHello)(nil), // 139: openshell.v1.SupervisorHello - (*SessionAccepted)(nil), // 140: openshell.v1.SessionAccepted - (*SessionRejected)(nil), // 141: openshell.v1.SessionRejected - (*SupervisorHeartbeat)(nil), // 142: openshell.v1.SupervisorHeartbeat - (*GatewayHeartbeat)(nil), // 143: openshell.v1.GatewayHeartbeat - (*ReportMainProcessExitRequest)(nil), // 144: openshell.v1.ReportMainProcessExitRequest - (*ReportMainProcessExitResponse)(nil), // 145: openshell.v1.ReportMainProcessExitResponse - (*RelayOpen)(nil), // 146: openshell.v1.RelayOpen - (*SshRelayTarget)(nil), // 147: openshell.v1.SshRelayTarget - (*TcpRelayTarget)(nil), // 148: openshell.v1.TcpRelayTarget - (*RelayInit)(nil), // 149: openshell.v1.RelayInit - (*RelayFrame)(nil), // 150: openshell.v1.RelayFrame - (*RelayOpenResult)(nil), // 151: openshell.v1.RelayOpenResult - (*RelayClose)(nil), // 152: openshell.v1.RelayClose - (*L7RequestSample)(nil), // 153: openshell.v1.L7RequestSample - (*DenialSummary)(nil), // 154: openshell.v1.DenialSummary - (*DenialGroupCount)(nil), // 155: openshell.v1.DenialGroupCount - (*NetworkActivitySummary)(nil), // 156: openshell.v1.NetworkActivitySummary - (*PolicyChunk)(nil), // 157: openshell.v1.PolicyChunk - (*DraftPolicyUpdate)(nil), // 158: openshell.v1.DraftPolicyUpdate - (*SubmitPolicyAnalysisRequest)(nil), // 159: openshell.v1.SubmitPolicyAnalysisRequest - (*SubmitPolicyAnalysisResponse)(nil), // 160: openshell.v1.SubmitPolicyAnalysisResponse - (*GetDraftPolicyRequest)(nil), // 161: openshell.v1.GetDraftPolicyRequest - (*GetDraftPolicyResponse)(nil), // 162: openshell.v1.GetDraftPolicyResponse - (*ApproveDraftChunkRequest)(nil), // 163: openshell.v1.ApproveDraftChunkRequest - (*ApproveDraftChunkResponse)(nil), // 164: openshell.v1.ApproveDraftChunkResponse - (*RejectDraftChunkRequest)(nil), // 165: openshell.v1.RejectDraftChunkRequest - (*RejectDraftChunkResponse)(nil), // 166: openshell.v1.RejectDraftChunkResponse - (*DraftChunkApproval)(nil), // 167: openshell.v1.DraftChunkApproval - (*ApproveAllDraftChunksRequest)(nil), // 168: openshell.v1.ApproveAllDraftChunksRequest - (*ApproveAllDraftChunksResponse)(nil), // 169: openshell.v1.ApproveAllDraftChunksResponse - (*EditDraftChunkRequest)(nil), // 170: openshell.v1.EditDraftChunkRequest - (*EditDraftChunkResponse)(nil), // 171: openshell.v1.EditDraftChunkResponse - (*UndoDraftChunkRequest)(nil), // 172: openshell.v1.UndoDraftChunkRequest - (*UndoDraftChunkResponse)(nil), // 173: openshell.v1.UndoDraftChunkResponse - (*ClearDraftChunksRequest)(nil), // 174: openshell.v1.ClearDraftChunksRequest - (*ClearDraftChunksResponse)(nil), // 175: openshell.v1.ClearDraftChunksResponse - (*GetDraftHistoryRequest)(nil), // 176: openshell.v1.GetDraftHistoryRequest - (*DraftHistoryEntry)(nil), // 177: openshell.v1.DraftHistoryEntry - (*GetDraftHistoryResponse)(nil), // 178: openshell.v1.GetDraftHistoryResponse - (*PolicyRevisionPayload)(nil), // 179: openshell.v1.PolicyRevisionPayload - (*DraftChunkPayload)(nil), // 180: openshell.v1.DraftChunkPayload - (*StoredPolicyRevision)(nil), // 181: openshell.v1.StoredPolicyRevision - (*StoredDraftChunk)(nil), // 182: openshell.v1.StoredDraftChunk - (*CreateWorkspaceRequest)(nil), // 183: openshell.v1.CreateWorkspaceRequest - (*CreateWorkspaceResponse)(nil), // 184: openshell.v1.CreateWorkspaceResponse - (*GetWorkspaceRequest)(nil), // 185: openshell.v1.GetWorkspaceRequest - (*GetWorkspaceResponse)(nil), // 186: openshell.v1.GetWorkspaceResponse - (*ListWorkspacesRequest)(nil), // 187: openshell.v1.ListWorkspacesRequest - (*ListWorkspacesResponse)(nil), // 188: openshell.v1.ListWorkspacesResponse - (*DeleteWorkspaceRequest)(nil), // 189: openshell.v1.DeleteWorkspaceRequest - (*DeleteWorkspaceResponse)(nil), // 190: openshell.v1.DeleteWorkspaceResponse - (*WorkspaceMember)(nil), // 191: openshell.v1.WorkspaceMember - (*AddWorkspaceMemberRequest)(nil), // 192: openshell.v1.AddWorkspaceMemberRequest - (*AddWorkspaceMemberResponse)(nil), // 193: openshell.v1.AddWorkspaceMemberResponse - (*RemoveWorkspaceMemberRequest)(nil), // 194: openshell.v1.RemoveWorkspaceMemberRequest - (*RemoveWorkspaceMemberResponse)(nil), // 195: openshell.v1.RemoveWorkspaceMemberResponse - (*ListWorkspaceMembersRequest)(nil), // 196: openshell.v1.ListWorkspaceMembersRequest - (*ListWorkspaceMembersResponse)(nil), // 197: openshell.v1.ListWorkspaceMembersResponse - (*ExtensionServiceCredential)(nil), // 198: openshell.v1.ExtensionServiceCredential - nil, // 199: openshell.v1.SandboxSpec.EnvironmentEntry - nil, // 200: openshell.v1.SandboxTemplate.LabelsEntry - nil, // 201: openshell.v1.SandboxTemplate.AnnotationsEntry - nil, // 202: openshell.v1.SandboxTemplate.EnvironmentEntry - nil, // 203: openshell.v1.PlatformEvent.MetadataEntry - nil, // 204: openshell.v1.CreateSandboxRequest.LabelsEntry - nil, // 205: openshell.v1.CreateSandboxRequest.AnnotationsEntry - nil, // 206: openshell.v1.ExecSandboxRequest.EnvironmentEntry - nil, // 207: openshell.v1.SandboxLogLine.FieldsEntry - nil, // 208: openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry - nil, // 209: openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry - nil, // 210: openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry - nil, // 211: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry - nil, // 212: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry - nil, // 213: openshell.v1.ProviderProfile.AnnotationsEntry - nil, // 214: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - nil, // 215: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry - nil, // 216: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - nil, // 217: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry - nil, // 218: openshell.v1.UpdateConfigRequest.AnnotationsEntry - nil, // 219: openshell.v1.UpdateConfigResponse.AnnotationsEntry - nil, // 220: openshell.v1.SandboxPolicyRevision.ProvenanceEntry - nil, // 221: openshell.v1.PolicyRevisionPayload.ProvenanceEntry - nil, // 222: openshell.v1.StoredPolicyRevision.ProvenanceEntry - nil, // 223: openshell.v1.CreateWorkspaceRequest.LabelsEntry - (*datamodelv1.ObjectMeta)(nil), // 224: openshell.datamodel.v1.ObjectMeta - (*sandboxv1.SandboxPolicy)(nil), // 225: openshell.sandbox.v1.SandboxPolicy - (*structpb.Struct)(nil), // 226: google.protobuf.Struct - (*datamodelv1.Provider)(nil), // 227: openshell.datamodel.v1.Provider - (*datamodelv1.CredentialHandle)(nil), // 228: openshell.datamodel.v1.CredentialHandle - (*sandboxv1.NetworkEndpoint)(nil), // 229: openshell.sandbox.v1.NetworkEndpoint - (*sandboxv1.NetworkBinary)(nil), // 230: openshell.sandbox.v1.NetworkBinary - (*sandboxv1.SettingValue)(nil), // 231: openshell.sandbox.v1.SettingValue - (*sandboxv1.NetworkPolicyRule)(nil), // 232: openshell.sandbox.v1.NetworkPolicyRule - (*sandboxv1.L7DenyRule)(nil), // 233: openshell.sandbox.v1.L7DenyRule - (*sandboxv1.L7Rule)(nil), // 234: openshell.sandbox.v1.L7Rule - (*datamodelv1.Workspace)(nil), // 235: openshell.datamodel.v1.Workspace - (*sandboxv1.GetSandboxConfigRequest)(nil), // 236: openshell.sandbox.v1.GetSandboxConfigRequest - (*sandboxv1.GetGatewayConfigRequest)(nil), // 237: openshell.sandbox.v1.GetGatewayConfigRequest - (*sandboxv1.GetSandboxConfigResponse)(nil), // 238: openshell.sandbox.v1.GetSandboxConfigResponse - (*sandboxv1.GetGatewayConfigResponse)(nil), // 239: openshell.sandbox.v1.GetGatewayConfigResponse + (ProviderCredentialRefreshRecoveryAction)(0), // 7: openshell.v1.ProviderCredentialRefreshRecoveryAction + (*IssueSandboxTokenRequest)(nil), // 8: openshell.v1.IssueSandboxTokenRequest + (*IssueSandboxTokenResponse)(nil), // 9: openshell.v1.IssueSandboxTokenResponse + (*RefreshSandboxTokenRequest)(nil), // 10: openshell.v1.RefreshSandboxTokenRequest + (*RefreshSandboxTokenResponse)(nil), // 11: openshell.v1.RefreshSandboxTokenResponse + (*HealthRequest)(nil), // 12: openshell.v1.HealthRequest + (*HealthResponse)(nil), // 13: openshell.v1.HealthResponse + (*GetCurrentUserRequest)(nil), // 14: openshell.v1.GetCurrentUserRequest + (*GetCurrentUserResponse)(nil), // 15: openshell.v1.GetCurrentUserResponse + (*GetGatewayInfoRequest)(nil), // 16: openshell.v1.GetGatewayInfoRequest + (*GetGatewayInfoResponse)(nil), // 17: openshell.v1.GetGatewayInfoResponse + (*ComputeDriverInfo)(nil), // 18: openshell.v1.ComputeDriverInfo + (*ComputeDriverCapabilities)(nil), // 19: openshell.v1.ComputeDriverCapabilities + (*Sandbox)(nil), // 20: openshell.v1.Sandbox + (*SandboxSpec)(nil), // 21: openshell.v1.SandboxSpec + (*ResourceRequirements)(nil), // 22: openshell.v1.ResourceRequirements + (*GpuResourceRequirements)(nil), // 23: openshell.v1.GpuResourceRequirements + (*SandboxTemplate)(nil), // 24: openshell.v1.SandboxTemplate + (*SandboxStatus)(nil), // 25: openshell.v1.SandboxStatus + (*SandboxCondition)(nil), // 26: openshell.v1.SandboxCondition + (*PlatformEvent)(nil), // 27: openshell.v1.PlatformEvent + (*CreateSandboxRequest)(nil), // 28: openshell.v1.CreateSandboxRequest + (*GetSandboxRequest)(nil), // 29: openshell.v1.GetSandboxRequest + (*ListSandboxesRequest)(nil), // 30: openshell.v1.ListSandboxesRequest + (*ListSandboxProvidersRequest)(nil), // 31: openshell.v1.ListSandboxProvidersRequest + (*AttachSandboxProviderRequest)(nil), // 32: openshell.v1.AttachSandboxProviderRequest + (*DetachSandboxProviderRequest)(nil), // 33: openshell.v1.DetachSandboxProviderRequest + (*DeleteSandboxRequest)(nil), // 34: openshell.v1.DeleteSandboxRequest + (*StopSandboxRequest)(nil), // 35: openshell.v1.StopSandboxRequest + (*StartSandboxRequest)(nil), // 36: openshell.v1.StartSandboxRequest + (*SandboxResponse)(nil), // 37: openshell.v1.SandboxResponse + (*ListSandboxesResponse)(nil), // 38: openshell.v1.ListSandboxesResponse + (*ListSandboxProvidersResponse)(nil), // 39: openshell.v1.ListSandboxProvidersResponse + (*AttachSandboxProviderResponse)(nil), // 40: openshell.v1.AttachSandboxProviderResponse + (*DetachSandboxProviderResponse)(nil), // 41: openshell.v1.DetachSandboxProviderResponse + (*DeleteSandboxResponse)(nil), // 42: openshell.v1.DeleteSandboxResponse + (*CreateSshSessionRequest)(nil), // 43: openshell.v1.CreateSshSessionRequest + (*CreateSshSessionResponse)(nil), // 44: openshell.v1.CreateSshSessionResponse + (*ExposeServiceRequest)(nil), // 45: openshell.v1.ExposeServiceRequest + (*GetServiceRequest)(nil), // 46: openshell.v1.GetServiceRequest + (*ListServicesRequest)(nil), // 47: openshell.v1.ListServicesRequest + (*ListServicesResponse)(nil), // 48: openshell.v1.ListServicesResponse + (*DeleteServiceRequest)(nil), // 49: openshell.v1.DeleteServiceRequest + (*DeleteServiceResponse)(nil), // 50: openshell.v1.DeleteServiceResponse + (*ServiceEndpoint)(nil), // 51: openshell.v1.ServiceEndpoint + (*ServiceEndpointResponse)(nil), // 52: openshell.v1.ServiceEndpointResponse + (*RevokeSshSessionRequest)(nil), // 53: openshell.v1.RevokeSshSessionRequest + (*RevokeSshSessionResponse)(nil), // 54: openshell.v1.RevokeSshSessionResponse + (*ExecSandboxRequest)(nil), // 55: openshell.v1.ExecSandboxRequest + (*ExecSandboxStdout)(nil), // 56: openshell.v1.ExecSandboxStdout + (*ExecSandboxStderr)(nil), // 57: openshell.v1.ExecSandboxStderr + (*ExecSandboxExit)(nil), // 58: openshell.v1.ExecSandboxExit + (*ExecSandboxEvent)(nil), // 59: openshell.v1.ExecSandboxEvent + (*TcpForwardInit)(nil), // 60: openshell.v1.TcpForwardInit + (*TcpForwardFrame)(nil), // 61: openshell.v1.TcpForwardFrame + (*ExecSandboxInput)(nil), // 62: openshell.v1.ExecSandboxInput + (*ExecSandboxWindowResize)(nil), // 63: openshell.v1.ExecSandboxWindowResize + (*SshSession)(nil), // 64: openshell.v1.SshSession + (*WatchSandboxRequest)(nil), // 65: openshell.v1.WatchSandboxRequest + (*SandboxStreamEvent)(nil), // 66: openshell.v1.SandboxStreamEvent + (*SandboxLogLine)(nil), // 67: openshell.v1.SandboxLogLine + (*SandboxStreamWarning)(nil), // 68: openshell.v1.SandboxStreamWarning + (*CreateProviderRequest)(nil), // 69: openshell.v1.CreateProviderRequest + (*GetProviderRequest)(nil), // 70: openshell.v1.GetProviderRequest + (*ListProvidersRequest)(nil), // 71: openshell.v1.ListProvidersRequest + (*UpdateProviderRequest)(nil), // 72: openshell.v1.UpdateProviderRequest + (*DeleteProviderRequest)(nil), // 73: openshell.v1.DeleteProviderRequest + (*ProviderResponse)(nil), // 74: openshell.v1.ProviderResponse + (*ListProvidersResponse)(nil), // 75: openshell.v1.ListProvidersResponse + (*ListProviderProfilesRequest)(nil), // 76: openshell.v1.ListProviderProfilesRequest + (*GetProviderProfileRequest)(nil), // 77: openshell.v1.GetProviderProfileRequest + (*ProviderProfileImportItem)(nil), // 78: openshell.v1.ProviderProfileImportItem + (*ProviderProfileDiagnostic)(nil), // 79: openshell.v1.ProviderProfileDiagnostic + (*ProviderCredentialTokenGrantAudienceOverride)(nil), // 80: openshell.v1.ProviderCredentialTokenGrantAudienceOverride + (*ProviderCredentialTokenGrantSubjectToken)(nil), // 81: openshell.v1.ProviderCredentialTokenGrantSubjectToken + (*ProviderCredentialTokenGrant)(nil), // 82: openshell.v1.ProviderCredentialTokenGrant + (*ProviderProfileCredential)(nil), // 83: openshell.v1.ProviderProfileCredential + (*ProviderCredentialRefreshMaterial)(nil), // 84: openshell.v1.ProviderCredentialRefreshMaterial + (*ProviderCredentialRefreshOutput)(nil), // 85: openshell.v1.ProviderCredentialRefreshOutput + (*ProviderCredentialRefresh)(nil), // 86: openshell.v1.ProviderCredentialRefresh + (*ProviderCredentialRefreshStatus)(nil), // 87: openshell.v1.ProviderCredentialRefreshStatus + (*ProviderProfileDiscovery)(nil), // 88: openshell.v1.ProviderProfileDiscovery + (*StoredProviderCredentialRefreshState)(nil), // 89: openshell.v1.StoredProviderCredentialRefreshState + (*StoredRefreshMaterialDeletion)(nil), // 90: openshell.v1.StoredRefreshMaterialDeletion + (*GetProviderRefreshStatusRequest)(nil), // 91: openshell.v1.GetProviderRefreshStatusRequest + (*GetProviderRefreshStatusResponse)(nil), // 92: openshell.v1.GetProviderRefreshStatusResponse + (*ConfigureProviderRefreshRequest)(nil), // 93: openshell.v1.ConfigureProviderRefreshRequest + (*ConfigureProviderRefreshResponse)(nil), // 94: openshell.v1.ConfigureProviderRefreshResponse + (*RotateProviderCredentialRequest)(nil), // 95: openshell.v1.RotateProviderCredentialRequest + (*RotateProviderCredentialResponse)(nil), // 96: openshell.v1.RotateProviderCredentialResponse + (*DeleteProviderRefreshRequest)(nil), // 97: openshell.v1.DeleteProviderRefreshRequest + (*DeleteProviderRefreshResponse)(nil), // 98: openshell.v1.DeleteProviderRefreshResponse + (*ProviderProfile)(nil), // 99: openshell.v1.ProviderProfile + (*StoredProviderProfile)(nil), // 100: openshell.v1.StoredProviderProfile + (*ProviderProfileResponse)(nil), // 101: openshell.v1.ProviderProfileResponse + (*ListProviderProfilesResponse)(nil), // 102: openshell.v1.ListProviderProfilesResponse + (*ImportProviderProfilesRequest)(nil), // 103: openshell.v1.ImportProviderProfilesRequest + (*ImportProviderProfilesResponse)(nil), // 104: openshell.v1.ImportProviderProfilesResponse + (*UpdateProviderProfilesRequest)(nil), // 105: openshell.v1.UpdateProviderProfilesRequest + (*UpdateProviderProfilesResponse)(nil), // 106: openshell.v1.UpdateProviderProfilesResponse + (*LintProviderProfilesRequest)(nil), // 107: openshell.v1.LintProviderProfilesRequest + (*LintProviderProfilesResponse)(nil), // 108: openshell.v1.LintProviderProfilesResponse + (*DeleteProviderResponse)(nil), // 109: openshell.v1.DeleteProviderResponse + (*DeleteProviderProfileRequest)(nil), // 110: openshell.v1.DeleteProviderProfileRequest + (*DeleteProviderProfileResponse)(nil), // 111: openshell.v1.DeleteProviderProfileResponse + (*GetSandboxProviderEnvironmentRequest)(nil), // 112: openshell.v1.GetSandboxProviderEnvironmentRequest + (*StaticCredentialEndpointBinding)(nil), // 113: openshell.v1.StaticCredentialEndpointBinding + (*StaticCredentialBinding)(nil), // 114: openshell.v1.StaticCredentialBinding + (*GetSandboxProviderEnvironmentResponse)(nil), // 115: openshell.v1.GetSandboxProviderEnvironmentResponse + (*ExchangeProviderSubjectTokenRequest)(nil), // 116: openshell.v1.ExchangeProviderSubjectTokenRequest + (*ExchangeProviderSubjectTokenResponse)(nil), // 117: openshell.v1.ExchangeProviderSubjectTokenResponse + (*UpdateConfigRequest)(nil), // 118: openshell.v1.UpdateConfigRequest + (*PolicyMergeOperation)(nil), // 119: openshell.v1.PolicyMergeOperation + (*AddNetworkRule)(nil), // 120: openshell.v1.AddNetworkRule + (*RemoveNetworkEndpoint)(nil), // 121: openshell.v1.RemoveNetworkEndpoint + (*RemoveNetworkRule)(nil), // 122: openshell.v1.RemoveNetworkRule + (*AddDenyRules)(nil), // 123: openshell.v1.AddDenyRules + (*AddAllowRules)(nil), // 124: openshell.v1.AddAllowRules + (*RemoveNetworkBinary)(nil), // 125: openshell.v1.RemoveNetworkBinary + (*UpdateConfigResponse)(nil), // 126: openshell.v1.UpdateConfigResponse + (*GetSandboxPolicyStatusRequest)(nil), // 127: openshell.v1.GetSandboxPolicyStatusRequest + (*GetSandboxPolicyStatusResponse)(nil), // 128: openshell.v1.GetSandboxPolicyStatusResponse + (*ListSandboxPoliciesRequest)(nil), // 129: openshell.v1.ListSandboxPoliciesRequest + (*ListSandboxPoliciesResponse)(nil), // 130: openshell.v1.ListSandboxPoliciesResponse + (*ReportPolicyStatusRequest)(nil), // 131: openshell.v1.ReportPolicyStatusRequest + (*ReportPolicyStatusResponse)(nil), // 132: openshell.v1.ReportPolicyStatusResponse + (*SandboxPolicyRevision)(nil), // 133: openshell.v1.SandboxPolicyRevision + (*GetSandboxLogsRequest)(nil), // 134: openshell.v1.GetSandboxLogsRequest + (*PushSandboxLogsRequest)(nil), // 135: openshell.v1.PushSandboxLogsRequest + (*PushSandboxLogsResponse)(nil), // 136: openshell.v1.PushSandboxLogsResponse + (*GetSandboxLogsResponse)(nil), // 137: openshell.v1.GetSandboxLogsResponse + (*SupervisorMessage)(nil), // 138: openshell.v1.SupervisorMessage + (*GatewayMessage)(nil), // 139: openshell.v1.GatewayMessage + (*SupervisorHello)(nil), // 140: openshell.v1.SupervisorHello + (*SessionAccepted)(nil), // 141: openshell.v1.SessionAccepted + (*SessionRejected)(nil), // 142: openshell.v1.SessionRejected + (*SupervisorHeartbeat)(nil), // 143: openshell.v1.SupervisorHeartbeat + (*GatewayHeartbeat)(nil), // 144: openshell.v1.GatewayHeartbeat + (*ReportMainProcessExitRequest)(nil), // 145: openshell.v1.ReportMainProcessExitRequest + (*ReportMainProcessExitResponse)(nil), // 146: openshell.v1.ReportMainProcessExitResponse + (*RelayOpen)(nil), // 147: openshell.v1.RelayOpen + (*SshRelayTarget)(nil), // 148: openshell.v1.SshRelayTarget + (*TcpRelayTarget)(nil), // 149: openshell.v1.TcpRelayTarget + (*RelayInit)(nil), // 150: openshell.v1.RelayInit + (*RelayFrame)(nil), // 151: openshell.v1.RelayFrame + (*RelayOpenResult)(nil), // 152: openshell.v1.RelayOpenResult + (*RelayClose)(nil), // 153: openshell.v1.RelayClose + (*L7RequestSample)(nil), // 154: openshell.v1.L7RequestSample + (*DenialSummary)(nil), // 155: openshell.v1.DenialSummary + (*DenialGroupCount)(nil), // 156: openshell.v1.DenialGroupCount + (*NetworkActivitySummary)(nil), // 157: openshell.v1.NetworkActivitySummary + (*PolicyChunk)(nil), // 158: openshell.v1.PolicyChunk + (*DraftPolicyUpdate)(nil), // 159: openshell.v1.DraftPolicyUpdate + (*SubmitPolicyAnalysisRequest)(nil), // 160: openshell.v1.SubmitPolicyAnalysisRequest + (*SubmitPolicyAnalysisResponse)(nil), // 161: openshell.v1.SubmitPolicyAnalysisResponse + (*GetDraftPolicyRequest)(nil), // 162: openshell.v1.GetDraftPolicyRequest + (*GetDraftPolicyResponse)(nil), // 163: openshell.v1.GetDraftPolicyResponse + (*ApproveDraftChunkRequest)(nil), // 164: openshell.v1.ApproveDraftChunkRequest + (*ApproveDraftChunkResponse)(nil), // 165: openshell.v1.ApproveDraftChunkResponse + (*RejectDraftChunkRequest)(nil), // 166: openshell.v1.RejectDraftChunkRequest + (*RejectDraftChunkResponse)(nil), // 167: openshell.v1.RejectDraftChunkResponse + (*DraftChunkApproval)(nil), // 168: openshell.v1.DraftChunkApproval + (*ApproveAllDraftChunksRequest)(nil), // 169: openshell.v1.ApproveAllDraftChunksRequest + (*ApproveAllDraftChunksResponse)(nil), // 170: openshell.v1.ApproveAllDraftChunksResponse + (*EditDraftChunkRequest)(nil), // 171: openshell.v1.EditDraftChunkRequest + (*EditDraftChunkResponse)(nil), // 172: openshell.v1.EditDraftChunkResponse + (*UndoDraftChunkRequest)(nil), // 173: openshell.v1.UndoDraftChunkRequest + (*UndoDraftChunkResponse)(nil), // 174: openshell.v1.UndoDraftChunkResponse + (*ClearDraftChunksRequest)(nil), // 175: openshell.v1.ClearDraftChunksRequest + (*ClearDraftChunksResponse)(nil), // 176: openshell.v1.ClearDraftChunksResponse + (*GetDraftHistoryRequest)(nil), // 177: openshell.v1.GetDraftHistoryRequest + (*DraftHistoryEntry)(nil), // 178: openshell.v1.DraftHistoryEntry + (*GetDraftHistoryResponse)(nil), // 179: openshell.v1.GetDraftHistoryResponse + (*PolicyRevisionPayload)(nil), // 180: openshell.v1.PolicyRevisionPayload + (*DraftChunkPayload)(nil), // 181: openshell.v1.DraftChunkPayload + (*StoredPolicyRevision)(nil), // 182: openshell.v1.StoredPolicyRevision + (*StoredDraftChunk)(nil), // 183: openshell.v1.StoredDraftChunk + (*CreateWorkspaceRequest)(nil), // 184: openshell.v1.CreateWorkspaceRequest + (*CreateWorkspaceResponse)(nil), // 185: openshell.v1.CreateWorkspaceResponse + (*GetWorkspaceRequest)(nil), // 186: openshell.v1.GetWorkspaceRequest + (*GetWorkspaceResponse)(nil), // 187: openshell.v1.GetWorkspaceResponse + (*ListWorkspacesRequest)(nil), // 188: openshell.v1.ListWorkspacesRequest + (*ListWorkspacesResponse)(nil), // 189: openshell.v1.ListWorkspacesResponse + (*DeleteWorkspaceRequest)(nil), // 190: openshell.v1.DeleteWorkspaceRequest + (*DeleteWorkspaceResponse)(nil), // 191: openshell.v1.DeleteWorkspaceResponse + (*WorkspaceMember)(nil), // 192: openshell.v1.WorkspaceMember + (*AddWorkspaceMemberRequest)(nil), // 193: openshell.v1.AddWorkspaceMemberRequest + (*AddWorkspaceMemberResponse)(nil), // 194: openshell.v1.AddWorkspaceMemberResponse + (*RemoveWorkspaceMemberRequest)(nil), // 195: openshell.v1.RemoveWorkspaceMemberRequest + (*RemoveWorkspaceMemberResponse)(nil), // 196: openshell.v1.RemoveWorkspaceMemberResponse + (*ListWorkspaceMembersRequest)(nil), // 197: openshell.v1.ListWorkspaceMembersRequest + (*ListWorkspaceMembersResponse)(nil), // 198: openshell.v1.ListWorkspaceMembersResponse + (*ExtensionServiceCredential)(nil), // 199: openshell.v1.ExtensionServiceCredential + nil, // 200: openshell.v1.SandboxSpec.EnvironmentEntry + nil, // 201: openshell.v1.SandboxTemplate.LabelsEntry + nil, // 202: openshell.v1.SandboxTemplate.AnnotationsEntry + nil, // 203: openshell.v1.SandboxTemplate.EnvironmentEntry + nil, // 204: openshell.v1.PlatformEvent.MetadataEntry + nil, // 205: openshell.v1.CreateSandboxRequest.LabelsEntry + nil, // 206: openshell.v1.CreateSandboxRequest.AnnotationsEntry + nil, // 207: openshell.v1.ExecSandboxRequest.EnvironmentEntry + nil, // 208: openshell.v1.SandboxLogLine.FieldsEntry + nil, // 209: openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry + nil, // 210: openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry + nil, // 211: openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry + nil, // 212: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry + nil, // 213: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + nil, // 214: openshell.v1.ProviderProfile.AnnotationsEntry + nil, // 215: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + nil, // 216: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry + nil, // 217: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + nil, // 218: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry + nil, // 219: openshell.v1.UpdateConfigRequest.AnnotationsEntry + nil, // 220: openshell.v1.UpdateConfigResponse.AnnotationsEntry + nil, // 221: openshell.v1.SandboxPolicyRevision.ProvenanceEntry + nil, // 222: openshell.v1.PolicyRevisionPayload.ProvenanceEntry + nil, // 223: openshell.v1.StoredPolicyRevision.ProvenanceEntry + nil, // 224: openshell.v1.CreateWorkspaceRequest.LabelsEntry + (*datamodelv1.ObjectMeta)(nil), // 225: openshell.datamodel.v1.ObjectMeta + (*sandboxv1.SandboxPolicy)(nil), // 226: openshell.sandbox.v1.SandboxPolicy + (*structpb.Struct)(nil), // 227: google.protobuf.Struct + (*datamodelv1.Provider)(nil), // 228: openshell.datamodel.v1.Provider + (*datamodelv1.CredentialHandle)(nil), // 229: openshell.datamodel.v1.CredentialHandle + (*sandboxv1.NetworkEndpoint)(nil), // 230: openshell.sandbox.v1.NetworkEndpoint + (*sandboxv1.NetworkBinary)(nil), // 231: openshell.sandbox.v1.NetworkBinary + (*sandboxv1.SettingValue)(nil), // 232: openshell.sandbox.v1.SettingValue + (*sandboxv1.NetworkPolicyRule)(nil), // 233: openshell.sandbox.v1.NetworkPolicyRule + (*sandboxv1.L7DenyRule)(nil), // 234: openshell.sandbox.v1.L7DenyRule + (*sandboxv1.L7Rule)(nil), // 235: openshell.sandbox.v1.L7Rule + (*datamodelv1.Workspace)(nil), // 236: openshell.datamodel.v1.Workspace + (*sandboxv1.GetSandboxConfigRequest)(nil), // 237: openshell.sandbox.v1.GetSandboxConfigRequest + (*sandboxv1.GetGatewayConfigRequest)(nil), // 238: openshell.sandbox.v1.GetGatewayConfigRequest + (*sandboxv1.GetSandboxConfigResponse)(nil), // 239: openshell.sandbox.v1.GetSandboxConfigResponse + (*sandboxv1.GetGatewayConfigResponse)(nil), // 240: openshell.sandbox.v1.GetGatewayConfigResponse } var file_openshell_proto_depIdxs = []int32{ - 198, // 0: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential + 199, // 0: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential 5, // 1: openshell.v1.HealthResponse.status:type_name -> openshell.v1.ServiceStatus 5, // 2: openshell.v1.GetGatewayInfoResponse.status:type_name -> openshell.v1.ServiceStatus - 17, // 3: openshell.v1.GetGatewayInfoResponse.compute_drivers:type_name -> openshell.v1.ComputeDriverInfo - 18, // 4: openshell.v1.ComputeDriverInfo.capabilities:type_name -> openshell.v1.ComputeDriverCapabilities - 224, // 5: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 20, // 6: openshell.v1.Sandbox.spec:type_name -> openshell.v1.SandboxSpec - 24, // 7: openshell.v1.Sandbox.status:type_name -> openshell.v1.SandboxStatus - 199, // 8: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry - 23, // 9: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate - 225, // 10: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 21, // 11: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements - 22, // 12: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements - 200, // 13: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry - 201, // 14: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry - 202, // 15: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry - 226, // 16: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct - 226, // 17: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct - 25, // 18: openshell.v1.SandboxStatus.conditions:type_name -> openshell.v1.SandboxCondition + 18, // 3: openshell.v1.GetGatewayInfoResponse.compute_drivers:type_name -> openshell.v1.ComputeDriverInfo + 19, // 4: openshell.v1.ComputeDriverInfo.capabilities:type_name -> openshell.v1.ComputeDriverCapabilities + 225, // 5: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 21, // 6: openshell.v1.Sandbox.spec:type_name -> openshell.v1.SandboxSpec + 25, // 7: openshell.v1.Sandbox.status:type_name -> openshell.v1.SandboxStatus + 200, // 8: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry + 24, // 9: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate + 226, // 10: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 22, // 11: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements + 23, // 12: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements + 201, // 13: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry + 202, // 14: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry + 203, // 15: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry + 227, // 16: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct + 227, // 17: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct + 26, // 18: openshell.v1.SandboxStatus.conditions:type_name -> openshell.v1.SandboxCondition 0, // 19: openshell.v1.SandboxStatus.phase:type_name -> openshell.v1.SandboxPhase - 203, // 20: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry - 20, // 21: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec - 204, // 22: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry - 205, // 23: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry - 19, // 24: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox - 19, // 25: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox - 227, // 26: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider - 19, // 27: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox - 19, // 28: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox - 51, // 29: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse - 224, // 30: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 50, // 31: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint - 206, // 32: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry - 55, // 33: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout - 56, // 34: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr - 57, // 35: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit - 147, // 36: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget - 148, // 37: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget - 59, // 38: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit - 54, // 39: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest - 62, // 40: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize - 224, // 41: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 19, // 42: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox - 66, // 43: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine - 26, // 44: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent - 67, // 45: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning - 158, // 46: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate - 207, // 47: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry - 227, // 48: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 227, // 49: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 208, // 50: openshell.v1.UpdateProviderRequest.credential_expires_at_ms:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry - 227, // 51: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider - 227, // 52: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider - 98, // 53: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile - 79, // 54: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride + 204, // 20: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry + 21, // 21: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec + 205, // 22: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry + 206, // 23: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry + 20, // 24: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox + 20, // 25: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox + 228, // 26: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 20, // 27: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox + 20, // 28: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox + 52, // 29: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse + 225, // 30: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 51, // 31: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint + 207, // 32: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry + 56, // 33: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout + 57, // 34: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr + 58, // 35: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit + 148, // 36: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget + 149, // 37: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget + 60, // 38: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit + 55, // 39: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest + 63, // 40: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize + 225, // 41: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 20, // 42: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox + 67, // 43: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine + 27, // 44: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent + 68, // 45: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning + 159, // 46: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate + 208, // 47: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry + 228, // 48: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 228, // 49: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 209, // 50: openshell.v1.UpdateProviderRequest.credential_expires_at_ms:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry + 228, // 51: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider + 228, // 52: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 99, // 53: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile + 80, // 54: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride 1, // 55: openshell.v1.ProviderCredentialTokenGrant.grant_type:type_name -> openshell.v1.ProviderCredentialTokenGrantType - 80, // 56: openshell.v1.ProviderCredentialTokenGrant.subject_token:type_name -> openshell.v1.ProviderCredentialTokenGrantSubjectToken - 85, // 57: openshell.v1.ProviderProfileCredential.refresh:type_name -> openshell.v1.ProviderCredentialRefresh - 81, // 58: openshell.v1.ProviderProfileCredential.token_grant:type_name -> openshell.v1.ProviderCredentialTokenGrant + 81, // 56: openshell.v1.ProviderCredentialTokenGrant.subject_token:type_name -> openshell.v1.ProviderCredentialTokenGrantSubjectToken + 86, // 57: openshell.v1.ProviderProfileCredential.refresh:type_name -> openshell.v1.ProviderCredentialRefresh + 82, // 58: openshell.v1.ProviderProfileCredential.token_grant:type_name -> openshell.v1.ProviderCredentialTokenGrant 2, // 59: openshell.v1.ProviderCredentialRefresh.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 83, // 60: openshell.v1.ProviderCredentialRefresh.material:type_name -> openshell.v1.ProviderCredentialRefreshMaterial - 84, // 61: openshell.v1.ProviderCredentialRefresh.additional_outputs:type_name -> openshell.v1.ProviderCredentialRefreshOutput + 84, // 60: openshell.v1.ProviderCredentialRefresh.material:type_name -> openshell.v1.ProviderCredentialRefreshMaterial + 85, // 61: openshell.v1.ProviderCredentialRefresh.additional_outputs:type_name -> openshell.v1.ProviderCredentialRefreshOutput 2, // 62: openshell.v1.ProviderCredentialRefreshStatus.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 224, // 63: openshell.v1.StoredProviderCredentialRefreshState.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 2, // 64: openshell.v1.StoredProviderCredentialRefreshState.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 209, // 65: openshell.v1.StoredProviderCredentialRefreshState.material:type_name -> openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry - 210, // 66: openshell.v1.StoredProviderCredentialRefreshState.additional_output_keys:type_name -> openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry - 211, // 67: openshell.v1.StoredProviderCredentialRefreshState.secret_material_handles:type_name -> openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry - 89, // 68: openshell.v1.StoredProviderCredentialRefreshState.pending_secret_deletions:type_name -> openshell.v1.StoredRefreshMaterialDeletion - 228, // 69: openshell.v1.StoredRefreshMaterialDeletion.handle:type_name -> openshell.datamodel.v1.CredentialHandle - 86, // 70: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 2, // 71: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 212, // 72: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry - 86, // 73: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 86, // 74: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 3, // 75: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory - 82, // 76: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential - 229, // 77: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint - 230, // 78: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary - 87, // 79: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery - 213, // 80: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry - 224, // 81: openshell.v1.StoredProviderProfile.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 98, // 82: openshell.v1.StoredProviderProfile.profile:type_name -> openshell.v1.ProviderProfile - 98, // 83: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile - 98, // 84: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile - 77, // 85: openshell.v1.ImportProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem - 78, // 86: openshell.v1.ImportProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 98, // 87: openshell.v1.ImportProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile - 77, // 88: openshell.v1.UpdateProviderProfilesRequest.profile:type_name -> openshell.v1.ProviderProfileImportItem - 78, // 89: openshell.v1.UpdateProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 98, // 90: openshell.v1.UpdateProviderProfilesResponse.profile:type_name -> openshell.v1.ProviderProfile - 77, // 91: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem - 78, // 92: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 112, // 93: openshell.v1.StaticCredentialBinding.endpoints:type_name -> openshell.v1.StaticCredentialEndpointBinding - 214, // 94: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - 215, // 95: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expires_at_ms:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry - 216, // 96: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - 217, // 97: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry - 225, // 98: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 231, // 99: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue - 118, // 100: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation - 218, // 101: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry - 119, // 102: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule - 120, // 103: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint - 121, // 104: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule - 122, // 105: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules - 123, // 106: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules - 124, // 107: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary - 232, // 108: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 233, // 109: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule - 234, // 110: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule - 219, // 111: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry - 132, // 112: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision - 132, // 113: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision - 4, // 114: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus - 4, // 115: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus - 225, // 116: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 220, // 117: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry - 66, // 118: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine - 66, // 119: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine - 139, // 120: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello - 142, // 121: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat - 151, // 122: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult - 152, // 123: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose - 140, // 124: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted - 141, // 125: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected - 143, // 126: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat - 146, // 127: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen - 152, // 128: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose - 147, // 129: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget - 148, // 130: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget - 149, // 131: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit - 153, // 132: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample - 155, // 133: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount - 232, // 134: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 225, // 135: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 225, // 136: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 154, // 137: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary - 157, // 138: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk - 156, // 139: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary - 157, // 140: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk - 167, // 141: openshell.v1.ApproveAllDraftChunksRequest.approvals:type_name -> openshell.v1.DraftChunkApproval - 232, // 142: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 177, // 143: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry - 225, // 144: openshell.v1.PolicyRevisionPayload.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 221, // 145: openshell.v1.PolicyRevisionPayload.provenance:type_name -> openshell.v1.PolicyRevisionPayload.ProvenanceEntry - 232, // 146: openshell.v1.DraftChunkPayload.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 225, // 147: openshell.v1.DraftChunkPayload.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 225, // 148: openshell.v1.DraftChunkPayload.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 222, // 149: openshell.v1.StoredPolicyRevision.provenance:type_name -> openshell.v1.StoredPolicyRevision.ProvenanceEntry - 225, // 150: openshell.v1.StoredDraftChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 225, // 151: openshell.v1.StoredDraftChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 223, // 152: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry - 235, // 153: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 235, // 154: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 235, // 155: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace - 224, // 156: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 6, // 157: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole - 6, // 158: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole - 191, // 159: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember - 191, // 160: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember - 228, // 161: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry.value:type_name -> openshell.datamodel.v1.CredentialHandle - 82, // 162: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential - 113, // 163: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding - 11, // 164: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest - 13, // 165: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest - 15, // 166: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest - 27, // 167: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest - 28, // 168: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest - 29, // 169: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest - 30, // 170: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest - 31, // 171: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest - 32, // 172: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest - 33, // 173: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest - 34, // 174: openshell.v1.OpenShell.StopSandbox:input_type -> openshell.v1.StopSandboxRequest - 35, // 175: openshell.v1.OpenShell.StartSandbox:input_type -> openshell.v1.StartSandboxRequest - 42, // 176: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest - 44, // 177: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest - 45, // 178: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest - 46, // 179: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest - 48, // 180: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest - 52, // 181: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest - 54, // 182: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest - 60, // 183: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame - 61, // 184: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput - 68, // 185: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest - 69, // 186: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest - 70, // 187: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest - 75, // 188: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest - 76, // 189: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest - 102, // 190: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest - 104, // 191: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest - 106, // 192: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest - 71, // 193: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest - 90, // 194: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest - 92, // 195: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest - 94, // 196: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest - 96, // 197: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest - 72, // 198: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest - 109, // 199: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest - 236, // 200: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest - 237, // 201: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest - 117, // 202: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest - 126, // 203: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest - 128, // 204: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest - 130, // 205: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest - 111, // 206: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest - 115, // 207: openshell.v1.OpenShell.ExchangeProviderSubjectToken:input_type -> openshell.v1.ExchangeProviderSubjectTokenRequest - 133, // 208: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest - 134, // 209: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest - 137, // 210: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage - 144, // 211: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest - 150, // 212: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame - 64, // 213: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest - 159, // 214: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest - 161, // 215: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest - 163, // 216: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest - 165, // 217: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest - 168, // 218: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest - 170, // 219: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest - 172, // 220: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest - 174, // 221: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest - 176, // 222: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest - 7, // 223: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest - 9, // 224: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest - 183, // 225: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest - 185, // 226: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest - 187, // 227: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest - 189, // 228: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest - 192, // 229: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest - 194, // 230: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest - 196, // 231: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest - 12, // 232: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse - 14, // 233: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse - 16, // 234: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse - 36, // 235: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse - 36, // 236: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse - 37, // 237: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse - 38, // 238: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse - 39, // 239: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse - 40, // 240: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse - 41, // 241: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse - 36, // 242: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse - 36, // 243: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse - 43, // 244: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse - 51, // 245: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse - 51, // 246: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse - 47, // 247: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse - 49, // 248: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse - 53, // 249: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse - 58, // 250: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent - 60, // 251: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame - 58, // 252: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent - 73, // 253: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse - 73, // 254: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse - 74, // 255: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse - 101, // 256: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse - 100, // 257: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse - 103, // 258: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse - 105, // 259: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse - 107, // 260: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse - 73, // 261: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse - 91, // 262: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse - 93, // 263: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse - 95, // 264: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse - 97, // 265: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse - 108, // 266: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse - 110, // 267: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse - 238, // 268: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse - 239, // 269: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse - 125, // 270: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse - 127, // 271: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse - 129, // 272: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse - 131, // 273: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse - 114, // 274: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse - 116, // 275: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse - 136, // 276: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse - 135, // 277: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse - 138, // 278: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage - 145, // 279: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse - 150, // 280: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame - 65, // 281: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent - 160, // 282: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse - 162, // 283: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse - 164, // 284: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse - 166, // 285: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse - 169, // 286: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse - 171, // 287: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse - 173, // 288: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse - 175, // 289: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse - 178, // 290: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse - 8, // 291: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse - 10, // 292: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse - 184, // 293: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse - 186, // 294: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse - 188, // 295: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse - 190, // 296: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse - 193, // 297: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse - 195, // 298: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse - 197, // 299: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse - 232, // [232:300] is the sub-list for method output_type - 164, // [164:232] is the sub-list for method input_type - 164, // [164:164] is the sub-list for extension type_name - 164, // [164:164] is the sub-list for extension extendee - 0, // [0:164] is the sub-list for field type_name + 7, // 63: openshell.v1.ProviderCredentialRefreshStatus.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction + 225, // 64: openshell.v1.StoredProviderCredentialRefreshState.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 2, // 65: openshell.v1.StoredProviderCredentialRefreshState.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 210, // 66: openshell.v1.StoredProviderCredentialRefreshState.material:type_name -> openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry + 211, // 67: openshell.v1.StoredProviderCredentialRefreshState.additional_output_keys:type_name -> openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry + 212, // 68: openshell.v1.StoredProviderCredentialRefreshState.secret_material_handles:type_name -> openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry + 90, // 69: openshell.v1.StoredProviderCredentialRefreshState.pending_secret_deletions:type_name -> openshell.v1.StoredRefreshMaterialDeletion + 7, // 70: openshell.v1.StoredProviderCredentialRefreshState.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction + 229, // 71: openshell.v1.StoredRefreshMaterialDeletion.handle:type_name -> openshell.datamodel.v1.CredentialHandle + 87, // 72: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 2, // 73: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 213, // 74: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + 87, // 75: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 87, // 76: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 3, // 77: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory + 83, // 78: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential + 230, // 79: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 231, // 80: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 88, // 81: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery + 214, // 82: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry + 225, // 83: openshell.v1.StoredProviderProfile.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 99, // 84: openshell.v1.StoredProviderProfile.profile:type_name -> openshell.v1.ProviderProfile + 99, // 85: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile + 99, // 86: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile + 78, // 87: openshell.v1.ImportProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem + 79, // 88: openshell.v1.ImportProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 99, // 89: openshell.v1.ImportProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile + 78, // 90: openshell.v1.UpdateProviderProfilesRequest.profile:type_name -> openshell.v1.ProviderProfileImportItem + 79, // 91: openshell.v1.UpdateProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 99, // 92: openshell.v1.UpdateProviderProfilesResponse.profile:type_name -> openshell.v1.ProviderProfile + 78, // 93: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem + 79, // 94: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 113, // 95: openshell.v1.StaticCredentialBinding.endpoints:type_name -> openshell.v1.StaticCredentialEndpointBinding + 215, // 96: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + 216, // 97: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expires_at_ms:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry + 217, // 98: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + 218, // 99: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry + 226, // 100: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 232, // 101: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue + 119, // 102: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation + 219, // 103: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry + 120, // 104: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule + 121, // 105: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint + 122, // 106: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule + 123, // 107: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules + 124, // 108: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules + 125, // 109: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary + 233, // 110: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 234, // 111: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 235, // 112: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule + 220, // 113: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry + 133, // 114: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision + 133, // 115: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision + 4, // 116: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus + 4, // 117: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus + 226, // 118: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 221, // 119: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry + 67, // 120: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine + 67, // 121: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine + 140, // 122: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello + 143, // 123: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat + 152, // 124: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult + 153, // 125: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose + 141, // 126: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted + 142, // 127: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected + 144, // 128: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat + 147, // 129: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen + 153, // 130: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose + 148, // 131: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget + 149, // 132: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget + 150, // 133: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit + 154, // 134: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample + 156, // 135: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount + 233, // 136: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 226, // 137: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 226, // 138: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 155, // 139: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary + 158, // 140: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk + 157, // 141: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary + 158, // 142: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk + 168, // 143: openshell.v1.ApproveAllDraftChunksRequest.approvals:type_name -> openshell.v1.DraftChunkApproval + 233, // 144: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 178, // 145: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry + 226, // 146: openshell.v1.PolicyRevisionPayload.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 222, // 147: openshell.v1.PolicyRevisionPayload.provenance:type_name -> openshell.v1.PolicyRevisionPayload.ProvenanceEntry + 233, // 148: openshell.v1.DraftChunkPayload.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 226, // 149: openshell.v1.DraftChunkPayload.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 226, // 150: openshell.v1.DraftChunkPayload.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 223, // 151: openshell.v1.StoredPolicyRevision.provenance:type_name -> openshell.v1.StoredPolicyRevision.ProvenanceEntry + 226, // 152: openshell.v1.StoredDraftChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 226, // 153: openshell.v1.StoredDraftChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 224, // 154: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry + 236, // 155: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 236, // 156: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 236, // 157: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace + 225, // 158: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 6, // 159: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole + 6, // 160: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole + 192, // 161: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember + 192, // 162: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember + 229, // 163: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry.value:type_name -> openshell.datamodel.v1.CredentialHandle + 83, // 164: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential + 114, // 165: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding + 12, // 166: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest + 14, // 167: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest + 16, // 168: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest + 28, // 169: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest + 29, // 170: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest + 30, // 171: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest + 31, // 172: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest + 32, // 173: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest + 33, // 174: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest + 34, // 175: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest + 35, // 176: openshell.v1.OpenShell.StopSandbox:input_type -> openshell.v1.StopSandboxRequest + 36, // 177: openshell.v1.OpenShell.StartSandbox:input_type -> openshell.v1.StartSandboxRequest + 43, // 178: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest + 45, // 179: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest + 46, // 180: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest + 47, // 181: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest + 49, // 182: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest + 53, // 183: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest + 55, // 184: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest + 61, // 185: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame + 62, // 186: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput + 69, // 187: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest + 70, // 188: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest + 71, // 189: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest + 76, // 190: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest + 77, // 191: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest + 103, // 192: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest + 105, // 193: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest + 107, // 194: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest + 72, // 195: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest + 91, // 196: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest + 93, // 197: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest + 95, // 198: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest + 97, // 199: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest + 73, // 200: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest + 110, // 201: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest + 237, // 202: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest + 238, // 203: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest + 118, // 204: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest + 127, // 205: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest + 129, // 206: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest + 131, // 207: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest + 112, // 208: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest + 116, // 209: openshell.v1.OpenShell.ExchangeProviderSubjectToken:input_type -> openshell.v1.ExchangeProviderSubjectTokenRequest + 134, // 210: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest + 135, // 211: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest + 138, // 212: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage + 145, // 213: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest + 151, // 214: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame + 65, // 215: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest + 160, // 216: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest + 162, // 217: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest + 164, // 218: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest + 166, // 219: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest + 169, // 220: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest + 171, // 221: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest + 173, // 222: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest + 175, // 223: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest + 177, // 224: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest + 8, // 225: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest + 10, // 226: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest + 184, // 227: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest + 186, // 228: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest + 188, // 229: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest + 190, // 230: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest + 193, // 231: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest + 195, // 232: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest + 197, // 233: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest + 13, // 234: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse + 15, // 235: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse + 17, // 236: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse + 37, // 237: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse + 37, // 238: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse + 38, // 239: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse + 39, // 240: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse + 40, // 241: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse + 41, // 242: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse + 42, // 243: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse + 37, // 244: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse + 37, // 245: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse + 44, // 246: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse + 52, // 247: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse + 52, // 248: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse + 48, // 249: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse + 50, // 250: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse + 54, // 251: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse + 59, // 252: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent + 61, // 253: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame + 59, // 254: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent + 74, // 255: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse + 74, // 256: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse + 75, // 257: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse + 102, // 258: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse + 101, // 259: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse + 104, // 260: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse + 106, // 261: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse + 108, // 262: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse + 74, // 263: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse + 92, // 264: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse + 94, // 265: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse + 96, // 266: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse + 98, // 267: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse + 109, // 268: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse + 111, // 269: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse + 239, // 270: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse + 240, // 271: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse + 126, // 272: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse + 128, // 273: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse + 130, // 274: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse + 132, // 275: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse + 115, // 276: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse + 117, // 277: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse + 137, // 278: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse + 136, // 279: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse + 139, // 280: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage + 146, // 281: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse + 151, // 282: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame + 66, // 283: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent + 161, // 284: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse + 163, // 285: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse + 165, // 286: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse + 167, // 287: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse + 170, // 288: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse + 172, // 289: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse + 174, // 290: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse + 176, // 291: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse + 179, // 292: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse + 9, // 293: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse + 11, // 294: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse + 185, // 295: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse + 187, // 296: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse + 189, // 297: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse + 191, // 298: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse + 194, // 299: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse + 196, // 300: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse + 198, // 301: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse + 234, // [234:302] is the sub-list for method output_type + 166, // [166:234] is the sub-list for method input_type + 166, // [166:166] is the sub-list for extension type_name + 166, // [166:166] is the sub-list for extension extendee + 0, // [0:166] is the sub-list for field type_name } func init() { file_openshell_proto_init() } @@ -15706,7 +15860,7 @@ func file_openshell_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_openshell_proto_rawDesc), len(file_openshell_proto_rawDesc)), - NumEnums: 7, + NumEnums: 8, NumMessages: 217, NumExtensions: 0, NumServices: 1, diff --git a/tasks/test.toml b/tasks/test.toml index e3328d12bd..dfb632dda0 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -116,6 +116,12 @@ run = [ "CONTAINER_RUNTIME=podman e2e/with-keycloak.sh env OPENSHELL_E2E_OIDC_GATEWAY=1 e2e/with-podman-gateway.sh cargo test --manifest-path e2e/rust/Cargo.toml --features e2e-oidc-pkce --test oidc_pkce", ] +["e2e:provider-refresh-keycloak"] +description = "Run provider refresh recovery e2e against Keycloak and a Podman gateway" +run = [ + "CONTAINER_RUNTIME=podman e2e/with-keycloak.sh e2e/with-podman-gateway.sh cargo test --manifest-path e2e/rust/Cargo.toml --features e2e-provider-refresh-keycloak --test provider_refresh_keycloak -- --nocapture", +] + ["e2e:oidc-pkce:docker"] description = "Run Linux browser PKCE and RBAC e2e tests against Keycloak and a Docker gateway" run = [ From 74015b951a7f99ffe21a1b47ddd641f2bf2209e8 Mon Sep 17 00:00:00 2001 From: Polite_realism <119441270+politerealism@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:30:59 +0000 Subject: [PATCH 017/505] fix(sandbox): terminate sandbox when proxy accept loop exits unexpectedly (#2370) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sandbox): terminate sandbox when proxy accept loop exits unexpectedly Add a oneshot notification channel to ProxyHandle that fires when the proxy task exits for any reason (panic, abort, or loop break). The sandbox main loop now races this signal alongside the entrypoint process and shutdown signals, terminating with a clear OCSF error if the proxy dies. This prevents the sandbox from continuing to report Ready with a dead proxy. Follows the existing sidecar control channel pattern. All five wait paths (process+sidecar, process-only, network+sidecar, network-only on Linux, and non-Linux) now monitor proxy liveness. Refs #2337 Signed-off-by: politerealism * test(proxy): add unit tests for exit detection drop-guard and take_exit_receiver Verify the oneshot drop-guard pattern fires on normal task exit and abort, and document the take_exit_receiver contract including the double-call hazard. Signed-off-by: politerealism * fix(proxy): classify terminal vs transient accept errors and exit on unrecoverable failures Extract accept-loop error handling into classify_accept_error() with AcceptAction enum so the decision logic is independently testable. Terminal errors (EBADF/EINVAL/ENOTSOCK) now exit the loop immediately, allowing the supervisor to detect proxy death. Unknown errors exit after 10 consecutive failures. FD exhaustion retries with exponential backoff capped at 5s (fix unreachable cap from min(6) to min(7)). Adds 9 unit tests covering classification, counter interactions, backoff progression, and bounded exit behavior. Signed-off-by: politerealism * refactor(proxy): use libc constants and Option-based exit receiver Replace raw errno numbers with libc:: constants behind #[cfg(unix)] gates in classify_accept_error and its tests. Refactor ProxyHandle::exited_rx to Option> so take_exit_receiver uses .take() instead of allocating a dummy sender. Add a ProxyHandle-level test that verifies the full drop → abort → receiver fires path. Signed-off-by: politerealism * fix(proxy): restore full transient errno classification from main The refactored classifier only recognized EMFILE/ENFILE as retryable, routing all other documented transient accept errors (ENOBUFS, ENOMEM, ECONNABORTED, ECONNRESET, EINTR, ENETDOWN, etc.) through the unknown- error budget which terminates the proxy after 10 consecutive failures. Restore the complete transient errno table from main so these errors retry indefinitely with appropriate backoff. Signed-off-by: politerealism * fix(sandbox): resolve Clippy warnings in proxy-exit detection code Replace redundant closure |p| p.take_exit_receiver() with the method reference ProxyHandle::take_exit_receiver, and change all five select! arms from _ = &mut proxy_exited to () = &mut proxy_exited to satisfy clippy::ignored_unit_patterns. Also run cargo fmt on the new proxy test in proxy.rs. Signed-off-by: politerealism * ci: trigger required check re-run Signed-off-by: politerealism * fix(sandbox): remove unnecessary std::pin qualification Signed-off-by: politerealism --------- Signed-off-by: politerealism --- crates/openshell-sandbox/src/lib.rs | 108 ++- .../openshell-supervisor-network/src/proxy.rs | 721 +++++++++--------- 2 files changed, 447 insertions(+), 382 deletions(-) diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index d96f141cc8..b4c2cfd9dd 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -16,6 +16,7 @@ mod sidecar_control; use miette::{IntoDiagnostic, Result, WrapErr}; use std::future::Future; +use std::pin::Pin; use std::sync::Arc; #[cfg(target_os = "linux")] use std::sync::atomic::Ordering; @@ -62,6 +63,7 @@ use openshell_core::policy::{NetworkMode, NetworkPolicy, ProxyPolicy, SandboxPol use openshell_core::proposals::AgentProposals; use openshell_core::provider_credentials::ProviderCredentialState; use openshell_supervisor_network::opa::OpaEngine; +use openshell_supervisor_network::proxy::ProxyHandle; use openshell_supervisor_process::process::ProcessEnforcementMode; pub use openshell_supervisor_process::process::{ProcessHandle, ProcessStatus}; use openshell_supervisor_process::skills; @@ -514,7 +516,7 @@ pub async fn run_sandbox( // API read the current value so proposals target the correct workspace. let (workspace_tx, workspace_rx) = tokio::sync::watch::channel(String::new()); - let networking = if network_enabled { + let mut networking = if network_enabled { #[cfg(target_os = "linux")] let proxy_bind_ip = netns .as_ref() @@ -823,6 +825,19 @@ pub async fn run_sandbox( .zip(bootstrap.proxy_ca_bundle_path.clone()) }); + let proxy_exited: Pin + Send>> = if let Some(rx) = networking + .as_mut() + .and_then(|n| n.proxy.as_mut()) + .and_then(ProxyHandle::take_exit_receiver) + { + Box::pin(async { + let _ = rx.await; + }) + } else { + Box::pin(std::future::pending()) + }; + tokio::pin!(proxy_exited); + let exit_code = if process_enabled { let ca_file_paths = networking .as_ref() @@ -935,9 +950,41 @@ pub async fn run_sandbox( "authoritative network-sidecar control channel closed" )); } + () = &mut proxy_exited => { + ocsf_emit!( + AppLifecycleBuilder::new(ocsf_ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::High) + .status(StatusId::Failure) + .message( + "Proxy accept loop exited unexpectedly; terminating sandbox" + ) + .build() + ); + return Err(miette::miette!( + "proxy accept loop exited unexpectedly" + )); + } } } else { - process.await? + tokio::select! { + result = process => result?, + () = &mut proxy_exited => { + ocsf_emit!( + AppLifecycleBuilder::new(ocsf_ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::High) + .status(StatusId::Failure) + .message( + "Proxy accept loop exited unexpectedly; terminating sandbox" + ) + .build() + ); + return Err(miette::miette!( + "proxy accept loop exited unexpectedly" + )); + } + } } } else { // Network-only sidecar mode: keep the proxy and its background @@ -953,15 +1000,62 @@ pub async fn run_sandbox( warn!(?result, "Authoritative sidecar control channel exited; restarting sidecar"); 1 } + () = &mut proxy_exited => { + ocsf_emit!( + AppLifecycleBuilder::new(ocsf_ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::High) + .status(StatusId::Failure) + .message( + "Proxy accept loop exited unexpectedly; terminating sandbox" + ) + .build() + ); + return Err(miette::miette!( + "proxy accept loop exited unexpectedly" + )); + } } } else { - wait_for_shutdown_signal().await; - 0 + tokio::select! { + () = wait_for_shutdown_signal() => 0, + () = &mut proxy_exited => { + ocsf_emit!( + AppLifecycleBuilder::new(ocsf_ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::High) + .status(StatusId::Failure) + .message( + "Proxy accept loop exited unexpectedly; terminating sandbox" + ) + .build() + ); + return Err(miette::miette!( + "proxy accept loop exited unexpectedly" + )); + } + } } #[cfg(not(target_os = "linux"))] { - wait_for_shutdown_signal().await; - 0 + tokio::select! { + () = wait_for_shutdown_signal() => 0, + () = &mut proxy_exited => { + ocsf_emit!( + AppLifecycleBuilder::new(ocsf_ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::High) + .status(StatusId::Failure) + .message( + "Proxy accept loop exited unexpectedly; terminating sandbox" + ) + .build() + ); + return Err(miette::miette!( + "proxy accept loop exited unexpectedly" + )); + } + } } }; @@ -3157,7 +3251,7 @@ type MiddlewareConnector = Arc< dyn Fn( Vec, MiddlewareAuthentication, - ) -> std::pin::Pin< + ) -> Pin< Box< dyn std::future::Future< Output = Result, diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index bc3ad8b3db..dc2736a4ea 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -228,11 +228,11 @@ impl InferenceContext { } } -#[derive(Debug)] pub struct ProxyHandle { #[allow(dead_code)] http_addr: Option, join: JoinHandle<()>, + exited_rx: Option>, } impl ProxyHandle { @@ -337,7 +337,13 @@ impl ProxyHandle { ocsf_emit!(event); } + let (exited_tx, exited_rx) = tokio::sync::oneshot::channel::<()>(); let join = tokio::spawn(async move { + // Hold the sender for the lifetime of this task — when the task + // exits (panic, abort, or loop break), the sender drops and the + // receiver fires, notifying the sandbox that the proxy is gone. + let _proxy_exit_guard = exited_tx; + // Wait for the OPA engine's symlink resolution reload to complete // before accepting connections. This prevents requests from // observing a generation transition mid-flight, which would cause @@ -422,23 +428,36 @@ impl ProxyHandle { }); } Err(err) => { - let outcome = handle_accept_error( + match classify_accept_error( &err, &mut consecutive_resource_errors, &mut consecutive_unknown_errors, - ); - - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(outcome.severity) - .status(StatusId::Failure) - .message(outcome.message) - .build(); - ocsf_emit!(event); - - match outcome.backoff { - Some(backoff) => tokio::time::sleep(backoff).await, - None => break, + ) { + AcceptAction::Terminal => { + let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::High) + .status(StatusId::Failure) + .message(format!( + "Proxy accept loop exiting on terminal error: {err}", + )) + .build(); + ocsf_emit!(event); + break; + } + AcceptAction::Retry { backoff, severity } => { + let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(severity) + .status(StatusId::Failure) + .message(format!( + "Proxy accept error (retrying in {}ms): {err}", + backoff.as_millis(), + )) + .build(); + ocsf_emit!(event); + tokio::time::sleep(backoff).await; + } } } } @@ -448,6 +467,7 @@ impl ProxyHandle { Ok(Self { http_addr: Some(local_addr), join, + exited_rx: Some(exited_rx), }) } @@ -455,6 +475,10 @@ impl ProxyHandle { pub const fn http_addr(&self) -> Option { self.http_addr } + + pub fn take_exit_receiver(&mut self) -> Option> { + self.exited_rx.take() + } } impl Drop for ProxyHandle { @@ -972,142 +996,106 @@ fn emit_transparent_policy_denial( ); } -fn emit_activity(tx: &Option, denied: bool, deny_group: &'static str) { - if let Some(tx) = tx { - let _ = try_record_activity(tx, denied, deny_group); - } -} +const MAX_CONSECUTIVE_UNKNOWN_ACCEPT_ERRORS: u32 = 10; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum AcceptErrorClass { - Transient, +#[derive(Debug, PartialEq)] +enum AcceptAction { Terminal, - Unknown, + Retry { + backoff: std::time::Duration, + severity: SeverityId, + }, } -#[cfg(unix)] -fn classify_accept_error(err: &std::io::Error) -> AcceptErrorClass { - match err.raw_os_error() { - Some( - libc::EMFILE - | libc::ENFILE - | libc::ENOBUFS - | libc::ENOMEM - | libc::ECONNABORTED - | libc::ECONNRESET - | libc::EINTR - | libc::ENETDOWN - | libc::EPROTO - | libc::ENOPROTOOPT - | libc::EHOSTDOWN - | libc::EHOSTUNREACH - | libc::EOPNOTSUPP - | libc::ENETUNREACH - | libc::ENOSR - | libc::ESOCKTNOSUPPORT - | libc::EPROTONOSUPPORT - | libc::ETIMEDOUT, - ) => AcceptErrorClass::Transient, - #[cfg(target_os = "linux")] - Some(libc::ENONET) => AcceptErrorClass::Transient, - Some(libc::EBADF | libc::EINVAL | libc::ENOTSOCK) => AcceptErrorClass::Terminal, - _ => AcceptErrorClass::Unknown, - } -} - -#[cfg(not(unix))] -fn classify_accept_error(_err: &std::io::Error) -> AcceptErrorClass { - AcceptErrorClass::Unknown -} - -#[cfg(unix)] -fn is_resource_pressure_error(err: &std::io::Error) -> bool { - matches!( +fn classify_accept_error( + err: &std::io::Error, + consecutive_resource_errors: &mut u32, + consecutive_unknown_errors: &mut u32, +) -> AcceptAction { + #[cfg(unix)] + if matches!( err.raw_os_error(), - Some(libc::EMFILE | libc::ENFILE | libc::ENOBUFS | libc::ENOMEM | libc::ENOSR) - ) -} + Some(libc::EBADF | libc::EINVAL | libc::ENOTSOCK) + ) { + return AcceptAction::Terminal; + } -#[cfg(not(unix))] -fn is_resource_pressure_error(_err: &std::io::Error) -> bool { - false -} + #[cfg(unix)] + if matches!( + err.raw_os_error(), + Some( + libc::EMFILE + | libc::ENFILE + | libc::ENOBUFS + | libc::ENOMEM + | libc::ECONNABORTED + | libc::ECONNRESET + | libc::EINTR + | libc::ENETDOWN + | libc::EPROTO + | libc::ENOPROTOOPT + | libc::EHOSTDOWN + | libc::EHOSTUNREACH + | libc::EOPNOTSUPP + | libc::ENETUNREACH + | libc::ENOSR + | libc::ESOCKTNOSUPPORT + | libc::EPROTONOSUPPORT + | libc::ETIMEDOUT + ) + ) { + *consecutive_unknown_errors = 0; + + #[cfg(unix)] + let is_resource_pressure = matches!( + err.raw_os_error(), + Some(libc::EMFILE | libc::ENFILE | libc::ENOBUFS | libc::ENOMEM | libc::ENOSR) + ); + #[cfg(not(unix))] + let is_resource_pressure = false; + + if is_resource_pressure { + *consecutive_resource_errors = consecutive_resource_errors.saturating_add(1); + let backoff_ms = 100u64 + .saturating_mul(1u64 << (*consecutive_resource_errors).min(7).saturating_sub(1)) + .min(5_000); + return AcceptAction::Retry { + backoff: std::time::Duration::from_millis(backoff_ms), + severity: SeverityId::Medium, + }; + } -const ACCEPT_BACKOFF_BASE_MS: u64 = 100; -const ACCEPT_BACKOFF_MAX_MS: u64 = 5_000; -const MAX_CONSECUTIVE_UNKNOWN_ERRORS: u32 = 5; + *consecutive_resource_errors = 0; + return AcceptAction::Retry { + backoff: std::time::Duration::from_millis(100), + severity: SeverityId::Low, + }; + } -fn accept_backoff(consecutive_errors: u32) -> std::time::Duration { - let exponent = consecutive_errors.saturating_sub(1).min(7); - let ms = ACCEPT_BACKOFF_BASE_MS - .saturating_mul(1u64 << exponent) - .min(ACCEPT_BACKOFF_MAX_MS); - std::time::Duration::from_millis(ms) -} + #[cfg(unix)] + #[cfg(target_os = "linux")] + if matches!(err.raw_os_error(), Some(libc::ENONET)) { + *consecutive_unknown_errors = 0; + *consecutive_resource_errors = 0; + return AcceptAction::Retry { + backoff: std::time::Duration::from_millis(100), + severity: SeverityId::Low, + }; + } -struct AcceptErrorOutcome { - severity: SeverityId, - message: String, - backoff: Option, + *consecutive_unknown_errors = consecutive_unknown_errors.saturating_add(1); + if *consecutive_unknown_errors >= MAX_CONSECUTIVE_UNKNOWN_ACCEPT_ERRORS { + return AcceptAction::Terminal; + } + AcceptAction::Retry { + backoff: std::time::Duration::from_millis(100), + severity: SeverityId::Low, + } } -fn handle_accept_error( - err: &std::io::Error, - consecutive_resource_errors: &mut u32, - consecutive_unknown_errors: &mut u32, -) -> AcceptErrorOutcome { - let class = classify_accept_error(err); - - match class { - AcceptErrorClass::Terminal => AcceptErrorOutcome { - severity: SeverityId::High, - message: format!("Proxy accept error (terminal, exiting): {err}"), - backoff: None, - }, - AcceptErrorClass::Unknown => { - *consecutive_unknown_errors = consecutive_unknown_errors.saturating_add(1); - if *consecutive_unknown_errors > MAX_CONSECUTIVE_UNKNOWN_ERRORS { - AcceptErrorOutcome { - severity: SeverityId::High, - message: format!( - "Proxy accept error (exceeded {MAX_CONSECUTIVE_UNKNOWN_ERRORS} retries, exiting): {err}" - ), - backoff: None, - } - } else { - let backoff = accept_backoff(*consecutive_unknown_errors); - AcceptErrorOutcome { - severity: SeverityId::Medium, - message: format!( - "Proxy accept error (retry {}/{MAX_CONSECUTIVE_UNKNOWN_ERRORS} in {}ms): {err}", - *consecutive_unknown_errors, - backoff.as_millis(), - ), - backoff: Some(backoff), - } - } - } - AcceptErrorClass::Transient => { - *consecutive_unknown_errors = 0; - if is_resource_pressure_error(err) { - *consecutive_resource_errors = consecutive_resource_errors.saturating_add(1); - let backoff = accept_backoff(*consecutive_resource_errors); - AcceptErrorOutcome { - severity: SeverityId::Medium, - message: format!( - "Proxy accept error (retrying in {}ms): {err}", - backoff.as_millis(), - ), - backoff: Some(backoff), - } - } else { - AcceptErrorOutcome { - severity: SeverityId::Low, - message: format!("Proxy accept error (retrying in 100ms): {err}"), - backoff: Some(std::time::Duration::from_millis(100)), - } - } - } +fn emit_activity(tx: &Option, denied: bool, deny_group: &'static str) { + if let Some(tx) = tx { + let _ = try_record_activity(tx, denied, deny_group); } } @@ -12711,335 +12699,318 @@ network_policies: } } - #[test] - fn accept_backoff_exponential_progression() { - let ms = |n| accept_backoff(n).as_millis(); - assert_eq!(ms(1), 100); - assert_eq!(ms(2), 200); - assert_eq!(ms(3), 400); - assert_eq!(ms(4), 800); - assert_eq!(ms(5), 1_600); - assert_eq!(ms(6), 3_200); - assert_eq!(ms(7), 5_000); // 6400 capped to 5000 - assert_eq!(ms(8), 5_000); // stays at cap - } - - #[test] - fn accept_backoff_zero_consecutive_errors() { - assert_eq!(accept_backoff(0).as_millis(), 100); + #[tokio::test] + async fn test_exit_receiver_fires_when_task_exits() { + let (exited_tx, exited_rx) = tokio::sync::oneshot::channel::<()>(); + let handle = tokio::spawn(async move { + let _guard = exited_tx; + }); + handle.await.unwrap(); + // The sender was dropped when the task completed, so the receiver + // should resolve immediately with an Err (sender dropped). + assert!(exited_rx.await.is_err()); } - #[test] - fn accept_backoff_saturates_at_cap() { - assert_eq!(accept_backoff(100).as_millis(), 5_000); - assert_eq!(accept_backoff(u32::MAX).as_millis(), 5_000); + #[tokio::test] + async fn test_exit_receiver_fires_when_task_is_aborted() { + let (exited_tx, exited_rx) = tokio::sync::oneshot::channel::<()>(); + let handle = tokio::spawn(async move { + let _guard = exited_tx; + std::future::pending::<()>().await; + }); + handle.abort(); + // Abort drops the task's locals, including the sender guard. + assert!(exited_rx.await.is_err()); } - #[cfg(unix)] - #[test] - fn is_resource_pressure_detects_emfile() { - let err = std::io::Error::from_raw_os_error(libc::EMFILE); - assert!(is_resource_pressure_error(&err)); + #[tokio::test] + async fn test_take_exit_receiver_returns_real_receiver() { + let (tx, rx) = tokio::sync::oneshot::channel::<()>(); + let join = tokio::spawn(std::future::pending::<()>()); + let mut handle = ProxyHandle { + http_addr: None, + join, + exited_rx: Some(rx), + }; + let mut taken = handle + .take_exit_receiver() + .expect("first take should return Some"); + assert!(taken.try_recv().is_err()); + drop(tx); + assert!(taken.await.is_err()); } - #[cfg(unix)] - #[test] - fn is_resource_pressure_detects_enfile() { - let err = std::io::Error::from_raw_os_error(libc::ENFILE); - assert!(is_resource_pressure_error(&err)); + #[tokio::test] + async fn test_take_exit_receiver_second_call_returns_none() { + let (_tx, rx) = tokio::sync::oneshot::channel::<()>(); + let join = tokio::spawn(std::future::pending::<()>()); + let mut handle = ProxyHandle { + http_addr: None, + join, + exited_rx: Some(rx), + }; + let _first = handle.take_exit_receiver(); + assert!(handle.take_exit_receiver().is_none()); } - #[cfg(unix)] - #[test] - fn is_resource_pressure_detects_memory_pressure() { - assert!(is_resource_pressure_error( - &std::io::Error::from_raw_os_error(libc::ENOBUFS) - )); - assert!(is_resource_pressure_error( - &std::io::Error::from_raw_os_error(libc::ENOMEM) - )); - assert!(is_resource_pressure_error( - &std::io::Error::from_raw_os_error(libc::ENOSR) - )); + #[tokio::test] + async fn test_proxy_handle_drop_fires_exit_receiver() { + let (exited_tx, exited_rx) = tokio::sync::oneshot::channel::<()>(); + let join = tokio::spawn(async move { + let _guard = exited_tx; + std::future::pending::<()>().await; + }); + let mut handle = ProxyHandle { + http_addr: None, + join, + exited_rx: Some(exited_rx), + }; + let rx = handle.take_exit_receiver().expect("should return Some"); + drop(handle); + assert!(rx.await.is_err()); } - #[cfg(unix)] - #[test] - fn is_resource_pressure_rejects_other_errors() { - let err = std::io::Error::from_raw_os_error(libc::ECONNABORTED); - assert!(!is_resource_pressure_error(&err)); - } + // --- classify_accept_error tests --- #[cfg(unix)] #[test] - fn classify_accept_error_fd_exhaustion_is_transient() { - assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::EMFILE)), - AcceptErrorClass::Transient, - ); + fn test_classify_terminal_error_ebadf() { + let err = std::io::Error::from_raw_os_error(libc::EBADF); + let mut fd = 0; + let mut unk = 0; assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::ENFILE)), - AcceptErrorClass::Transient, + classify_accept_error(&err, &mut fd, &mut unk), + AcceptAction::Terminal ); } #[cfg(unix)] #[test] - fn classify_accept_error_connection_errors_are_transient() { - assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::ECONNABORTED)), - AcceptErrorClass::Transient, - ); - assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::ECONNRESET)), - AcceptErrorClass::Transient, - ); + fn test_classify_terminal_error_einval() { + let err = std::io::Error::from_raw_os_error(libc::EINVAL); + let mut fd = 0; + let mut unk = 0; assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::EINTR)), - AcceptErrorClass::Transient, + classify_accept_error(&err, &mut fd, &mut unk), + AcceptAction::Terminal ); } #[cfg(unix)] #[test] - fn classify_accept_error_broken_listener_is_terminal() { - assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::EBADF)), - AcceptErrorClass::Terminal, - ); - assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::EINVAL)), - AcceptErrorClass::Terminal, - ); + fn test_classify_terminal_error_enotsock() { + let err = std::io::Error::from_raw_os_error(libc::ENOTSOCK); + let mut fd = 0; + let mut unk = 0; assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::ENOTSOCK)), - AcceptErrorClass::Terminal, + classify_accept_error(&err, &mut fd, &mut unk), + AcceptAction::Terminal ); } #[cfg(unix)] #[test] - fn classify_accept_error_unrecognized_errno_is_unknown() { - assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::EPERM)), - AcceptErrorClass::Unknown, + fn test_classify_fd_exhaustion_returns_retry_medium() { + let err = std::io::Error::from_raw_os_error(libc::EMFILE); + let mut fd = 0; + let mut unk = 0; + let action = classify_accept_error(&err, &mut fd, &mut unk); + assert!( + matches!( + action, + AcceptAction::Retry { + severity: SeverityId::Medium, + .. + } + ), + "expected Retry/Medium for EMFILE, got {action:?}", ); } #[cfg(unix)] #[test] - fn handle_accept_error_terminal_exits_immediately() { - let mut res = 0; + fn test_classify_unknown_error_returns_retry_low() { + let err = std::io::Error::from_raw_os_error(libc::ECONNREFUSED); + let mut fd = 0; let mut unk = 0; - let err = std::io::Error::from_raw_os_error(libc::EBADF); - let outcome = handle_accept_error(&err, &mut res, &mut unk); - assert!(outcome.backoff.is_none()); - assert_eq!(outcome.severity, SeverityId::High); + let action = classify_accept_error(&err, &mut fd, &mut unk); + assert!( + matches!( + action, + AcceptAction::Retry { + severity: SeverityId::Low, + .. + } + ), + "expected Retry/Low for unknown error, got {action:?}", + ); } #[cfg(unix)] #[test] - fn handle_accept_error_transient_retries_indefinitely() { - let mut res = 0; + fn test_classify_fd_exhaustion_backoff_increases_and_caps() { + let mut fd = 0; let mut unk = 0; let err = std::io::Error::from_raw_os_error(libc::EMFILE); - for i in 1..=20 { - let outcome = handle_accept_error(&err, &mut res, &mut unk); - assert!(outcome.backoff.is_some(), "should retry on attempt {i}"); - assert_eq!(outcome.severity, SeverityId::Medium); + + let mut prev_backoff = std::time::Duration::ZERO; + for _ in 0..6 { + match classify_accept_error(&err, &mut fd, &mut unk) { + AcceptAction::Retry { backoff, .. } => { + assert!( + backoff > prev_backoff, + "backoff should increase: {backoff:?} <= {prev_backoff:?}", + ); + prev_backoff = backoff; + } + AcceptAction::Terminal => panic!("expected Retry, got Terminal"), + } + } + + // After enough consecutive errors the backoff should hit the 5s cap. + for _ in 6..12 { + classify_accept_error(&err, &mut fd, &mut unk); + } + match classify_accept_error(&err, &mut fd, &mut unk) { + AcceptAction::Retry { backoff, .. } => { + assert_eq!( + backoff, + std::time::Duration::from_secs(5), + "backoff should cap at 5000ms", + ); + } + AcceptAction::Terminal => panic!("expected Retry, got Terminal"), } - assert_eq!(res, 20); } #[cfg(unix)] #[test] - fn handle_accept_error_unknown_exits_after_limit() { - let mut res = 0; + fn test_classify_unknown_errors_exit_after_threshold() { + let mut fd = 0; let mut unk = 0; - let err = std::io::Error::from_raw_os_error(libc::EPERM); - for i in 1..=MAX_CONSECUTIVE_UNKNOWN_ERRORS { - let outcome = handle_accept_error(&err, &mut res, &mut unk); + let err = std::io::Error::from_raw_os_error(libc::ECONNREFUSED); + + for i in 1..MAX_CONSECUTIVE_UNKNOWN_ACCEPT_ERRORS { + let action = classify_accept_error(&err, &mut fd, &mut unk); assert!( - outcome.backoff.is_some(), - "should retry on attempt {i}/{MAX_CONSECUTIVE_UNKNOWN_ERRORS}", + matches!(action, AcceptAction::Retry { .. }), + "call {i} should be Retry, got {action:?}", ); - assert_eq!(outcome.severity, SeverityId::Medium); } - let outcome = handle_accept_error(&err, &mut res, &mut unk); - assert!( - outcome.backoff.is_none(), - "should exit after limit exceeded" + let final_action = classify_accept_error(&err, &mut fd, &mut unk); + assert_eq!( + final_action, + AcceptAction::Terminal, + "call {MAX_CONSECUTIVE_UNKNOWN_ACCEPT_ERRORS} should be Terminal", ); - assert_eq!(outcome.severity, SeverityId::High); } #[cfg(unix)] #[test] - fn handle_accept_error_transient_resets_unknown_counter() { - let mut res = 0; + fn test_classify_success_resets_counters() { + let mut fd = 0; let mut unk = 0; - let unknown_err = std::io::Error::from_raw_os_error(libc::EPERM); - let transient_err = std::io::Error::from_raw_os_error(libc::ECONNABORTED); + let err = std::io::Error::from_raw_os_error(libc::ECONNREFUSED); - // Accumulate unknowns up to the limit. - for _ in 1..=MAX_CONSECUTIVE_UNKNOWN_ERRORS { - handle_accept_error(&unknown_err, &mut res, &mut unk); + for _ in 1..MAX_CONSECUTIVE_UNKNOWN_ACCEPT_ERRORS { + classify_accept_error(&err, &mut fd, &mut unk); } - assert_eq!(unk, MAX_CONSECUTIVE_UNKNOWN_ERRORS); - // A transient error resets the unknown counter. - let outcome = handle_accept_error(&transient_err, &mut res, &mut unk); - assert!(outcome.backoff.is_some()); - assert_eq!(unk, 0); + fd = 0; + unk = 0; - // Unknown errors can retry again from zero. - let outcome = handle_accept_error(&unknown_err, &mut res, &mut unk); - assert!(outcome.backoff.is_some()); - assert_eq!(unk, 1); + for i in 1..MAX_CONSECUTIVE_UNKNOWN_ACCEPT_ERRORS { + let action = classify_accept_error(&err, &mut fd, &mut unk); + assert!( + matches!(action, AcceptAction::Retry { .. }), + "after reset, call {i} should be Retry, got {action:?}", + ); + } } #[cfg(unix)] #[test] - fn handle_accept_error_fd_exhaustion_uses_exponential_backoff() { - let mut res = 0; + fn test_classify_fd_error_resets_unknown_counter() { + let mut fd = 0; let mut unk = 0; - let err = std::io::Error::from_raw_os_error(libc::EMFILE); + let unknown_err = std::io::Error::from_raw_os_error(libc::ECONNREFUSED); + let fd_err = std::io::Error::from_raw_os_error(libc::EMFILE); - let b1 = handle_accept_error(&err, &mut res, &mut unk) - .backoff - .unwrap(); - let b2 = handle_accept_error(&err, &mut res, &mut unk) - .backoff - .unwrap(); - let b3 = handle_accept_error(&err, &mut res, &mut unk) - .backoff - .unwrap(); + for _ in 0..5 { + classify_accept_error(&unknown_err, &mut fd, &mut unk); + } - assert_eq!(b1.as_millis(), 100); - assert_eq!(b2.as_millis(), 200); - assert_eq!(b3.as_millis(), 400); + classify_accept_error(&fd_err, &mut fd, &mut unk); + + for i in 1..MAX_CONSECUTIVE_UNKNOWN_ACCEPT_ERRORS { + let action = classify_accept_error(&unknown_err, &mut fd, &mut unk); + assert!( + matches!(action, AcceptAction::Retry { .. }), + "after FD reset, call {i} should be Retry, got {action:?}", + ); + } } #[cfg(unix)] #[test] - fn classify_accept_error_network_errors_are_transient() { + fn test_classify_transient_and_terminal_are_disjoint() { + let mut res = 0; + let mut unk = 0; + for errno in [ + libc::EMFILE, + libc::ENFILE, + libc::ENOBUFS, + libc::ENOMEM, + libc::ECONNABORTED, + libc::ECONNRESET, + libc::EINTR, libc::ENETDOWN, - libc::EPROTO, - libc::ENOPROTOOPT, libc::EHOSTDOWN, libc::EHOSTUNREACH, libc::EOPNOTSUPP, libc::ENETUNREACH, - libc::ESOCKTNOSUPPORT, - libc::EPROTONOSUPPORT, + libc::ENOSR, libc::ETIMEDOUT, ] { - assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(errno)), - AcceptErrorClass::Transient, - "errno {errno} should be transient", + let err = std::io::Error::from_raw_os_error(errno); + assert!( + matches!( + classify_accept_error(&err, &mut res, &mut unk), + AcceptAction::Retry { .. } + ), + "errno {errno} should be Retry", ); + res = 0; + unk = 0; } - } - #[cfg(target_os = "linux")] - #[test] - fn classify_accept_error_enonet_is_transient() { - assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::ENONET)), - AcceptErrorClass::Transient, - ); - } - - #[cfg(unix)] - #[test] - fn classify_accept_error_resource_pressure_is_transient() { - assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::ENOBUFS)), - AcceptErrorClass::Transient, - ); - assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::ENOMEM)), - AcceptErrorClass::Transient, - ); - assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::ENOSR)), - AcceptErrorClass::Transient, - ); + for errno in [libc::EBADF, libc::EINVAL, libc::ENOTSOCK] { + let err = std::io::Error::from_raw_os_error(errno); + assert_eq!( + classify_accept_error(&err, &mut res, &mut unk), + AcceptAction::Terminal, + "errno {errno} should be Terminal", + ); + } } #[cfg(unix)] #[test] - fn handle_accept_error_non_resource_transient_uses_fixed_backoff() { + fn test_transient_errors_never_hit_unknown_budget() { let mut res = 0; let mut unk = 0; let err = std::io::Error::from_raw_os_error(libc::ECONNABORTED); - let o1 = handle_accept_error(&err, &mut res, &mut unk); - let o2 = handle_accept_error(&err, &mut res, &mut unk); - - assert_eq!(o1.severity, SeverityId::Low); - assert_eq!(o1.backoff.unwrap().as_millis(), 100); - assert_eq!(o2.backoff.unwrap().as_millis(), 100); - assert_eq!(res, 0); - } - - #[cfg(unix)] - #[test] - fn handle_accept_error_unknown_uses_exponential_backoff() { - let mut res = 0; - let mut unk = 0; - let err = std::io::Error::from_raw_os_error(libc::EPERM); - - let b1 = handle_accept_error(&err, &mut res, &mut unk) - .backoff - .unwrap(); - let b2 = handle_accept_error(&err, &mut res, &mut unk) - .backoff - .unwrap(); - let b3 = handle_accept_error(&err, &mut res, &mut unk) - .backoff - .unwrap(); - - assert_eq!(b1.as_millis(), 100); - assert_eq!(b2.as_millis(), 200); - assert_eq!(b3.as_millis(), 400); - } - - #[cfg(unix)] - #[test] - fn handle_accept_error_resource_counter_persists_across_mixed_transient() { - let mut res = 0; - let mut unk = 0; - let resource_err = std::io::Error::from_raw_os_error(libc::EMFILE); - let transient_err = std::io::Error::from_raw_os_error(libc::ECONNABORTED); - - let o1 = handle_accept_error(&resource_err, &mut res, &mut unk); - assert_eq!(res, 1); - assert_eq!(o1.backoff.unwrap().as_millis(), 100); - - let o2 = handle_accept_error(&transient_err, &mut res, &mut unk); - assert_eq!(res, 1); - assert_eq!(o2.backoff.unwrap().as_millis(), 100); - - let o3 = handle_accept_error(&resource_err, &mut res, &mut unk); - assert_eq!(res, 2); - assert_eq!(o3.backoff.unwrap().as_millis(), 200); + for i in 0..(MAX_CONSECUTIVE_UNKNOWN_ACCEPT_ERRORS + 5) { + let action = classify_accept_error(&err, &mut res, &mut unk); + assert!( + matches!(action, AcceptAction::Retry { .. }), + "transient error on call {i} should always Retry, got {action:?}", + ); + } } - #[cfg(unix)] - #[test] - fn handle_accept_error_terminal_leaves_counters_unchanged() { - let mut res = 3; - let mut unk = 2; - let err = std::io::Error::from_raw_os_error(libc::EBADF); - - let outcome = handle_accept_error(&err, &mut res, &mut unk); - assert!(outcome.backoff.is_none()); - assert_eq!(res, 3); - assert_eq!(unk, 2); - } #[path = "compatibility.rs"] mod compatibility; } From 0e79653a7f78a0cedf829b501058da3295991257 Mon Sep 17 00:00:00 2001 From: Vyncint Ng <115854244+vyncint@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:37:36 +0000 Subject: [PATCH 018/505] feat(tui): show persisted guidance on rejected policy chunks (#2908) * feat(tui): show persisted guidance on rejected policy chunks The reviewer's rejection note is stored in PolicyChunk.rejection_reason and already reaches the TUI in GetDraftPolicyResponse.chunks, but openshell-tui never read the field. The note was dropped at the last step, so a reviewer had no way to recall why a chunk had been rejected. Render it in two places, following the truncate-in-list / full-in-popup convention in the TUI development guide: a truncated, dimmed suffix on the list row, and a "Guidance:" line in the detail popup. Gate the accessor on status == "rejected" rather than on the field alone. Approving a chunk passes None for the reason and the gateway writes the field only when Some, so a chunk that was rejected and later approved still carries the old note. Reading the field unconditionally would surface a stale rejection on an approved rule. Part of #1098, Definition of Done item "Rejected chunks show persisted guidance". The other items on that issue are untouched. Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> * fix(tui): scroll the draft detail popup so long guidance stays reachable A rejection reason has no server-side length cap, so a paragraph-length one overflowed the fixed 22-row detail popup: the tail was clipped and the later fields and both action hints were pushed off screen. The same overflow already affected the unbounded rationale and security_notes fields, so fix the popup rather than special-case the guidance line. Split the popup's inner area into a scrolling body and a pinned hint row, following the pattern in ui/create_provider.rs, and drive it with j/k, the arrow keys, PageUp, PageDown, g and G. The approve and close controls now stay on screen at every scroll position, and the bottom border carries the scroll position. Wrap free-form values explicitly instead of relying on Paragraph's Wrap, so the rendered row count is exactly lines.len() and the scroll clamp cannot under-run the content. Paragraph::line_count would answer the same question but sits behind ratatui's unstable-rendered-line-info feature. Add deterministic TestBackend coverage at 80x24 with a 2,000-character reason, covering the head and tail, the pinned hints, over-scroll clamping, and the pre-existing long-rationale case. Document the reviewer-facing guidance in the policy advisor page. Part of #1098. Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> * fix(tui): wrap the denial row and hint footer on a narrow popup Making the scroll clamp exact meant dropping Paragraph's Wrap, which also removed wrapping from the lines that do not go through push_wrapped. At 70 columns the denial row clipped mid-value and lost the last-seen timestamp, and at 60 a pending chunk with scrollable content pushed [Esc] Close past the right edge of the single hint row. The key still worked, but that is the same controls-not-visible failure one axis over. Keep the denial row on one line while it fits and wrap it onto the label indent when it does not, so the common 80-column layout is unchanged. Pack the footer hints into as many rows as they need without splitting a hint, and derive the footer height from that: adding a hint row shrinks the body and can itself change whether the content scrolls, so the two settle together. The earlier tests were all 80x24, which is why they missed this. Cover the denial timestamps and the approve, reject and close hints at 60, 70 and 80 columns, plus the packing helper directly. Part of #1098. Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> * fix(tui): measure popup wrapping in display columns, not chars wrap_value packed rows by chars().count(), and a CJK glyph is one char but two terminal columns. A double-width rejection reason therefore produced rows about twice the popup's width: the right half of every row was clipped, and unlike the vertical case there is no horizontal scroll to recover it. Measure width with Span::width(), the same measurement ratatui applies when it lays cells out, so the wrap agrees with the renderer by construction and no new dependency is needed. This covers word packing, the hard-break path for a word wider than the line, the label indent, the footer hint packing, and the denial row's fits-on-one-line check. The list row's shortened copy had the same defect through truncate_str, which counts chars. Leave truncate_str alone for its two existing callers and add truncate_display for the guidance row. Cover it with an 80x24 render regression that interleaves markers through the CJK text: a trailing marker lands on its own short row in both the broken and fixed layouts, so it would not detect this. The two unit tests measure with an independent column oracle rather than the helper under test, which would make them tautological. Part of #1098. Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> --------- Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> --- crates/openshell-tui/src/app.rs | 50 ++ crates/openshell-tui/src/ui/mod.rs | 9 +- crates/openshell-tui/src/ui/sandbox_draft.rs | 831 +++++++++++++++++-- docs/sandboxes/policy-advisor.mdx | 2 + 4 files changed, 806 insertions(+), 86 deletions(-) diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 1619dab9fa..a5e14bcbab 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -711,6 +711,13 @@ pub struct App { pub draft_viewport_height: usize, /// When true, the detail popup is shown for the selected draft chunk. pub draft_detail_open: bool, + /// Scroll offset, in rendered rows, of the draft detail popup body. + pub draft_detail_scroll: usize, + /// Total rows of detail-popup content (set by the draw pass). + pub draft_detail_rows: usize, + /// Visible rows in the detail-popup body, excluding the pinned hint row + /// (set by the draw pass). + pub draft_detail_body_height: usize, /// Per-sandbox count of pending draft recommendations (parallel to `sandbox_names`). pub sandbox_draft_counts: Vec, @@ -1032,6 +1039,9 @@ impl App { draft_scroll: 0, draft_viewport_height: 0, draft_detail_open: false, + draft_detail_scroll: 0, + draft_detail_rows: 0, + draft_detail_body_height: 0, sandbox_draft_counts: Vec::new(), pending_draft_approve: false, pending_draft_reject: false, @@ -1648,6 +1658,7 @@ impl App { KeyCode::Esc => { self.cancel_log_stream(); self.draft_detail_open = false; + self.draft_detail_scroll = 0; self.sandbox_policy_tab = SandboxPolicyTab::Policy; self.screen = Screen::Dashboard; self.focus = Focus::Sandboxes; @@ -1856,6 +1867,25 @@ impl App { } } + /// Largest useful scroll offset for the draft detail popup. + fn draft_detail_max_scroll(&self) -> usize { + self.draft_detail_rows + .saturating_sub(self.draft_detail_body_height) + } + + /// One screenful of the draft detail popup body. + fn draft_detail_page(&self) -> isize { + isize::try_from(self.draft_detail_body_height.max(1)).unwrap_or(1) + } + + /// Move the draft detail popup by `delta` rows, clamped to the content. + fn scroll_draft_detail(&mut self, delta: isize) { + let max = isize::try_from(self.draft_detail_max_scroll()).unwrap_or(isize::MAX); + let current = isize::try_from(self.draft_detail_scroll).unwrap_or(0); + let next = current.saturating_add(delta).clamp(0, max); + self.draft_detail_scroll = usize::try_from(next).unwrap_or(0); + } + fn handle_draft_key(&mut self, key: KeyEvent) { // Approve-all confirmation modal intercepts all keys when open. if self.approve_all_confirm_open { @@ -1880,6 +1910,7 @@ impl App { match key.code { KeyCode::Esc | KeyCode::Enter => { self.draft_detail_open = false; + self.draft_detail_scroll = 0; } // Allow approve/reject toggle from within the popup. KeyCode::Char('a') => { @@ -1893,6 +1924,7 @@ impl App { if st == "pending" || st == "rejected" { self.pending_draft_approve = true; self.draft_detail_open = false; + self.draft_detail_scroll = 0; } } } @@ -1908,10 +1940,27 @@ impl App { if st == "pending" || st == "approved" { self.pending_draft_reject = true; self.draft_detail_open = false; + self.draft_detail_scroll = 0; } } } } + // Scroll the detail body; long rejection guidance and rationales + // can exceed the fixed popup height. + KeyCode::Down | KeyCode::Char('j') => self.scroll_draft_detail(1), + KeyCode::Up | KeyCode::Char('k') => self.scroll_draft_detail(-1), + KeyCode::PageDown => { + let page = self.draft_detail_page(); + self.scroll_draft_detail(page); + } + KeyCode::PageUp => { + let page = self.draft_detail_page(); + self.scroll_draft_detail(-page); + } + KeyCode::Home | KeyCode::Char('g') => self.draft_detail_scroll = 0, + KeyCode::End | KeyCode::Char('G') => { + self.draft_detail_scroll = self.draft_detail_max_scroll(); + } _ => {} } return; @@ -1937,6 +1986,7 @@ impl App { } KeyCode::Enter if !self.draft_chunks.is_empty() => { self.draft_detail_open = true; + self.draft_detail_scroll = 0; } KeyCode::Char('j') | KeyCode::Down => { if total == 0 { diff --git a/crates/openshell-tui/src/ui/mod.rs b/crates/openshell-tui/src/ui/mod.rs index 8df6dd3470..3078ff793f 100644 --- a/crates/openshell-tui/src/ui/mod.rs +++ b/crates/openshell-tui/src/ui/mod.rs @@ -103,8 +103,13 @@ fn draw_sandbox_screen(frame: &mut Frame<'_>, app: &mut App, area: Rect) { // Draft detail popup renders over the full frame. if app.focus == Focus::SandboxDraft && app.draft_detail_open { let abs = app.draft_scroll + app.draft_selected; - if let Some(chunk) = app.draft_chunks.get(abs) { - sandbox_draft::draw_detail_popup(frame, chunk, frame.size(), &app.theme); + let scroll = app.draft_detail_scroll; + let metrics = app.draft_chunks.get(abs).map(|chunk| { + sandbox_draft::draw_detail_popup(frame, chunk, frame.size(), &app.theme, scroll) + }); + if let Some(metrics) = metrics { + app.draft_detail_rows = metrics.total_rows; + app.draft_detail_body_height = metrics.body_height; } } diff --git a/crates/openshell-tui/src/ui/sandbox_draft.rs b/crates/openshell-tui/src/ui/sandbox_draft.rs index cda60d7742..470463b7d4 100644 --- a/crates/openshell-tui/src/ui/sandbox_draft.rs +++ b/crates/openshell-tui/src/ui/sandbox_draft.rs @@ -6,10 +6,10 @@ use crate::app::App; use openshell_core::proto::{L7Allow, L7DenyRule, L7QueryMatcher, NetworkEndpoint, PolicyChunk}; use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::style::Modifier; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap}; +use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph}; use super::centered_rect; @@ -150,6 +150,12 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect) { if chunk.hit_count > 1 { spans.push(Span::styled(format!(" {}x", chunk.hit_count), t.accent)); } + if let Some(reason) = rejection_guidance(chunk) { + spans.push(Span::styled( + format!(" \"{}\"", truncate_display(reason, 32)), + t.muted, + )); + } let mut line = Line::from(spans); if is_selected { @@ -172,12 +178,22 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect) { // Detail popup (Enter key) // --------------------------------------------------------------------------- +/// What `draw_detail_popup` actually laid out, so the app can clamp its scroll +/// offset to content that exists. +pub struct DetailMetrics { + /// Total rows of content, after wrapping. + pub total_rows: usize, + /// Rows visible in the scrollable body, excluding the pinned hint row. + pub body_height: usize, +} + pub fn draw_detail_popup( frame: &mut Frame<'_>, chunk: &PolicyChunk, area: Rect, theme: &crate::theme::Theme, -) { + scroll: usize, +) -> DetailMetrics { let t = theme; let popup_width = (area.width * 4 / 5).min(area.width.saturating_sub(4)); let popup_height = 22u16.min(area.height.saturating_sub(4)); @@ -198,6 +214,11 @@ pub fn draw_detail_popup( .border_style(t.accent) .padding(Padding::new(1, 1, 0, 0)); + // Text columns inside the borders and the one-column padding on each side. + // Content is wrapped to this width here rather than by `Wrap`, so the row + // count below is exactly what renders and the scroll clamp stays honest. + let text_width = usize::from(popup_width).saturating_sub(4).max(1); + let mut lines: Vec> = vec![ Line::from(vec![ Span::styled("Status: ", t.muted), @@ -215,57 +236,100 @@ pub fn draw_detail_popup( ApprovalAnnotationKind::RequiresReview => t.status_warn.add_modifier(Modifier::BOLD), ApprovalAnnotationKind::Reviewed => t.muted, }; - lines.push(Line::from(vec![ - Span::styled("Review: ", t.muted), - Span::styled(annotation.detail_label, annotation_style), - ])); + push_wrapped( + &mut lines, + "Review: ", + t.muted, + &annotation.detail_label, + annotation_style, + text_width, + ); + } + + // Reviewer's persisted rejection guidance. The reason is free-form and has + // no server-side length cap, so it wraps and the popup scrolls instead of + // clipping the tail. + if let Some(reason) = rejection_guidance(chunk) { + push_wrapped( + &mut lines, + "Guidance: ", + t.muted, + reason, + t.status_err, + text_width, + ); } // Binary (denormalized from the denial). if !chunk.binary.is_empty() { - lines.push(Line::from(vec![ - Span::styled("Binary: ", t.muted), - Span::styled(&chunk.binary, t.text), - ])); + push_wrapped( + &mut lines, + "Binary: ", + t.muted, + &chunk.binary, + t.text, + text_width, + ); } - // Hit count (accumulated real denial count) and first/last seen. - lines.push(Line::from(vec![ - Span::styled("Denied: ", t.muted), - Span::styled( - format!( - "{} connection{}", - chunk.hit_count, - if chunk.hit_count == 1 { "" } else { "s" } - ), + // Hit count (accumulated real denial count) and first/last seen. Kept on one + // row while it fits, so a narrow terminal wraps it instead of losing the tail. + let denied_label = "Denied: "; + let denied_count = format!( + "{} connection{}", + chunk.hit_count, + if chunk.hit_count == 1 { "" } else { "s" } + ); + let denied_seen = format!( + "(first {} / last {})", + format_short_time(chunk.first_seen_ms), + format_short_time(chunk.last_seen_ms), + ); + let denied_width = display_width(denied_label) + + display_width(&denied_count) + + 2 + + display_width(&denied_seen); + if denied_width <= text_width { + lines.push(Line::from(vec![ + Span::styled(denied_label, t.muted), + Span::styled(denied_count, t.accent), + Span::styled(format!(" {denied_seen}"), t.muted), + ])); + } else { + push_wrapped( + &mut lines, + denied_label, + t.muted, + &denied_count, t.accent, - ), - Span::styled( - format!( - " (first {} / last {})", - format_short_time(chunk.first_seen_ms), - format_short_time(chunk.last_seen_ms), - ), + text_width, + ); + push_wrapped( + &mut lines, + &" ".repeat(display_width(denied_label)), t.muted, - ), - ])); + &denied_seen, + t.muted, + text_width, + ); + } // Endpoints. if let Some(ref rule) = chunk.proposed_rule { lines.push(Line::from("")); lines.push(Line::from(Span::styled("Endpoints:", t.muted))); for ep in &rule.endpoints { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled("-> ", t.muted), - Span::styled(format_endpoint_summary(ep), t.accent), - ])); + push_wrapped( + &mut lines, + " -> ", + t.muted, + &format_endpoint_summary(ep), + t.accent, + text_width, + ); for detail in format_endpoint_details(ep) { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(detail, t.text), - ])); + push_wrapped(&mut lines, " ", t.text, &detail, t.text, text_width); } } @@ -274,10 +338,7 @@ pub fn draw_detail_popup( lines.push(Line::from("")); lines.push(Line::from(Span::styled("Binaries:", t.muted))); for b in &rule.binaries { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(&b.path, t.text), - ])); + push_wrapped(&mut lines, " ", t.text, &b.path, t.text, text_width); } } } @@ -285,59 +346,86 @@ pub fn draw_detail_popup( // Rationale. if !chunk.rationale.is_empty() { lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled("Rationale: ", t.muted), - Span::styled(&chunk.rationale, t.text), - ])); + push_wrapped( + &mut lines, + "Rationale: ", + t.muted, + &chunk.rationale, + t.text, + text_width, + ); } // Security notes. if !chunk.security_notes.is_empty() { lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - format!("! {}", chunk.security_notes), - t.status_warn.add_modifier(Modifier::BOLD), - )])); + let warn = t.status_warn.add_modifier(Modifier::BOLD); + push_wrapped( + &mut lines, + "! ", + warn, + &chunk.security_notes, + warn, + text_width, + ); } - // Action hints — state-aware toggle keys. - lines.push(Line::from("")); - let mut hint_spans: Vec> = Vec::new(); - match chunk.status.as_str() { - "pending" => { - hint_spans.extend([ - Span::styled("[a]", t.key_hint), - Span::styled(" Approve ", t.text), - Span::styled("[x]", t.key_hint), - Span::styled(" Reject ", t.text), - ]); - } - "approved" => { - hint_spans.extend([ - Span::styled("[x]", t.key_hint), - Span::styled(" Revoke ", t.text), - ]); - } - "rejected" => { - hint_spans.extend([ - Span::styled("[a]", t.key_hint), - Span::styled(" Approve ", t.text), - ]); + // Split the inner area into a scrollable body and pinned hint rows, so the + // action and close controls stay on screen however long the content is. The + // hints need a second row on a narrow terminal and that shrinks the body, so + // settle the two together. + let inner = block.inner(popup_area); + let inner_height = usize::from(inner.height); + let total_rows = lines.len(); + let max_footer = inner_height.saturating_sub(1).max(1); + + let mut footer_rows = 1usize; + let mut hint_rows = Vec::new(); + for _ in 0..2 { + let body = inner_height.saturating_sub(footer_rows); + let scrollable = total_rows.saturating_sub(body) > 0; + hint_rows = pack_hints(&hint_units(chunk, t, scrollable), text_width); + let needed = hint_rows.len().clamp(1, max_footer); + if needed == footer_rows { + break; } - _ => {} + footer_rows = needed; } - hint_spans.extend([ - Span::styled("[Esc]", t.muted), - Span::styled(" Close", t.muted), - ]); - lines.push(Line::from(hint_spans)); + let parts = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(0), + Constraint::Length(u16::try_from(footer_rows).unwrap_or(1)), + ]) + .split(inner); + let body_height = usize::from(parts[0].height); + let max_scroll = total_rows.saturating_sub(body_height); + let scroll = scroll.min(max_scroll); + + let block = if max_scroll > 0 { + block.title_bottom( + Line::from(Span::styled( + format!(" {}/{} ", scroll + 1, total_rows), + t.muted, + )) + .right_aligned(), + ) + } else { + block + }; + + frame.render_widget(block, popup_area); frame.render_widget( - Paragraph::new(lines) - .block(block) - .wrap(Wrap { trim: false }), - popup_area, + Paragraph::new(lines).scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)), + parts[0], ); + frame.render_widget(Paragraph::new(hint_rows), parts[1]); + + DetailMetrics { + total_rows, + body_height, + } } // --------------------------------------------------------------------------- @@ -460,6 +548,188 @@ fn truncate_str(s: &str, max_len: usize) -> String { } } +/// Terminal display width of `text` in columns. +/// +/// Uses the same measurement ratatui applies when it lays cells out, so a +/// double-width glyph such as CJK counts as the two columns it will occupy. +/// Counting `chars` here instead would let CJK text render past the popup edge +/// with no way to scroll to the missing tail. +fn display_width(text: &str) -> usize { + Span::raw(text).width() +} + +/// Word-wrap `text` into rows of at most `width` display columns. +/// +/// A word wider than `width` is hard-broken rather than allowed to overflow. +/// Always returns at least one row so callers can index the first row safely. +fn wrap_value(text: &str, width: usize) -> Vec { + if width == 0 { + return vec![text.to_string()]; + } + let mut rows: Vec = Vec::new(); + let mut cur = String::new(); + let mut cur_width = 0usize; + let mut buf = [0u8; 4]; + for word in text.split_whitespace() { + let word_width = display_width(word); + if word_width > width { + if cur_width > 0 { + rows.push(std::mem::take(&mut cur)); + cur_width = 0; + } + for ch in word.chars() { + let ch_width = display_width(ch.encode_utf8(&mut buf)); + if cur_width + ch_width > width && cur_width > 0 { + rows.push(std::mem::take(&mut cur)); + cur_width = 0; + } + cur.push(ch); + cur_width += ch_width; + } + continue; + } + let needed = if cur_width == 0 { + word_width + } else { + cur_width + 1 + word_width + }; + if needed > width { + rows.push(std::mem::take(&mut cur)); + cur_width = 0; + } + if cur_width > 0 { + cur.push(' '); + cur_width += 1; + } + cur.push_str(word); + cur_width += word_width; + } + if cur_width > 0 || rows.is_empty() { + rows.push(cur); + } + rows +} + +/// Truncate `text` to `max_columns` display columns, appending `...` when cut. +/// +/// `truncate_str` counts chars, which is right for its existing callers but +/// would let a CJK value take twice its budget on the list row. +fn truncate_display(text: &str, max_columns: usize) -> String { + if display_width(text) <= max_columns { + return text.to_string(); + } + if max_columns <= 3 { + return ".".repeat(max_columns); + } + let budget = max_columns - 3; + let mut out = String::new(); + let mut width = 0usize; + let mut buf = [0u8; 4]; + for ch in text.chars() { + let ch_width = display_width(ch.encode_utf8(&mut buf)); + if width + ch_width > budget { + break; + } + out.push(ch); + width += ch_width; + } + out.push_str("..."); + out +} + +/// Push `text` wrapped to `width`, with `prefix` on the first row and a matching +/// indent on continuation rows so the label column stays aligned. +fn push_wrapped( + lines: &mut Vec>, + prefix: &str, + prefix_style: Style, + text: &str, + text_style: Style, + width: usize, +) { + let indent = display_width(prefix); + let available = width.saturating_sub(indent).max(1); + for (i, row) in wrap_value(text, available).into_iter().enumerate() { + let head = if i == 0 { + Span::styled(prefix.to_string(), prefix_style) + } else { + Span::raw(" ".repeat(indent)) + }; + lines.push(Line::from(vec![head, Span::styled(row, text_style)])); + } +} + +/// One footer hint: the spans that render it, and its display width. +type HintUnit = (Vec>, usize); + +/// State-aware hints for the detail popup footer. +fn hint_units(chunk: &PolicyChunk, t: &crate::theme::Theme, scrollable: bool) -> Vec { + let mut units: Vec = Vec::new(); + { + let mut add = |key: &str, label: &str, key_style: Style, label_style: Style| { + units.push(( + vec![ + Span::styled(key.to_string(), key_style), + Span::styled(label.to_string(), label_style), + ], + display_width(key) + display_width(label), + )); + }; + match chunk.status.as_str() { + "pending" => { + add("[a]", " Approve ", t.key_hint, t.text); + add("[x]", " Reject ", t.key_hint, t.text); + } + "approved" => add("[x]", " Revoke ", t.key_hint, t.text), + "rejected" => add("[a]", " Approve ", t.key_hint, t.text), + _ => {} + } + if scrollable { + add("[j/k]", " Scroll ", t.key_hint, t.text); + } + add("[Esc]", " Close", t.muted, t.muted); + } + units +} + +/// Pack hint units into rows no wider than `width`, never splitting a unit. +/// +/// A narrow terminal would otherwise clip the trailing hints, and `[Esc] Close` +/// is the last one. +fn pack_hints(units: &[HintUnit], width: usize) -> Vec> { + let mut rows: Vec> = Vec::new(); + let mut current: Vec> = Vec::new(); + let mut current_width = 0usize; + for (spans, unit_width) in units { + if current_width + unit_width > width && !current.is_empty() { + rows.push(Line::from(std::mem::take(&mut current))); + current_width = 0; + } + current.extend(spans.iter().cloned()); + current_width += unit_width; + } + if !current.is_empty() { + rows.push(Line::from(current)); + } + if rows.is_empty() { + rows.push(Line::from(String::new())); + } + rows +} + +/// The reviewer's persisted note for a rejected chunk. +/// +/// Gated on status rather than on the field alone: the gateway's +/// `update_draft_chunk_status` leaves `rejection_reason` untouched when a chunk +/// is later approved, so an approved chunk can still carry a stale note. +fn rejection_guidance(chunk: &PolicyChunk) -> Option<&str> { + if chunk.status != "rejected" { + return None; + } + let reason = chunk.rejection_reason.trim(); + (!reason.is_empty()).then_some(reason) +} + #[derive(Clone, Copy)] enum ApprovalAnnotationKind { AutoApproved, @@ -701,3 +971,396 @@ fn format_short_time(epoch_ms: i64) -> String { let seconds = time_of_day % 60; format!("{hours:02}:{minutes:02}:{seconds:02}") } + +#[cfg(test)] +mod tests { + use super::*; + use crate::theme::Theme; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + /// Column width computed independently of the production `display_width`. + /// + /// Asserting with the helper under test would be tautological: if it + /// regressed to counting chars, the assertion would regress with it. + fn expected_columns(text: &str) -> usize { + text.chars() + .map(|c| { + let wide = ('\u{1100}'..='\u{115f}').contains(&c) + || ('\u{2e80}'..='\u{a4cf}').contains(&c) + || ('\u{ac00}'..='\u{d7a3}').contains(&c) + || ('\u{f900}'..='\u{faff}').contains(&c) + || ('\u{fe30}'..='\u{fe6f}').contains(&c) + || ('\u{ff00}'..='\u{ff60}').contains(&c) + || ('\u{ffe0}'..='\u{ffe6}').contains(&c); + usize::from(wide) + 1 + }) + .sum() + } + + fn make_chunk(status: &str, rejection_reason: &str) -> PolicyChunk { + PolicyChunk { + status: status.to_string(), + rejection_reason: rejection_reason.to_string(), + ..Default::default() + } + } + + #[test] + fn rejected_chunk_exposes_its_reason() { + let chunk = make_chunk("rejected", "too broad: allows any port"); + assert_eq!( + rejection_guidance(&chunk), + Some("too broad: allows any port") + ); + } + + #[test] + fn approved_chunk_hides_stale_reason() { + // Approving passes None for the reason, which leaves the stored value in + // place, so a chunk rejected and later approved still carries the note. + let chunk = make_chunk("approved", "too broad: allows any port"); + assert_eq!(rejection_guidance(&chunk), None); + } + + #[test] + fn pending_chunk_has_no_guidance() { + assert_eq!(rejection_guidance(&make_chunk("pending", "")), None); + } + + #[test] + fn blank_reason_is_dropped() { + assert_eq!(rejection_guidance(&make_chunk("rejected", "")), None); + assert_eq!(rejection_guidance(&make_chunk("rejected", " \n")), None); + } + + #[test] + fn reason_is_trimmed() { + let chunk = make_chunk("rejected", " needs a narrower host "); + assert_eq!(rejection_guidance(&chunk), Some("needs a narrower host")); + } + + #[test] + fn long_reason_truncates_for_the_list_row() { + let reason = "rejected because the endpoint list is far too permissive"; + let shown = truncate_display(reason, 32); + assert_eq!(display_width(&shown), 32); + assert!(shown.ends_with("...")); + assert!(shown.starts_with("rejected because")); + } + + #[test] + fn short_reason_is_not_truncated() { + assert_eq!(truncate_str("too broad", 32), "too broad"); + } + + // --- wrapping --------------------------------------------------------- + + #[test] + fn wrap_value_breaks_on_word_boundaries() { + assert_eq!( + wrap_value("alpha beta gamma", 11), + vec!["alpha beta", "gamma"] + ); + } + + #[test] + fn wrap_value_hard_breaks_a_word_longer_than_the_width() { + assert_eq!(wrap_value("abcdefghij", 4), vec!["abcd", "efgh", "ij"]); + } + + #[test] + fn wrap_value_always_returns_at_least_one_row() { + assert_eq!(wrap_value("", 10), vec![String::new()]); + } + + #[test] + fn wrap_value_never_exceeds_the_width() { + let text = "reject this rule because the endpoint list is far too permissive"; + for row in wrap_value(text, 17) { + assert!(display_width(&row) <= 17, "row too wide: {row:?}"); + } + } + + #[test] + fn wrap_value_measures_display_columns_not_chars() { + // CJK glyphs occupy two columns each, and with no spaces the whole + // string takes the hard-break path. + let cjk = "这条规则的范围太广".repeat(20); + for row in wrap_value(&cjk, 40) { + assert!( + expected_columns(&row) <= 40, + "row is {} columns: {row:?}", + expected_columns(&row) + ); + } + + // Mixed script exercises the word-packing path instead. + let mixed = "scope 这条规则 to docs 路径 only ".repeat(20); + for row in wrap_value(&mixed, 33) { + assert!( + expected_columns(&row) <= 33, + "row is {} columns: {row:?}", + expected_columns(&row) + ); + } + } + + #[test] + fn truncate_display_counts_columns_not_chars() { + let cjk = "这条规则的范围太广".repeat(10); + let shown = truncate_display(&cjk, 32); + assert!( + expected_columns(&shown) <= 32, + "truncated value is {} columns", + expected_columns(&shown) + ); + assert!(shown.ends_with("...")); + assert_eq!(truncate_display("too broad", 32), "too broad"); + } + + // --- rendering -------------------------------------------------------- + + fn render(chunk: &PolicyChunk, width: u16, height: u16, scroll: usize) -> (String, usize) { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + let mut max_scroll = 0usize; + terminal + .draw(|frame| { + let metrics = draw_detail_popup( + frame, + chunk, + Rect::new(0, 0, width, height), + &Theme::dark(), + scroll, + ); + max_scroll = metrics.total_rows.saturating_sub(metrics.body_height); + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + let text: String = buffer + .content() + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect(); + (text, max_scroll) + } + + /// A rejection reason has no server-side length cap, so the popup has to keep + /// a paragraph-length one reachable rather than clipping its tail. + fn long_reason() -> String { + let mut reason = String::from("PREFIX_MARKER "); + while reason.len() < 1980 { + reason.push_str("this rule is far too broad and must be narrowed; "); + } + reason.push_str(" SUFFIX_MARKER"); + reason + } + + #[test] + fn long_guidance_head_and_tail_are_both_reachable() { + let chunk = PolicyChunk { + status: "rejected".to_string(), + rejection_reason: long_reason(), + rule_name: "allow-github".to_string(), + ..Default::default() + }; + + let (top, max_scroll) = render(&chunk, 80, 24, 0); + assert!(max_scroll > 0, "content should overflow an 80x24 popup"); + assert!( + top.contains("PREFIX_MARKER"), + "head not visible at scroll 0" + ); + + let (bottom, _) = render(&chunk, 80, 24, max_scroll); + assert!( + bottom.contains("SUFFIX_MARKER"), + "tail not reachable at max scroll" + ); + } + + #[test] + fn action_hints_stay_visible_at_every_scroll_position() { + let chunk = PolicyChunk { + status: "rejected".to_string(), + rejection_reason: long_reason(), + rule_name: "allow-github".to_string(), + ..Default::default() + }; + + let (top, max_scroll) = render(&chunk, 80, 24, 0); + let (bottom, _) = render(&chunk, 80, 24, max_scroll); + for (label, screen) in [("top", &top), ("bottom", &bottom)] { + assert!(screen.contains("Close"), "close hint missing at {label}"); + assert!( + screen.contains("Approve"), + "approve hint missing at {label}" + ); + assert!(screen.contains("Scroll"), "scroll hint missing at {label}"); + } + } + + #[test] + fn scrolling_past_the_end_is_clamped_to_the_last_page() { + let chunk = PolicyChunk { + status: "rejected".to_string(), + rejection_reason: long_reason(), + rule_name: "allow-github".to_string(), + ..Default::default() + }; + let (_, max_scroll) = render(&chunk, 80, 24, 0); + let (clamped, _) = render(&chunk, 80, 24, max_scroll + 500); + let (last, _) = render(&chunk, 80, 24, max_scroll); + assert_eq!( + clamped, last, + "over-scrolling should clamp to the last page" + ); + } + + /// The same overflow already affected `rationale` before this change, so the + /// scrollable body has to fix that case too. + #[test] + fn long_rationale_tail_is_reachable_on_a_pending_chunk() { + let mut rationale = String::from("PREFIX_MARKER "); + while rationale.len() < 1980 { + rationale.push_str("the agent needs broad network access; "); + } + rationale.push_str(" SUFFIX_MARKER"); + let chunk = PolicyChunk { + status: "pending".to_string(), + rule_name: "allow-github".to_string(), + rationale, + ..Default::default() + }; + + let (_, max_scroll) = render(&chunk, 80, 24, 0); + let (bottom, _) = render(&chunk, 80, 24, max_scroll); + assert!(bottom.contains("SUFFIX_MARKER")); + assert!(bottom.contains("Close")); + } + + /// Double-width guidance must stay reachable too. Counting chars rather than + /// columns made every wrapped row about twice as wide as the popup, so the + /// right half of each row was clipped with nowhere to scroll. Markers are + /// interleaved through the text rather than appended, because a trailing + /// marker lands on its own short row either way and would not detect this. + #[test] + fn cjk_guidance_is_fully_reachable_at_80x24() { + use std::fmt::Write as _; + + const MARKERS: usize = 16; + let mut reason = String::new(); + for i in 0..MARKERS { + let _ = write!(reason, "M{i:02}"); + reason.push_str(&"这条规则范围太广".repeat(4)); + } + let chunk = PolicyChunk { + status: "rejected".to_string(), + rejection_reason: reason, + rule_name: "allow-github".to_string(), + ..Default::default() + }; + + let (_, max_scroll) = render(&chunk, 80, 24, 0); + assert!(max_scroll > 0, "content should overflow an 80x24 popup"); + + let mut seen = String::new(); + for scroll in 0..=max_scroll { + seen.push_str(&render(&chunk, 80, 24, scroll).0); + } + for i in 0..MARKERS { + let marker = format!("M{i:02}"); + assert!( + seen.contains(&marker), + "guidance marker {marker} was clipped and is unreachable at every scroll offset" + ); + } + } + + #[test] + fn short_content_does_not_scroll() { + let chunk = PolicyChunk { + status: "rejected".to_string(), + rejection_reason: "too broad".to_string(), + rule_name: "allow-github".to_string(), + ..Default::default() + }; + let (screen, max_scroll) = render(&chunk, 80, 24, 0); + assert_eq!(max_scroll, 0, "short content should not be scrollable"); + assert!(screen.contains("too broad")); + assert!( + !screen.contains("Scroll"), + "scroll hint should be hidden when everything fits" + ); + } + + fn denied_chunk(status: &str, reason: &str) -> PolicyChunk { + PolicyChunk { + status: status.to_string(), + rejection_reason: reason.to_string(), + rule_name: "allow-github".to_string(), + confidence: 0.82, + hit_count: 3, + first_seen_ms: 1_700_000_000_000, + last_seen_ms: 1_700_000_100_000, + ..Default::default() + } + } + + /// Dropping `Wrap` means anything not routed through `push_wrapped` clips + /// horizontally, so the denial timestamps have to wrap on a narrow popup. + #[test] + fn denied_timestamps_survive_a_narrow_popup() { + for width in [60u16, 70u16, 80u16] { + let chunk = denied_chunk("rejected", "scope this to docs/ paths only"); + let (screen, _) = render(&chunk, width, 24, 0); + assert!( + screen.contains("22:13:20"), + "first-seen lost at width {width}" + ); + assert!( + screen.contains("22:15:00"), + "last-seen lost at width {width}" + ); + } + } + + /// The close hint is the last one, so a narrow footer would drop it first. + #[test] + fn close_hint_survives_a_narrow_popup_with_scrollable_content() { + for width in [60u16, 70u16, 80u16] { + let chunk = denied_chunk("pending", ""); + let chunk = PolicyChunk { + rationale: long_reason(), + ..chunk + }; + let (screen, max_scroll) = render(&chunk, width, 24, 0); + assert!(max_scroll > 0, "content should overflow at width {width}"); + assert!(screen.contains("Close"), "close hint lost at width {width}"); + assert!( + screen.contains("Approve"), + "approve hint lost at width {width}" + ); + assert!( + screen.contains("Reject"), + "reject hint lost at width {width}" + ); + } + } + + #[test] + fn hints_pack_onto_one_row_when_they_fit() { + let theme = Theme::dark(); + let chunk = denied_chunk("pending", ""); + let rows = pack_hints(&hint_units(&chunk, &theme, true), 200); + assert_eq!(rows.len(), 1); + } + + #[test] + fn hints_spill_onto_a_second_row_when_they_do_not_fit() { + let theme = Theme::dark(); + let chunk = denied_chunk("pending", ""); + let rows = pack_hints(&hint_units(&chunk, &theme, true), 30); + assert!(rows.len() > 1, "hints should wrap at 30 columns"); + } +} diff --git a/docs/sandboxes/policy-advisor.mdx b/docs/sandboxes/policy-advisor.mdx index bc09468cdb..b4d607cb33 100644 --- a/docs/sandboxes/policy-advisor.mdx +++ b/docs/sandboxes/policy-advisor.mdx @@ -231,6 +231,8 @@ openshell rule reject \ The rejection reason is returned to the agent through `policy.local`. The agent can use it to draft a narrower proposal. +Reviewers see the same guidance in the terminal UI. Run `openshell term`, open the sandbox's draft inbox, and select a rejected chunk to open its detail popup. The stored reason appears on a `Guidance:` line, and the list row shows a shortened copy of it. Rejection reasons have no length limit, so long guidance wraps and the popup body scrolls with `j`/`k`, `PageUp`/`PageDown`, and `g`/`G`; the approve and close controls stay pinned below the body, and the bottom border shows the scroll position. + ## Agent API `policy.local` is available only inside the sandbox and uses plain HTTP: From 4d16a2a6f448bbe9639cae13f102115deaf78b0d Mon Sep 17 00:00:00 2001 From: "John T. Myers" <9696606+johntmyers@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:59:04 +0000 Subject: [PATCH 019/505] feat(gator): improve review output and launch compatibility (#2896) * feat(gator): render human-readable review findings Signed-off-by: John Myers * feat(gator): collapse operational review metadata Signed-off-by: John Myers * fix(gator): support canonical sandbox startup Signed-off-by: John Myers * fix(gator): preserve credential placeholder identity Signed-off-by: John Myers * feat(gator): resolve addressed review threads Signed-off-by: John Myers * fix(agents): preserve provider profile revisions Signed-off-by: John Myers --------- Signed-off-by: John Myers Co-authored-by: John Myers --- .../skills/launch-openshell-gator/SKILL.md | 13 +- scripts/agents/gator/Dockerfile | 2 + scripts/agents/gator/README.md | 10 +- scripts/agents/gator/agent.yaml | 2 +- scripts/agents/gator/bin/gh | 2 +- scripts/agents/gator/bin/gh_guard_test.sh | 19 +- .../gator/bin/resolve-gator-review-threads | 106 +++++++++ .../bin/resolve_gator_review_threads_test.sh | 112 ++++++++++ .../gator/bin/review_feedback_ledger_test.sh | 18 +- scripts/agents/gator/prompts/gator.md | 5 +- .../agents/gator/providers/github-gator.yaml | 4 + .../agents/gator/skills/gator-gate/SKILL.md | 205 ++++++++++++++---- .../references/review-findings-schema.md | 5 + scripts/agents/run.sh | 96 +++++++- .../agents/runtime/harnesses/codex/exec.sh | 20 +- .../runtime/harnesses/codex/exec_test.sh | 39 ++++ 16 files changed, 566 insertions(+), 92 deletions(-) create mode 100755 scripts/agents/gator/bin/resolve-gator-review-threads create mode 100755 scripts/agents/gator/bin/resolve_gator_review_threads_test.sh create mode 100755 scripts/agents/runtime/harnesses/codex/exec_test.sh diff --git a/.agents/skills/launch-openshell-gator/SKILL.md b/.agents/skills/launch-openshell-gator/SKILL.md index 8b25760698..c8e9fc9489 100644 --- a/.agents/skills/launch-openshell-gator/SKILL.md +++ b/.agents/skills/launch-openshell-gator/SKILL.md @@ -28,6 +28,7 @@ For gator's PR/issue validation policy, load `gator-gate` inside the launched sa | `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. | +| `scripts/agents/gator/bin/resolve-gator-review-threads` | Resolves addressed Gator-owned inline review threads by ledger finding ID. | | `scripts/agents/gator/bin/validate-review-findings` | Enforces the blocker evidence schema and downgrades unsupported hypotheses. | | `scripts/agents/gator/prompts/gator.md` | Rendered top-level prompt template baked into the payload. | | `scripts/agents/gator/skills/gator-gate/SKILL.md` | In-sandbox gator state-machine skill. | @@ -157,7 +158,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 the sandbox, and writes a background log under `scripts/agents/gator/logs/`. +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/`. ### Launch An Issue Or Issue/PR Pair @@ -317,10 +318,10 @@ Restart when the payload must change, the sandbox is wedged without a sentinel, Increment `payload_version` in `scripts/agents/gator/agent.yaml` whenever a merged change alters the Gator prompt, gate skill, reviewer contract, write -guard, ledger, or bundled validator. Existing immutable watchers cannot replace -their own payload. New-version watchers detect later published versions and -stop with `stale_gator_payload`; relaunch every still-active older watcher after -the version bump is published. +guard, ledger, thread resolver, or bundled validator. Existing immutable +watchers cannot replace their own payload. New-version watchers detect later +published versions and stop with `stale_gator_payload`; relaunch every +still-active older watcher after the version bump is published. Before deleting, check that the sandbox is truly stale or that the operator asked for a restart. If a bounded review cycle is actively running and still producing useful output, prefer leaving it alone. @@ -368,7 +369,9 @@ Symptoms: host `gh` auth fails, Codex refresh fails, in-sandbox GitHub calls rep Actions: - Re-run the GitHub and Codex preflight checks. +- Existing refresh-managed providers are reused without an ordinary credential update; the launcher rotates their gateway-managed credential instead. - If host Codex auth changed, relaunch with `--reset-refresh` once. +- `--reset-refresh` removes the old refresh ownership before rediscovering host credentials, then configures and rotates the replacement refresh state. - If Entra or Microsoft auth is involved in a future provider, use the relevant auth skill. Gator's default providers are GitHub and Codex. ### Unsupported `gh pr view --json` Field diff --git a/scripts/agents/gator/Dockerfile b/scripts/agents/gator/Dockerfile index 5cf2616b7e..6484746dbb 100644 --- a/scripts/agents/gator/Dockerfile +++ b/scripts/agents/gator/Dockerfile @@ -85,10 +85,12 @@ RUN mkdir -p /etc/openshell COPY policy.yaml /etc/openshell/policy.yaml COPY bin/gh /usr/local/bin/gh-gator COPY bin/review-feedback-ledger /usr/local/bin/review-feedback-ledger +COPY bin/resolve-gator-review-threads /usr/local/bin/resolve-gator-review-threads COPY bin/validate-review-findings /usr/local/bin/validate-review-findings RUN rm -f /usr/local/bin/gh && \ cp /usr/local/bin/gh-gator /usr/local/bin/gh && \ chmod 755 /usr/local/bin/gh /usr/local/bin/review-feedback-ledger \ + /usr/local/bin/resolve-gator-review-threads \ /usr/local/bin/validate-review-findings RUN printf 'export PATH="/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin"\nexport PS1="\\u@\\h:\\w\\$ "\n' \ diff --git a/scripts/agents/gator/README.md b/scripts/agents/gator/README.md index 5773d08251..3bdb4f490a 100644 --- a/scripts/agents/gator/README.md +++ b/scripts/agents/gator/README.md @@ -29,7 +29,7 @@ The manifest-driven launcher at `scripts/agents/run.sh` reads `agent.yaml`, whic The launcher: -- Scans `profile_paths` in manifest order and imports `providers/github-gator.yaml`. +- Scans `profile_paths` in manifest order and imports or updates `providers/github-gator.yaml`. - Creates or updates the `github-gator` provider from `gh auth token`. - Selects the requested harness and bakes the common runtime into the immutable sandbox payload. - For `--harness codex`, imports `providers/codex-gator.yaml`, creates or updates the `codex-gator` provider from `$HOME/.codex/auth.json`, and stores the refresh token as gateway-only refresh material. @@ -38,14 +38,18 @@ The launcher: - Uses the gator image policy copied to `/etc/openshell/policy.yaml`. - Installs the gator-specific `gh` wrapper from `gator/bin/gh` as `/usr/local/bin/gh` to fail closed when same-head-SHA history cannot be checked, prevent duplicate dispositions, and require versioned review payloads. - Installs `gator/bin/review-feedback-ledger` as `/usr/local/bin/review-feedback-ledger` so reviews receive tree- and patch-aware scope, prior summaries and findings, resolution state, convergence telemetry, and the three-round Warning budget. +- Installs `gator/bin/resolve-gator-review-threads` so a follow-up commit that + demonstrably fixes a Gator inline finding can resolve the corresponding + Gator-owned GitHub review thread without touching human review threads. - Installs `gator/bin/validate-review-findings` to downgrade blockers that lack the required reachability, ownership, base-vs-head, impact, and reproducer evidence. +- Keeps that normalized evidence as Gator's internal review contract, then renders validated blockers for people as a read-aloud `Summary`, an actionable `Fix`, and a deterministic `Verify`. Exact paths and only the additional provenance an implementation agent needs appear in collapsed `Agent context`; raw evidence headings such as `Base` and `Head` are not posted publicly. Review-process provenance, docs and E2E disposition, SHAs, and state codes appear at the end of the summary in collapsed `Gator metadata`, while required human actions remain visible. - Bakes `scripts/agents/gator/skills/gator-gate/SKILL.md` into `/etc/openshell/agent-payload`. - Bakes `.claude/agents/principal-engineer-reviewer.md` so the selected harness can run a deterministic independent reviewer execution through `/etc/openshell/agent-payload/runtime/subagent.sh principal-engineer-reviewer < task.md`. - For `--harness codex`, optionally bakes a host Codex executable as `/etc/openshell/agent-payload/runtime/harnesses/codex/codex`. - Starts the selected harness without a TTY. - Runs gator in `watch` mode by default. The sandbox stays alive while the supervisor sleeps between bounded Codex cycles, so Codex is not connected during passive PR waits. The supervisor prints periodic heartbeat lines during active cycles and passive sleeps. - Makes each watch cycle compare its immutable payload version with the version published on the default branch. A stale watcher stops without GitHub writes and must be relaunched. -The GitHub provider profile allows read-only GraphQL queries on `api.github.com/graphql` so `gh` read paths can use GraphQL when needed. Write operations remain REST-only and scoped to the two allowed repositories. +The GitHub provider profile allows read-only GraphQL queries on `api.github.com/graphql` so `gh` read paths can use GraphQL when needed. Its only GraphQL mutation is the named `ResolveGatorReviewThread` operation, restricted to the `resolveReviewThread` root field. All other writes remain REST-only and scoped to the two allowed repositories. Set `GATOR_CODEX_ACCESS_CREDENTIAL_KEY` or pass `--codex-access-key` if the gator Codex profile uses a credential key other than `CODEX_AUTH_ACCESS_TOKEN` for the short-lived access token. @@ -58,4 +62,6 @@ The launcher preserves existing gateway-owned Codex refresh material by default ```shell bash scripts/agents/gator/bin/gh_guard_test.sh bash scripts/agents/gator/bin/review_feedback_ledger_test.sh +bash scripts/agents/gator/bin/resolve_gator_review_threads_test.sh +bash scripts/agents/runtime/harnesses/codex/exec_test.sh ``` diff --git a/scripts/agents/gator/agent.yaml b/scripts/agents/gator/agent.yaml index 209c709b51..f4eb082ac1 100644 --- a/scripts/agents/gator/agent.yaml +++ b/scripts/agents/gator/agent.yaml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 id: gator -payload_version: 4 +payload_version: 7 display_name: Gator Gate Agent description: Validate and monitor OpenShell GitHub issues and pull requests through the gator state machine. diff --git a/scripts/agents/gator/bin/gh b/scripts/agents/gator/bin/gh index bda573d795..d65159ff7b 100755 --- a/scripts/agents/gator/bin/gh +++ b/scripts/agents/gator/bin/gh @@ -7,7 +7,7 @@ set -euo pipefail REAL_GH="${OPENSHELL_REAL_GH:-/usr/bin/gh}" GATOR_MARKER='> **gator-agent**' -GATOR_PAYLOAD_VERSION="${OPENSHELL_AGENT_PAYLOAD_VERSION:-4}" +GATOR_PAYLOAD_VERSION="${OPENSHELL_AGENT_PAYLOAD_VERSION:-5}" if [[ $# -lt 1 || "$1" != "api" ]]; then exec "$REAL_GH" "$@" diff --git a/scripts/agents/gator/bin/gh_guard_test.sh b/scripts/agents/gator/bin/gh_guard_test.sh index 35cc5e161d..c1571866c1 100755 --- a/scripts/agents/gator/bin/gh_guard_test.sh +++ b/scripts/agents/gator/bin/gh_guard_test.sh @@ -111,8 +111,15 @@ run_review_case() { ## PR Review Status -Head SHA: `0e4d7af7722fbedce2307d571b0c937a1eb3250f`' \ - --arg payload 'Gator payload: `4`' \ +The review is ready for author follow-up. + +
+Gator metadata + +- Head SHA: `0e4d7af7722fbedce2307d571b0c937a1eb3250f`' \ + --arg payload '- Gator payload: `5` + +
' \ --arg inline_body '> **gator-agent** **Warning:** Keep this validation bound to the accepted value.' \ @@ -142,7 +149,7 @@ same_sha_body='> **gator-agent** ## PR Review Status Head SHA: `0e4d7af7722fbedce2307d571b0c937a1eb3250f` -Gator payload: `4`' +Gator payload: `5`' run_case "blocks duplicate marked comment" \ "$same_sha_body" \ @@ -169,7 +176,7 @@ run_case "allows first versioned review disposition" \ ## PR Review Status Head SHA: `0e4d7af7722fbedce2307d571b0c937a1eb3250f` -Gator payload: `4`' \ +Gator payload: `5`' \ 0 run_case "allows unmarked comment" \ @@ -224,7 +231,7 @@ Gator is blocked from completing the required independent re-review for current ## PR Review Status Head SHA: `0e4d7af7722fbedce2307d571b0c937a1eb3250f` -Gator payload: `4`' \ +Gator payload: `5`' \ 0 draft_blocked_body='> **gator-agent** @@ -244,7 +251,7 @@ run_case "ignores draft blocker after PR is ready" \ ## PR Review Status Head SHA: `0e4d7af7722fbedce2307d571b0c937a1eb3250f` -Gator payload: `4`' \ +Gator payload: `5`' \ 0 \ false diff --git a/scripts/agents/gator/bin/resolve-gator-review-threads b/scripts/agents/gator/bin/resolve-gator-review-threads new file mode 100755 index 0000000000..1ed2d39bf4 --- /dev/null +++ b/scripts/agents/gator/bin/resolve-gator-review-threads @@ -0,0 +1,106 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +usage() { + cat >&2 <<'EOF' +Usage: + resolve-gator-review-threads LEDGER.json FINDING_ID [FINDING_ID ...] +EOF +} + +[[ "$#" -ge 2 && -r "$1" ]] || { + usage + exit 2 +} + +ledger="$1" +shift + +jq -e ' + .schema_version == 4 and + (.threads | type == "array") +' "$ledger" >/dev/null || { + echo "invalid gator review feedback ledger" >&2 + exit 2 +} + +mutation='mutation ResolveGatorReviewThread($threadId: ID!) { + resolveReviewThread(input: {threadId: $threadId}) { + thread { + id + isResolved + } + } +}' + +finding_ids=("$@") +thread_ids=() +resolution_states=() +declare -A seen_finding_ids=() + +# Validate the complete request before performing the first GitHub write so a +# malformed or ambiguous later ID cannot leave a partially resolved batch. +for finding_id in "${finding_ids[@]}"; do + [[ "$finding_id" =~ ^(GATOR-[0-9A-Fa-f]{8}-[0-9]{2}|gator-inline-[0-9]+)$ ]] || { + echo "invalid gator finding ID: $finding_id" >&2 + exit 2 + } + [[ -z "${seen_finding_ids[$finding_id]:-}" ]] || { + echo "duplicate gator finding ID: $finding_id" >&2 + exit 2 + } + seen_finding_ids["$finding_id"]=1 + + matching_threads="$(jq -c --arg finding_id "$finding_id" ' + [ + .threads[] + | select(.finding_id == $finding_id) + ] + ' "$ledger")" + match_count="$(jq 'length' <<< "$matching_threads")" + [[ "$match_count" -eq 1 ]] || { + echo "expected exactly one Gator thread for $finding_id; found $match_count" >&2 + exit 1 + } + + first_body="$(jq -r '.[0].comments[0].body // empty' <<< "$matching_threads")" + [[ "$first_body" == '> **gator-agent**'* ]] || { + echo "refusing to resolve non-Gator thread for $finding_id" >&2 + exit 1 + } + + thread_id="$(jq -r '.[0].thread_id // empty' <<< "$matching_threads")" + [[ -n "$thread_id" ]] || { + echo "Gator thread ID missing for $finding_id" >&2 + exit 1 + } + + thread_ids+=("$thread_id") + resolution_states+=("$(jq -r '.[0].is_resolved' <<< "$matching_threads")") +done + +for ((i = 0; i < ${#finding_ids[@]}; i++)); do + finding_id="${finding_ids[$i]}" + thread_id="${thread_ids[$i]}" + if [[ "${resolution_states[$i]}" == "true" ]]; then + echo "Gator review thread already resolved: $finding_id" + continue + fi + + response="$(gh api graphql \ + -f query="$mutation" \ + -f threadId="$thread_id")" + jq -e --arg thread_id "$thread_id" ' + .data.resolveReviewThread.thread + | .id == $thread_id and .isResolved == true + ' <<< "$response" >/dev/null || { + echo "GitHub did not confirm resolution for $finding_id ($thread_id)" >&2 + exit 1 + } + + echo "Resolved Gator review thread: $finding_id" +done diff --git a/scripts/agents/gator/bin/resolve_gator_review_threads_test.sh b/scripts/agents/gator/bin/resolve_gator_review_threads_test.sh new file mode 100755 index 0000000000..1e38947d9a --- /dev/null +++ b/scripts/agents/gator/bin/resolve_gator_review_threads_test.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GATOR_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +RESOLVER="$SCRIPT_DIR/resolve-gator-review-threads" + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/bin" + +cat > "$tmp/bin/gh" <<'SH' +#!/usr/bin/env bash +set -euo pipefail + +thread_id="" +previous="" +for arg in "$@"; do + if [[ "$previous" == "-f" && "$arg" == threadId=* ]]; then + thread_id="${arg#threadId=}" + fi + previous="$arg" +done + +printf '%s\n' "$*" >> "${MOCK_GH_LOG:?}" +if [[ "$thread_id" == "${MOCK_FAIL_THREAD:-}" ]]; then + jq -n --arg id "$thread_id" '{data: {resolveReviewThread: {thread: {id: $id, isResolved: false}}}}' +else + jq -n --arg id "$thread_id" '{data: {resolveReviewThread: {thread: {id: $id, isResolved: true}}}}' +fi +SH +chmod +x "$tmp/bin/gh" + +cat > "$tmp/ledger.json" <<'JSON' +{ + "schema_version": 4, + "threads": [ + { + "thread_id": "thread-open", + "finding_id": "GATOR-11111111-01", + "is_resolved": false, + "comments": [{"body": "> **gator-agent**\n\nOpen finding"}] + }, + { + "thread_id": "thread-resolved", + "finding_id": "GATOR-11111111-02", + "is_resolved": true, + "comments": [{"body": "> **gator-agent**\n\nResolved finding"}] + }, + { + "thread_id": "thread-legacy", + "finding_id": "gator-inline-1234", + "is_resolved": false, + "comments": [{"body": "> **gator-agent**\n\nLegacy finding"}] + }, + { + "thread_id": "thread-human", + "finding_id": "GATOR-22222222-01", + "is_resolved": false, + "comments": [{"body": "Human review thread"}] + } + ] +} +JSON + +export MOCK_GH_LOG="$tmp/gh.log" +PATH="$tmp/bin:$PATH" "$RESOLVER" "$tmp/ledger.json" \ + GATOR-11111111-01 GATOR-11111111-02 gator-inline-1234 \ + > "$tmp/success.out" + +[[ "$(grep -c 'threadId=thread-' "$tmp/gh.log")" -eq 2 ]] +grep -q 'ResolveGatorReviewThread' "$tmp/gh.log" +grep -q 'threadId=thread-open' "$tmp/gh.log" +grep -q 'threadId=thread-legacy' "$tmp/gh.log" +grep -q 'already resolved: GATOR-11111111-02' "$tmp/success.out" + +if PATH="$tmp/bin:$PATH" "$RESOLVER" "$tmp/ledger.json" GATOR-33333333-01 >/dev/null 2>&1; then + echo "expected unknown finding ID to fail" >&2 + exit 1 +fi + +if PATH="$tmp/bin:$PATH" "$RESOLVER" "$tmp/ledger.json" GATOR-22222222-01 >/dev/null 2>&1; then + echo "expected human-owned thread resolution to fail" >&2 + exit 1 +fi + +if MOCK_FAIL_THREAD=thread-open PATH="$tmp/bin:$PATH" \ + "$RESOLVER" "$tmp/ledger.json" GATOR-11111111-01 >/dev/null 2>&1; then + echo "expected unconfirmed GitHub resolution to fail" >&2 + exit 1 +fi + +ruby -ryaml -e ' + profile = YAML.load_file(ARGV.fetch(0)) + endpoint = profile.fetch("endpoints").find { + |entry| entry["path"] == "/graphql" + } + abort unless endpoint.fetch("rules").any? { + |rule| + allow = rule["allow"] + allow && + allow["operation_type"] == "mutation" && + allow["operation_name"] == "ResolveGatorReviewThread" && + allow["fields"] == ["resolveReviewThread"] + } +' "$GATOR_DIR/providers/github-gator.yaml" + +echo "resolve-gator-review-threads tests passed" diff --git a/scripts/agents/gator/bin/review_feedback_ledger_test.sh b/scripts/agents/gator/bin/review_feedback_ledger_test.sh index 4ac64ca95d..7c5708cc8a 100755 --- a/scripts/agents/gator/bin/review_feedback_ledger_test.sh +++ b/scripts/agents/gator/bin/review_feedback_ledger_test.sh @@ -156,7 +156,7 @@ cat > "$tmp/reviews.json" <<'JSON' "login": "drew" }, "author_association": "MEMBER", - "body": "> **gator-agent**\n\n## PR Review Status\n\nHead SHA: `1111111111111111111111111111111111111111`\nBase SHA: `aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`\nMerge base SHA: `bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb`\nPatch ID: `cccccccccccccccccccccccccccccccccccccccc`\nGator payload: `2`\n\nGeneral findings:\n- Finding ID: GATOR-11111111-01 — Keep package verification.", + "body": "> **gator-agent**\n\n## PR Review Status\n\nGeneral findings:\n- Finding ID: GATOR-11111111-01 — Keep package verification.\n\n
\nGator metadata\n\n- Head SHA: `1111111111111111111111111111111111111111`\n- Base SHA: `aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`\n- Merge base SHA: `bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb`\n- Patch ID: `cccccccccccccccccccccccccccccccccccccccc`\n- Gator payload: `2`\n\n
", "state": "COMMENTED", "submitted_at": "2026-07-28T19:53:23Z", "commit_id": "1111111111111111111111111111111111111111" @@ -342,12 +342,14 @@ if "$LEDGER" --input "$tmp/missing-pr.json" >/dev/null 2>&1; then fi rg -q 'COPY bin/review-feedback-ledger /usr/local/bin/review-feedback-ledger' \ - "$GATOR_DIR/Dockerfile" + "$GATOR_DIR/Dockerfile" +rg -q 'COPY bin/resolve-gator-review-threads /usr/local/bin/resolve-gator-review-threads' \ + "$GATOR_DIR/Dockerfile" rg -q 'COPY bin/validate-review-findings /usr/local/bin/validate-review-findings' \ - "$GATOR_DIR/Dockerfile" + "$GATOR_DIR/Dockerfile" ruby -ryaml -e ' manifest = YAML.load_file(ARGV.fetch(0)) - abort unless manifest.fetch("payload_version") == 4 + abort unless manifest.fetch("payload_version") == 7 resource = manifest.fetch("resources").find { |entry| entry.fetch("id") == "gator-review-findings-schema" } @@ -384,5 +386,13 @@ rg -q '`test_dispatch_required`' \ "$GATOR_DIR/skills/gator-gate/SKILL.md" rg -q 'attacker_or_operator_prerequisite' \ "$GATOR_DIR/skills/gator-gate/references/review-findings-schema.md" +rg -Fq 'Write `Summary` as natural prose that can be read aloud' \ + "$GATOR_DIR/skills/gator-gate/SKILL.md" +rg -Fq 'Agent context' \ + "$GATOR_DIR/skills/gator-gate/SKILL.md" +rg -Fq 'Gator metadata' \ + "$GATOR_DIR/skills/gator-gate/SKILL.md" +rg -Fq 'Those fields validate the finding;' \ + "$GATOR_DIR/skills/gator-gate/SKILL.md" printf 'PASS: gator review feedback ledger tests\n' diff --git a/scripts/agents/gator/prompts/gator.md b/scripts/agents/gator/prompts/gator.md index 96d8dff5ec..07055a29cc 100644 --- a/scripts/agents/gator/prompts/gator.md +++ b/scripts/agents/gator/prompts/gator.md @@ -11,7 +11,10 @@ Load and follow this skill exactly: Important sandbox constraints: - GitHub REST write access is scoped to NVIDIA/OpenShell and NVIDIA/OpenShell-Community. -- GitHub GraphQL access is read-only. Prefer REST endpoints for write actions and use GraphQL-backed `gh` reads when useful. +- GitHub GraphQL access is read-only except for the named + `ResolveGatorReviewThread` mutation used by + `resolve-gator-review-threads`. Prefer REST endpoints for all other write + actions and use GraphQL-backed `gh` reads when useful. - Keep watching active PRs until they close, merge, or the operator stops the sandbox. - At the start of every watch cycle, read `payload_version` from `scripts/agents/gator/agent.yaml` on the default branch through the GitHub diff --git a/scripts/agents/gator/providers/github-gator.yaml b/scripts/agents/gator/providers/github-gator.yaml index 654254971f..d0c1f32d49 100644 --- a/scripts/agents/gator/providers/github-gator.yaml +++ b/scripts/agents/gator/providers/github-gator.yaml @@ -65,6 +65,10 @@ endpoints: rules: - allow: operation_type: query + - allow: + operation_type: mutation + operation_name: ResolveGatorReviewThread + fields: [resolveReviewThread] - host: github.com port: 443 protocol: rest diff --git a/scripts/agents/gator/skills/gator-gate/SKILL.md b/scripts/agents/gator/skills/gator-gate/SKILL.md index c7a1dcab4b..99ab8304f5 100644 --- a/scripts/agents/gator/skills/gator-gate/SKILL.md +++ b/scripts/agents/gator/skills/gator-gate/SKILL.md @@ -138,6 +138,11 @@ Treat the ledger as required reviewer input, not optional background: - Carry every still-open finding forward as an existing obligation. Do not post a new thread or semantically equivalent general finding for it. - A Gator thread resolved by a verified maintainer is addressed. If the resolver is only the PR author, inspect the trusted reply and latest diff to decide whether the finding was fixed; resolution alone does not grant a non-maintainer author waiver authority. +- When a later commit demonstrably fixes an open inline Gator finding, resolve + that Gator-owned GitHub review thread as part of the same reconciliation + cycle. Also resolve it after a verified maintainer explicitly waives the + finding. Do not resolve a thread merely because it is outdated, the author + says it is fixed, or a new review omits it. - Preserve a verified maintainer's reply as the rationale. An explicit rejection such as "invalid", "intentional", "fine as implemented", or "won't fix" is a waiver, not an unanswered request. - An unresolved thread with an explicit verified-maintainer waiver is also waived. A non-maintainer author's disagreement remains context for review but does not override a maintainer-required change. - Preserve each `GATOR--` finding ID across later @@ -152,6 +157,33 @@ Treat the ledger as required reviewer input, not optional background: nonzero duplicate finding-ID count, a waived finding reappearing, or an unchanged-code proposal as a reviewer-quality signal, not an author defect. +### Resolve addressed Gator threads + +After checking the new diff and trusted replies, partition every open ledger +finding into still-open, fixed by the current head, or explicitly waived by a +verified maintainer. Before posting the current-head disposition or advancing +out of review, resolve each fixed or waived inline Gator thread by stable +finding ID: + +```bash +resolve-gator-review-threads \ + /tmp/gator-review-feedback-ledger.json \ + GATOR-- [...] +``` + +The resolver accepts only exact threads from the feedback ledger whose first +comment carries the Gator marker. Never resolve a human-owned review thread, a +still-open finding, or a thread whose disposition is ambiguous. General +findings and issue comments have no review thread to resolve; retain their +durable disposition in the ledger. + +Thread resolution is required housekeeping for an addressed inline finding, +not a new review disposition, and does not consume the one-disposition-per-head +SHA slot. If GitHub does not confirm every requested resolution, do not advance +the Gator state or post the follow-up disposition. Return +`OPENSHELL_AGENT_RESULT {"status":"transient_failure","next_poll_seconds":120,"reason":"review_thread_resolution_failed"}` +and retry the reconciliation later. + ## Labels There must be at most one `gator:*` label on an issue or PR at any time. @@ -746,10 +778,50 @@ Post findings using these rules: - Submit all inline comments for a head SHA together in one `COMMENT` review. The review summary plus its complete inline-comment batch is the single gator disposition for that SHA. - Begin the review summary and each inline body with `> **gator-agent**`. Put the current head SHA in the summary using the canonical `Head SHA: ` field. - Put the stable finding ID in every blocking summary item and inline comment. -- In each blocker, state reachability, impact, why the PR owns the problem, and - the proportionate requested change. Also state the prerequisite, supported - entry point and sink, base-vs-head behavior, and deterministic reproducer - from the validated evidence contract. +- Keep the normalized evidence contract in `review-findings.json`; do not copy + its field labels into the public comment. Render each new blocker as the + human-first card below. +- Write `Summary` as natural prose that can be read aloud to someone who has + not seen the code. In one compact paragraph, explain who encounters the + problem, what they do, what goes wrong, and the concrete consequence. Prefer + product concepts over internal identifiers unless an identifier is needed + to understand the issue. +- Write `Fix` as a proportionate imperative derived from `requested_change`. + Retain every operative constraint and required regression-test outcome. +- Write `Verify` as a deterministic setup, action, and current-versus-expected + observation derived from `reproducer`. Preserve identity and equality + qualifiers and distinguish concurrent calls, staged objects, and durable + state when those distinctions matter. +- Across the visible card and its agent context, preserve the operative facts + needed to understand, fix, and verify the blocker: exact trigger conditions, + actor or permission scope, supported-path qualifiers, causal direction, + observable impact, relevant identifiers, and negations. Never move a read, + transform, store, forward, reject, or delete operation to a neighboring + component for brevity. Do not infer missing facts. +- Do not expose `Invariant`, `Prerequisite`, `Entry point`, `Sink`, `Base`, + `Head`, or `PR ownership` as headings. Those fields validate the finding; + they are not the public explanation. +- Add collapsed `Agent context` after the visible card. Always include the + validated `changed_location` as `path:line`. Include the exact execution + path, PR ownership, minimal historical contrast, or concrete sibling sites + only when that information is not already clear above and helps an agent act + or establishes otherwise-unclear PR causality. Do not add empty rows or + duplicate the visible prose. Most findings need no historical comparison. +- Target roughly 130 total words and about half the legacy evidence-dump + length. Operative fidelity is the escape hatch: exceed the target rather + than make the defect, fix, or verification ambiguous. +- Put review-process bookkeeping at the end of the review summary in one + collapsed `
Gator metadata` block. This includes + validation provenance, docs and E2E disposition, check status, head/base/ + merge-base SHAs, patch ID, payload version, review mode, previous reviewed + SHA, review-budget state, maintainer-decision state, next Gator state, and a + machine-readable blocked reason. Keep the canonical `Head SHA: ` and + `Gator payload: ` text intact inside the block so the write guard + and feedback ledger can parse them. +- Do not hide an action a person must take. When blocked, approval-needed, or + awaiting clarification, put one plain-language `Action required:` sentence + in the visible summary. Keep the redundant state code and operational detail + in `Gator metadata`. - Use the review summary for blocking design concerns, missing tests, cross-file findings, and blockers that cannot be anchored because the relevant line is outside the mode-appropriate diff. For an unanchored @@ -770,13 +842,13 @@ Build the batch as one REST request. Verify every requested line appears in the { "commit_id": "", "event": "COMMENT", - "body": "> **gator-agent**\n\n## PR Review Status\n\nHead SHA: ``\nBase SHA: ``\nMerge base SHA: ``\nPatch ID: ``\nGator payload: ``\n\n", + "body": "> **gator-agent**\n\n## PR Review Status\n\n\n\n
\nGator metadata\n\n- Head SHA: ``\n- Base SHA: ``\n- Merge base SHA: ``\n- Patch ID: ``\n- Gator payload: ``\n\n
", "comments": [ { "path": "crates/example/src/lib.rs", "line": 123, "side": "RIGHT", - "body": "> **gator-agent**\n\n**Warning — GATOR-12345678-01**\n\nInvariant: \n\nPrerequisite: \n\nEntry point → sink: → \n\nBase → head: → \n\nImpact: \n\nReproducer: \n\nPR ownership: \n\nRequested change: " + "body": "> **gator-agent**\n\n**Warning — GATOR-12345678-01 · Authorization happens after lookup**\n\n**Summary:** A user who can request a workspace can make the server look up another workspace before checking access. That discloses whether the workspace exists.\n\n**Fix:** Restore the authorization check before the workspace lookup and cover cross-workspace requests.\n\n**Verify:** Request another workspace as an unauthorized user; the server must return 404 without performing the lookup.\n\n
\nAgent context\n\n- **Agent path:** `GET /workspaces/{name}` → workspace record lookup\n- **Ownership:** This PR reordered the authorization check and lookup.\n- **Location:** `server.rs:123`\n\n
" } ] } @@ -804,12 +876,14 @@ determine which Gator findings or trusted maintainer comments are still unanswered. Ignore unacknowledged third-party comments and reviews. If the PR author has pushed commits and `review_scope.mode` is `follow_up`, review only the unresolved obligations plus `..HEAD`, carrying all -other dispositions without duplicating them. If the author replied without -pushing a new commit, do not re-review, repost findings, or post a same-SHA -disposition; inspect the response internally and wait for a new commit or -maintainer override. If CI changes state without a new commit, do not post a -same-SHA CI update. A due TTL author nudge remains allowed when the unresolved -feedback still requires an author action. +other dispositions without duplicating them. Resolve the Gator-owned inline +threads for obligations that the follow-up confirms are fixed or explicitly +waived before posting the new-head disposition or advancing the label. If the +author replied without pushing a new commit, do not re-review, repost findings, +or post a same-SHA disposition; inspect the response internally and wait for a +new commit or maintainer override. If CI changes state without a new commit, do +not post a same-SHA CI update. A due TTL author nudge remains allowed when the +unresolved feedback still requires an author action. If review feedback is waiting on the PR author for more than 48 business hours, post a single author nudge. Use the latest of these timestamps as the TTL start: @@ -986,16 +1060,9 @@ Recommended next step: . ## PR Review Status -Validation: -Head SHA: `` -Base SHA: `` -Merge base SHA: `` -Patch ID: `` -Gator payload: `` -Review mode: `` -Previous reviewed SHA: `` -Review budget exhausted: `` -Maintainer decision required: `` + + +Action required: Blocking findings: - ``: @@ -1006,9 +1073,26 @@ Carried findings: Non-blocking suggestions: - -Docs: - -Next state: `` +
+Gator metadata + +- Validation: +- Docs: +- Checks: +- E2E: +- Head SHA: `` +- Base SHA: `` +- Merge base SHA: `` +- Patch ID: `` +- Gator payload: `` +- Review mode: `` +- Previous reviewed SHA: `` +- Review budget exhausted: `` +- Maintainer decision required: `` +- Next state: `` +- Blocked reason: `` + +
``` ### Maintainer Convergence Decision @@ -1018,12 +1102,6 @@ Next state: `` -Base SHA: `` -Merge base SHA: `` -Patch ID: `` -Gator payload: `` - The autonomous Warning budget is exhausted, and a specific maintainer decision is required before review can proceed. @@ -1039,8 +1117,18 @@ Reviewer-quality signals: Maintainer action: -Next state: `gator:blocked` -Blocked reason: `review_convergence_decision_required` +
+Gator metadata + +- Head SHA: `` +- Base SHA: `` +- Merge base SHA: `` +- Patch ID: `` +- Gator payload: `` +- Next state: `gator:blocked` +- Blocked reason: `review_convergence_decision_required` + +
``` ### Human Response Disposition @@ -1054,12 +1142,6 @@ Post this as a new comment after a substantive author, maintainer, or reviewer r Thanks . I re-evaluated latest head `` after your comment about . -Head SHA: `` -Base SHA: `` -Merge base SHA: `` -Patch ID: `` -Gator payload: `` - What I checked: . Disposition: . @@ -1067,7 +1149,20 @@ Disposition: -Next state: `` +Action required: + +
+Gator metadata + +- Head SHA: `` +- Base SHA: `` +- Merge base SHA: `` +- Patch ID: `` +- Gator payload: `` +- Next state: `` +- Blocked reason: `` + +
``` ### Approval Needed @@ -1079,13 +1174,20 @@ Next state: ` Review: -Docs: -Checks: -E2E: Human maintainer approval is now required. + +
+Gator metadata + +- Validation: +- Docs: +- Checks: +- E2E: +- Next state: `gator:approval-needed` + +
``` ### Merge Ready @@ -1097,14 +1199,21 @@ Human maintainer approval is now required. Gator validation and PR monitoring are complete, and maintainer approval is present. -Validation: Review: Approval: -Docs: -Checks: -E2E: Human maintainer merge or close decision is now required. + +
+Gator metadata + +- Validation: +- Docs: +- Checks: +- E2E: +- Next state: `gator:merge-ready` + +
``` ### Monitoring Complete diff --git a/scripts/agents/gator/skills/gator-gate/references/review-findings-schema.md b/scripts/agents/gator/skills/gator-gate/references/review-findings-schema.md index b9e267ae2e..b65f40f5be 100644 --- a/scripts/agents/gator/skills/gator-gate/references/review-findings-schema.md +++ b/scripts/agents/gator/skills/gator-gate/references/review-findings-schema.md @@ -60,6 +60,11 @@ Critical or Warning missing any evidence field becomes a non-blocking list sibling sites on the first finding instead. Suggestions always remain non-blocking. +These fields are Gator's internal evidence contract, not the public comment +format. After validation, render blockers using the human-first +`Summary`/`Fix`/`Verify` card and conditional `Agent context` defined in the +gator-gate skill. Do not expose the schema's evidence labels as prose headings. + For a finite family, put every known member in `sibling_sites` under one invariant and one finding ID. On later rounds, update that finding instead of creating a sibling finding. diff --git a/scripts/agents/run.sh b/scripts/agents/run.sh index 8c5fd6e1ae..4bd868ea3d 100755 --- a/scripts/agents/run.sh +++ b/scripts/agents/run.sh @@ -324,18 +324,51 @@ upsert_provider() { fi } +provider_exists() { + openshell_cmd provider get "$1" >/dev/null 2>&1 +} + import_provider_profile() { local profile_id="$1" local profile_file="$2" - local import_output + local import_output current_profile resource_version update_dir update_file openshell_cmd provider profile delete "$profile_id" >/dev/null 2>&1 || true if import_output="$(openshell_cmd provider profile import --file "$profile_file" 2>&1)"; then return 0 fi if [[ "$import_output" == *"already exists"* ]]; then - echo "Provider profile already exists: $profile_file" - return 0 + if ! current_profile="$(openshell_cmd provider profile export \ + --output json "$profile_id")"; then + echo "failed to export existing provider profile: $profile_id" >&2 + return 1 + fi + resource_version="$(printf '%s' "$current_profile" | \ + jq -r '.resource_version // 0')" + [[ "$resource_version" =~ ^[1-9][0-9]*$ ]] || { + echo "existing provider profile has no resource version: $profile_id" >&2 + return 1 + } + + update_dir="$(mktemp -d "${TMPDIR:-/tmp}/openshell-provider-profile-XXXXXX")" + update_file="$update_dir/profile.yaml" + ruby -ryaml - "$profile_file" "$resource_version" "$update_file" <<'RUBY' +profile_file, resource_version, update_file = ARGV +profile = YAML.load_file(profile_file) || {} +profile["resource_version"] = Integer(resource_version, 10) +File.write(update_file, YAML.dump(profile)) +RUBY + if openshell_cmd provider profile update "$profile_id" \ + --file "$update_file" >/dev/null; then + rm -f "$update_file" + rmdir "$update_dir" + echo "Updated provider profile: $profile_file" + return 0 + fi + rm -f "$update_file" + rmdir "$update_dir" + echo "failed to update existing provider profile: $profile_id" >&2 + return 1 fi printf '%s\n' "$import_output" >&2 @@ -670,6 +703,7 @@ for ((provider_index = 0; provider_index < PROVIDER_COUNT; provider_index++)); d mode_var="PROVIDER_${provider_index}_CREDENTIAL_MODE" credential_count_var="PROVIDER_${provider_index}_CREDENTIAL_COUNT" refresh_enabled_var="PROVIDER_${provider_index}_REFRESH_ENABLED" + refresh_key_var="PROVIDER_${provider_index}_REFRESH_CREDENTIAL_KEY" provider_name="${!name_var}" profile_id="${!profile_var}" credential_mode="${!mode_var}" @@ -700,18 +734,32 @@ for ((provider_index = 0; provider_index < PROVIDER_COUNT; provider_index++)); d fi done + provider_credential_args=() case "$credential_mode" in explicit) - upsert_provider "$provider_name" "$profile_id" "${credential_args[@]}" + provider_credential_args=("${credential_args[@]}") ;; from_existing) - upsert_provider "$provider_name" "$profile_id" --from-existing + provider_credential_args=(--from-existing) ;; *) fail "unsupported credential_mode for $provider_name: $credential_mode" ;; esac + if [[ "${!refresh_enabled_var}" == "true" ]] && provider_exists "$provider_name"; then + if [[ "$RESET_REFRESH" == "1" ]]; then + log "Resetting refresh-owned provider credential '$provider_name/${!refresh_key_var}'." + openshell_cmd provider refresh delete "$provider_name" \ + --credential-key "${!refresh_key_var}" >/dev/null + upsert_provider "$provider_name" "$profile_id" "${provider_credential_args[@]}" + else + log "Reusing provider '$provider_name' because its credentials are managed by gateway refresh." + fi + else + upsert_provider "$provider_name" "$profile_id" "${provider_credential_args[@]}" + fi + if [[ "${!refresh_enabled_var}" == "true" ]]; then log "Refreshing provider credential '$provider_name/${!refresh_key_var}'." configure_provider_refresh "$provider_index" @@ -737,7 +785,7 @@ case "$HARNESS" in ;; esac -SANDBOX_CMD=( +SANDBOX_CREATE_CMD=( env -u OPENSHELL_SANDBOX_POLICY "$OPENSHELL_BIN" --gateway "$GATEWAY" sandbox create --name "$SANDBOX_NAME" @@ -747,11 +795,35 @@ SANDBOX_CMD=( --no-git-ignore --no-auto-providers --no-tty + --detach ) -if [[ "$KEEP_SANDBOX" != "1" ]]; then - SANDBOX_CMD+=(--no-keep) -fi -SANDBOX_CMD+=(-- env "${HARNESS_ENV_ARGS[@]}" bash "$PAYLOAD_IMAGE_DIR/runtime/entrypoint.sh") + +SANDBOX_EXEC_CMD=( + "$OPENSHELL_BIN" --gateway "$GATEWAY" sandbox exec + --name "$SANDBOX_NAME" + --no-tty + -- + env + "${HARNESS_ENV_ARGS[@]}" + bash "$PAYLOAD_IMAGE_DIR/runtime/entrypoint.sh" +) + +run_agent_sandbox() { + local exec_status=0 + local cleanup_status=0 + + "${SANDBOX_CREATE_CMD[@]}" + "${SANDBOX_EXEC_CMD[@]}" || exec_status=$? + + if [[ "$KEEP_SANDBOX" != "1" ]]; then + openshell_cmd sandbox delete "$SANDBOX_NAME" >/dev/null || cleanup_status=$? + fi + + if [[ "$exec_status" -ne 0 ]]; then + return "$exec_status" + fi + return "$cleanup_status" +} log "Launching $AGENT_DISPLAY_NAME sandbox '$SANDBOX_NAME' on gateway '$GATEWAY'." if [[ "$BACKGROUND" == "1" ]]; then @@ -761,9 +833,9 @@ if [[ "$BACKGROUND" == "1" ]]; then trap - EXIT ( trap 'cleanup_config; cleanup_payload' EXIT - "${SANDBOX_CMD[@]}" + run_agent_sandbox ) >"$LOG_FILE" 2>&1 & echo "Started in background. Log: $LOG_FILE" else - "${SANDBOX_CMD[@]}" + run_agent_sandbox fi diff --git a/scripts/agents/runtime/harnesses/codex/exec.sh b/scripts/agents/runtime/harnesses/codex/exec.sh index a2eb5be909..ef92258795 100755 --- a/scripts/agents/runtime/harnesses/codex/exec.sh +++ b/scripts/agents/runtime/harnesses/codex/exec.sh @@ -26,7 +26,7 @@ export GH_NO_UPDATE_NOTIFIER=1 export GH_NO_EXTENSION_UPDATE_NOTIFIER=1 export GH_TELEMETRY=false export DO_NOT_TRACK=1 -export HOME=/sandbox/home +export HOME="${OPENSHELL_AGENT_HOME:-/sandbox/home}" echo "openshell-agent: preparing Codex harness auth and workspace" >&2 mkdir -p "$HOME/.codex" @@ -34,13 +34,9 @@ node - <<'NODE' const fs = require("fs"); const path = `${process.env.HOME}/.codex/auth.json`; const b64u = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url"); -const providerPlaceholder = (envName) => { - const value = process.env[envName]; - if (value && value.startsWith("openshell:resolve:env:")) { - return `openshell:resolve:env:${envName}`; - } - return value; -}; +// Preserve gateway-issued revision and stable-handle placeholders verbatim. +// Endpoint-bound credentials reject identityless aliases by design. +const providerValue = (envName) => process.env[envName]; const now = Math.floor(Date.now() / 1000); const fallbackIdToken = [ b64u({ alg: "none", typ: "JWT" }), @@ -59,10 +55,10 @@ fs.writeFileSync(path, JSON.stringify({ auth_mode: "chatgpt", OPENAI_API_KEY: null, tokens: { - id_token: providerPlaceholder("CODEX_AUTH_ID_TOKEN") || fallbackIdToken, - access_token: providerPlaceholder("CODEX_AUTH_ACCESS_TOKEN"), - refresh_token: providerPlaceholder("CODEX_AUTH_REFRESH_TOKEN") || "gateway-managed-refresh-token", - account_id: providerPlaceholder("CODEX_AUTH_ACCOUNT_ID"), + id_token: providerValue("CODEX_AUTH_ID_TOKEN") || fallbackIdToken, + access_token: providerValue("CODEX_AUTH_ACCESS_TOKEN"), + refresh_token: providerValue("CODEX_AUTH_REFRESH_TOKEN") || "gateway-managed-refresh-token", + account_id: providerValue("CODEX_AUTH_ACCOUNT_ID"), }, last_refresh: new Date().toISOString(), }, null, 2)); diff --git a/scripts/agents/runtime/harnesses/codex/exec_test.sh b/scripts/agents/runtime/harnesses/codex/exec_test.sh new file mode 100755 index 0000000000..6f1b998217 --- /dev/null +++ b/scripts/agents/runtime/harnesses/codex/exec_test.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +ACCESS_PLACEHOLDER="openshell:resolve:env:s$(printf 'a%.0s' {1..64})_CODEX_AUTH_ACCESS_TOKEN" +ACCOUNT_PLACEHOLDER="openshell:resolve:env:v42_CODEX_AUTH_ACCOUNT_ID" + +cat > "$TMP_DIR/codex" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${1:-}" == "exec" && "${2:-}" == "--help" ]]; then + exit 0 +fi + +access="$(jq -r '.tokens.access_token' "$HOME/.codex/auth.json")" +account="$(jq -r '.tokens.account_id' "$HOME/.codex/auth.json")" +[[ "$access" == "$EXPECTED_ACCESS_PLACEHOLDER" ]] +[[ "$account" == "$EXPECTED_ACCOUNT_PLACEHOLDER" ]] +printf '%s\n' 'ok - Codex auth preserves opaque provider placeholders' +MOCK +chmod +x "$TMP_DIR/codex" +printf '%s\n' 'test prompt' > "$TMP_DIR/prompt.md" + +OPENSHELL_AGENT_HOME="$TMP_DIR/home" \ +CODEX_BIN="$TMP_DIR/codex" \ +CODEX_AUTH_ACCESS_TOKEN="$ACCESS_PLACEHOLDER" \ +CODEX_AUTH_ACCOUNT_ID="$ACCOUNT_PLACEHOLDER" \ +GITHUB_TOKEN="openshell:resolve:env:v42_GITHUB_TOKEN" \ +EXPECTED_ACCESS_PLACEHOLDER="$ACCESS_PLACEHOLDER" \ +EXPECTED_ACCOUNT_PLACEHOLDER="$ACCOUNT_PLACEHOLDER" \ + bash "$SCRIPT_DIR/exec.sh" "$TMP_DIR/prompt.md" From 60a9b4dc62ff08aabb75cb5ce63018b5dcb8a83f Mon Sep 17 00:00:00 2001 From: Polite_realism <119441270+politerealism@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:07:10 +0000 Subject: [PATCH 020/505] fix(ssh): add EMFILE backoff and exit notification to SSH accept loop (#2705) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the same two-layer defense from the proxy accept loop (#2369/#2370) to the SSH accept loop: classify transient vs terminal accept errors with exponential backoff on EMFILE/resource-exhaustion, and notify the sandbox when the accept loop exits so the container terminates instead of running without SSH access. - Add SshAcceptAction enum and classify_ssh_accept_error in ssh.rs, mirroring the proxy pattern (EMFILE/ENFILE/ENOBUFS → Retry with backoff, unknown errors → Terminal after 10 consecutive failures) - Replace the bare accept().await in run_ssh_server with a classify-and- retry loop; resets consecutive-error counter on each successful accept - Thread ssh_exit_tx: Option> through run_process; hold it as a drop-guard inside the SSH spawn so the receiver fires when the task ends for any reason - Wire ssh_exited future in lib.rs (created only when ssh_socket_path is Some) and select! on it in both process_enabled paths, returning an error so the sandbox container restarts Closes #2372 Signed-off-by: politerealism --- crates/openshell-sandbox/src/lib.rs | 46 ++++ .../openshell-supervisor-process/src/run.rs | 2 + .../openshell-supervisor-process/src/ssh.rs | 216 +++++++++++++++--- 3 files changed, 228 insertions(+), 36 deletions(-) diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index b4c2cfd9dd..b1c226cebd 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -852,6 +852,21 @@ pub async fn run_sandbox( } }); + let (ssh_exit_tx, ssh_exit_rx) = if ssh_socket_path.is_some() { + let (tx, rx) = tokio::sync::oneshot::channel::<()>(); + (Some(tx), Some(rx)) + } else { + (None, None) + }; + let ssh_exited: Pin + Send>> = if let Some(rx) = ssh_exit_rx { + Box::pin(async { + let _ = rx.await; + }) + } else { + Box::pin(std::future::pending()) + }; + tokio::pin!(ssh_exited); + let entrypoint_started_tx = if process_uses_sidecar_control && let Some(writer) = process_control_writer.clone() { let (tx, rx) = tokio::sync::oneshot::channel(); @@ -914,6 +929,7 @@ pub async fn run_sandbox( openshell_endpoint.as_deref(), ssh_socket_path, sidecar_network_enforcement, + ssh_exit_tx, &process_policy, resolved_process_identity, process_enforcement_mode, @@ -965,6 +981,21 @@ pub async fn run_sandbox( "proxy accept loop exited unexpectedly" )); } + () = &mut ssh_exited => { + ocsf_emit!( + AppLifecycleBuilder::new(ocsf_ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::High) + .status(StatusId::Failure) + .message( + "SSH accept loop exited unexpectedly; terminating sandbox" + ) + .build() + ); + return Err(miette::miette!( + "SSH accept loop exited unexpectedly" + )); + } } } else { tokio::select! { @@ -984,6 +1015,21 @@ pub async fn run_sandbox( "proxy accept loop exited unexpectedly" )); } + () = &mut ssh_exited => { + ocsf_emit!( + AppLifecycleBuilder::new(ocsf_ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::High) + .status(StatusId::Failure) + .message( + "SSH accept loop exited unexpectedly; terminating sandbox" + ) + .build() + ); + return Err(miette::miette!( + "SSH accept loop exited unexpectedly" + )); + } } } } else { diff --git a/crates/openshell-supervisor-process/src/run.rs b/crates/openshell-supervisor-process/src/run.rs index 8a2080f217..f0e792306d 100644 --- a/crates/openshell-supervisor-process/src/run.rs +++ b/crates/openshell-supervisor-process/src/run.rs @@ -67,6 +67,7 @@ pub async fn run_process( openshell_endpoint: Option<&str>, ssh_socket_path: Option, shared_ssh_socket: bool, + ssh_exit_tx: Option>, policy: &SandboxPolicy, resolved_process_identity: ResolvedProcessIdentity, enforcement_mode: ProcessEnforcementMode, @@ -292,6 +293,7 @@ pub async fn run_process( let (ssh_ready_tx, ssh_ready_rx) = tokio::sync::oneshot::channel(); tokio::spawn(async move { + let _ssh_exit_guard = ssh_exit_tx; if let Err(err) = crate::ssh::run_ssh_server( listen_path, ssh_ready_tx, diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index a56ab8f2d7..893967b2ac 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -12,6 +12,8 @@ use crate::process::{ drop_privileges_with_identity, is_supervisor_only_env_var, session_user_and_home, }; use crate::sandbox; +#[cfg(unix)] +use libc; use miette::{IntoDiagnostic, Result}; use nix::pty::{Winsize, openpty}; use nix::unistd::setsid; @@ -143,44 +145,186 @@ pub async fn run_ssh_server( } }; - loop { - let (stream, _peer) = listener.accept().await.into_diagnostic()?; - let config = config.clone(); - let policy = policy.clone(); - let workspace = workspace.clone(); - let proxy_url = proxy_url.clone(); - let ca_paths = ca_paths.clone(); - let provider_credentials = provider_credentials.clone(); - let user_environment = user_environment.clone(); - let main_session = Arc::clone(&main_session); + let mut consecutive_resource_errors: u32 = 0; + let mut consecutive_unknown_errors: u32 = 0; - tokio::spawn(async move { - if let Err(err) = handle_connection( - stream, - config, - policy, - workspace, - netns_fd, - proxy_url, - ca_paths, - provider_credentials, - user_environment, - resolved_identity, - enforcement_mode, - main_session, - ) - .await - { - ocsf_emit!( - SshActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::Low) - .status(StatusId::Failure) - .message(format!("SSH connection failed: {err}")) - .build() - ); + loop { + match listener.accept().await { + Ok((stream, _peer)) => { + consecutive_resource_errors = 0; + consecutive_unknown_errors = 0; + let config = config.clone(); + let policy = policy.clone(); + let workspace = workspace.clone(); + let proxy_url = proxy_url.clone(); + let ca_paths = ca_paths.clone(); + let provider_credentials = provider_credentials.clone(); + let user_environment = user_environment.clone(); + let main_session = Arc::clone(&main_session); + + tokio::spawn(async move { + if let Err(err) = handle_connection( + stream, + config, + policy, + workspace, + netns_fd, + proxy_url, + ca_paths, + provider_credentials, + user_environment, + resolved_identity, + enforcement_mode, + main_session, + ) + .await + { + ocsf_emit!( + SshActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::Low) + .status(StatusId::Failure) + .message(format!("SSH connection failed: {err}")) + .build() + ); + } + }); } - }); + Err(err) => { + match classify_ssh_accept_error( + &err, + &mut consecutive_resource_errors, + &mut consecutive_unknown_errors, + ) { + SshAcceptAction::Terminal => { + ocsf_emit!( + SshActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::High) + .status(StatusId::Failure) + .message(format!( + "SSH accept loop exiting on terminal error: {err}" + )) + .build() + ); + break; + } + SshAcceptAction::Retry { backoff, severity } => { + ocsf_emit!( + SshActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(severity) + .status(StatusId::Failure) + .message(format!( + "SSH accept error (retrying in {}ms): {err}", + backoff.as_millis(), + )) + .build() + ); + tokio::time::sleep(backoff).await; + } + } + } + } + } + + Ok(()) +} + +const MAX_CONSECUTIVE_UNKNOWN_SSH_ACCEPT_ERRORS: u32 = 10; + +#[derive(Debug, PartialEq)] +enum SshAcceptAction { + Terminal, + Retry { + backoff: Duration, + severity: SeverityId, + }, +} + +fn classify_ssh_accept_error( + err: &std::io::Error, + consecutive_resource_errors: &mut u32, + consecutive_unknown_errors: &mut u32, +) -> SshAcceptAction { + #[cfg(unix)] + if matches!( + err.raw_os_error(), + Some(libc::EBADF | libc::EINVAL | libc::ENOTSOCK) + ) { + return SshAcceptAction::Terminal; + } + + #[cfg(unix)] + if matches!( + err.raw_os_error(), + Some( + libc::EMFILE + | libc::ENFILE + | libc::ENOBUFS + | libc::ENOMEM + | libc::ECONNABORTED + | libc::ECONNRESET + | libc::EINTR + | libc::ENETDOWN + | libc::EPROTO + | libc::ENOPROTOOPT + | libc::EHOSTDOWN + | libc::EHOSTUNREACH + | libc::EOPNOTSUPP + | libc::ENETUNREACH + | libc::ENOSR + | libc::ESOCKTNOSUPPORT + | libc::EPROTONOSUPPORT + | libc::ETIMEDOUT + ) + ) { + *consecutive_unknown_errors = 0; + + #[cfg(unix)] + let is_resource_pressure = matches!( + err.raw_os_error(), + Some(libc::EMFILE | libc::ENFILE | libc::ENOBUFS | libc::ENOMEM | libc::ENOSR) + ); + #[cfg(not(unix))] + let is_resource_pressure = false; + + if is_resource_pressure { + *consecutive_resource_errors = consecutive_resource_errors.saturating_add(1); + let backoff_ms = 100u64 + .saturating_mul(1u64 << (*consecutive_resource_errors).min(7).saturating_sub(1)) + .min(5_000); + return SshAcceptAction::Retry { + backoff: Duration::from_millis(backoff_ms), + severity: SeverityId::Medium, + }; + } + + *consecutive_resource_errors = 0; + return SshAcceptAction::Retry { + backoff: Duration::from_millis(100), + severity: SeverityId::Low, + }; + } + + #[cfg(unix)] + #[cfg(target_os = "linux")] + if matches!(err.raw_os_error(), Some(libc::ENONET)) { + *consecutive_unknown_errors = 0; + *consecutive_resource_errors = 0; + return SshAcceptAction::Retry { + backoff: Duration::from_millis(100), + severity: SeverityId::Low, + }; + } + + *consecutive_unknown_errors = consecutive_unknown_errors.saturating_add(1); + if *consecutive_unknown_errors >= MAX_CONSECUTIVE_UNKNOWN_SSH_ACCEPT_ERRORS { + return SshAcceptAction::Terminal; + } + SshAcceptAction::Retry { + backoff: Duration::from_millis(100), + severity: SeverityId::Low, } } From a715a90db58539cac6877c15b946e84045078576 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Wed, 26 Aug 2026 04:54:52 +0000 Subject: [PATCH 021/505] fix(vm): bump gvproxy to v0.8.9 (#2901) Closes #2895 Signed-off-by: Evan Lezar --- crates/openshell-driver-vm/runtime/pins.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openshell-driver-vm/runtime/pins.env b/crates/openshell-driver-vm/runtime/pins.env index d05c66e839..34a9f0bf33 100644 --- a/crates/openshell-driver-vm/runtime/pins.env +++ b/crates/openshell-driver-vm/runtime/pins.env @@ -31,7 +31,7 @@ COMMUNITY_SANDBOX_IMAGE="${COMMUNITY_SANDBOX_IMAGE:-ghcr.io/nvidia/openshell-com # ── gvproxy (networking proxy) ────────────────────────────────────────── # Repo: https://github.com/containers/gvisor-tap-vsock -GVPROXY_VERSION="${GVPROXY_VERSION:-v0.8.8}" +GVPROXY_VERSION="${GVPROXY_VERSION:-v0.8.9}" # ── umoci (guest OCI unpacker) ────────────────────────────────────────── # Repo: https://github.com/opencontainers/umoci From 4e992093f87b7b3262da5b3b29a0fb9c37f330b5 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Wed, 26 Aug 2026 06:27:21 +0000 Subject: [PATCH 022/505] fix(docker): trace standalone driver over OTLP (#2923) Signed-off-by: Evan Lezar --- Cargo.lock | 2 + crates/openshell-driver-docker/Cargo.toml | 3 + crates/openshell-driver-docker/README.md | 5 + crates/openshell-driver-docker/src/main.rs | 37 +++++-- .../src/otel_tracing.rs | 97 ++++++++++++++++++- docs/reference/sandbox-compute-drivers.mdx | 4 + 6 files changed, 139 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3ae582a12a..65a50f0f39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3946,6 +3946,7 @@ dependencies = [ "bytes", "clap", "futures", + "http 1.4.0", "miette", "openshell-core", "openshell-otel", @@ -3962,6 +3963,7 @@ dependencies = [ "tokio-stream", "toml", "tonic", + "tower-http 0.6.8", "tracing", "tracing-opentelemetry", "tracing-subscriber", diff --git a/crates/openshell-driver-docker/Cargo.toml b/crates/openshell-driver-docker/Cargo.toml index 1c9e675f77..7a5fa3fe3d 100644 --- a/crates/openshell-driver-docker/Cargo.toml +++ b/crates/openshell-driver-docker/Cargo.toml @@ -19,6 +19,7 @@ openshell-core = { path = "../openshell-core", default-features = false, feature openshell-otel = { path = "../openshell-otel" } opentelemetry = { workspace = true } +opentelemetry_sdk = { workspace = true } tracing-opentelemetry = { workspace = true } tokio = { workspace = true } tonic = { workspace = true, features = ["transport"] } @@ -35,6 +36,8 @@ url = { workspace = true } clap = { workspace = true } miette = { workspace = true } toml = { workspace = true } +tower-http = { workspace = true } +http = { workspace = true } [dev-dependencies] openshell-otel-test-support = { path = "../openshell-otel-test-support" } diff --git a/crates/openshell-driver-docker/README.md b/crates/openshell-driver-docker/README.md index 17ae1cfa03..9b9657e94d 100644 --- a/crates/openshell-driver-docker/README.md +++ b/crates/openshell-driver-docker/README.md @@ -8,6 +8,11 @@ spans export to the same OTLP/gRPC collector with the service name context and emits the compute-driver RPC boundary that a standalone driver would expose. +The standalone `openshell-driver-docker` binary accepts +`OPENSHELL_OTLP_ENDPOINT`. When set, it exports Docker driver spans to that +collector, continues W3C trace context from gateway RPC metadata, and flushes +spans during graceful shutdown. + The driver manages sandbox containers through the local Docker daemon with the `bollard` client. It is intended for developer environments where Docker is already available and running Kubernetes would be unnecessary. diff --git a/crates/openshell-driver-docker/src/main.rs b/crates/openshell-driver-docker/src/main.rs index 3c539ba11b..80a6353d31 100644 --- a/crates/openshell-driver-docker/src/main.rs +++ b/crates/openshell-driver-docker/src/main.rs @@ -8,9 +8,11 @@ use clap::Parser; use miette::{IntoDiagnostic, Result}; use openshell_core::proto::compute::v1::compute_driver_server::ComputeDriverServer; use openshell_core::{Config, VERSION}; -use openshell_driver_docker::{DockerComputeConfig, DockerComputeDriver}; +use openshell_driver_docker::otel_tracing::compute_driver_rpc_layer; +use openshell_driver_docker::{ComputeDriverService, DockerComputeConfig, DockerComputeDriver}; use tracing::info; use tracing_subscriber::EnvFilter; +use tracing_subscriber::prelude::*; #[derive(Debug, Parser)] #[command(name = "openshell-driver-docker", version = VERSION)] @@ -33,16 +35,30 @@ struct Args { #[arg(long, env = "OPENSHELL_LOG_LEVEL", default_value = "info")] log_level: String, + + #[arg(long, env = "OPENSHELL_OTLP_ENDPOINT")] + otlp_endpoint: Option, } #[tokio::main] async fn main() -> Result<()> { let args = Args::parse(); - tracing_subscriber::fmt() - .with_env_filter( - EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level)), + let (tracer_provider, setup_error) = + openshell_driver_docker::otel_tracing::provider_for(args.otlp_endpoint.as_deref()); + tracing_subscriber::registry() + .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level))) + .with(tracing_subscriber::fmt::layer()) + .with( + tracer_provider + .as_ref() + .map(openshell_driver_docker::otel_tracing::layer), ) .init(); + if let Some(error) = setup_error { + tracing::error!(%error, "OTLP exporting could not be started"); + } else if let Some(endpoint) = &args.otlp_endpoint { + info!(endpoint, "OTLP exporting enabled"); + } let config_source = std::fs::read_to_string(&args.config).into_diagnostic()?; let docker_config: DockerComputeConfig = toml::from_str(&config_source).into_diagnostic()?; @@ -56,14 +72,21 @@ async fn main() -> Result<()> { let _cleanup = openshell_core::external_driver_socket::SocketCleanup::new(args.bind_socket.clone()); info!(socket = %args.bind_socket.display(), "Starting Docker compute driver"); - tonic::transport::Server::builder() - .add_service(ComputeDriverServer::new(driver)) + let result = tonic::transport::Server::builder() + .layer(compute_driver_rpc_layer()) + .add_service(ComputeDriverServer::new(ComputeDriverService::new(driver))) .serve_with_incoming_shutdown( openshell_core::external_driver_socket::SameUidUnixIncoming::new(listener), shutdown_signal(), ) .await - .into_diagnostic() + .into_diagnostic(); + if let Some(provider) = &tracer_provider + && let Err(error) = provider.shutdown() + { + tracing::warn!(%error, "OTLP tracer provider shutdown failed"); + } + result } async fn shutdown_signal() { diff --git a/crates/openshell-driver-docker/src/otel_tracing.rs b/crates/openshell-driver-docker/src/otel_tracing.rs index 9c388b5f04..f232fc1e7b 100644 --- a/crates/openshell-driver-docker/src/otel_tracing.rs +++ b/crates/openshell-driver-docker/src/otel_tracing.rs @@ -3,14 +3,93 @@ //! OpenTelemetry trace exporting for the Docker compute driver. -use openshell_otel::{OtlpTraceConfig, SdkTracerProvider, ServiceName, SetupError}; -use tracing::Subscriber; +use http::Request; +use openshell_otel::{ + HeaderMapExtractor, OtlpTraceConfig, RecordGrpcFailure, RecordGrpcStatus, SdkTracerProvider, + ServiceName, SetupError, +}; +use opentelemetry::propagation::TextMapPropagator as _; +use opentelemetry::trace::TraceContextExt as _; +use opentelemetry_sdk::propagation::TraceContextPropagator; +use tower_http::trace::{GrpcMakeClassifier, MakeSpan, TraceLayer}; +use tracing::{Span, Subscriber}; +use tracing_opentelemetry::OpenTelemetrySpanExt as _; use tracing_subscriber::registry::LookupSpan; const SERVICE_NAME: &str = "openshell-driver-docker"; const INSTRUMENTATION_SCOPE: &str = "openshell-driver-docker"; +const COMPUTE_DRIVER_SERVICE: &str = "openshell.compute.v1.ComputeDriver"; pub const IN_PROCESS_TARGET_PREFIX: &str = "openshell_driver_docker"; +/// Trace inbound standalone compute-driver RPCs and continue gateway context. +pub fn compute_driver_rpc_layer() -> TraceLayer< + GrpcMakeClassifier, + ComputeDriverRpcSpan, + (), + RecordGrpcStatus, + (), + RecordGrpcStatus, + RecordGrpcFailure, +> { + TraceLayer::new_for_grpc() + .make_span_with(ComputeDriverRpcSpan) + .on_request(()) + .on_response(RecordGrpcStatus) + .on_body_chunk(()) + .on_eos(RecordGrpcStatus) + .on_failure(RecordGrpcFailure) +} + +#[derive(Debug, Clone, Copy)] +pub struct ComputeDriverRpcSpan; + +impl MakeSpan for ComputeDriverRpcSpan { + fn make_span(&mut self, request: &Request) -> Span { + let (operation, method) = compute_driver_rpc_operation(request.uri().path()); + let span = tracing::info_span!( + "driver_rpc", + otel.name = operation, + otel.kind = "server", + otel.status_code = tracing::field::Empty, + rpc.system = "grpc", + rpc.service = COMPUTE_DRIVER_SERVICE, + rpc.method = method, + rpc.grpc.status_code = tracing::field::Empty, + ); + let parent = TraceContextPropagator::new().extract_with_context( + &opentelemetry::Context::new(), + &HeaderMapExtractor::new(request.headers()), + ); + if parent.span().span_context().is_valid() { + let _ = span.set_parent(parent); + } + span + } +} + +pub(crate) fn compute_driver_rpc_operation(path: &str) -> (&'static str, &'static str) { + match path.rsplit('/').next() { + Some("GetCapabilities") => ("driver.get_capabilities", "get_capabilities"), + Some("GetGatewayListenerRequirements") => ( + "driver.get_gateway_listener_requirements", + "get_gateway_listener_requirements", + ), + Some("ValidateSandboxCreate") => { + ("driver.validate_sandbox_create", "validate_sandbox_create") + } + Some("CreateSandbox") => ("driver.create_sandbox", "create_sandbox"), + Some("GetSandbox") => ("driver.get_sandbox", "get_sandbox"), + Some("ListSandboxes") => ("driver.list_sandboxes", "list_sandboxes"), + Some("StopSandbox") => ("driver.stop_sandbox", "stop_sandbox"), + Some("StartSandbox") => ("driver.start_sandbox", "start_sandbox"), + Some("DeleteSandbox") => ("driver.delete_sandbox", "delete_sandbox"), + Some("WatchSandboxes") => ("driver.watch_sandboxes", "watch_sandboxes"), + Some("EnsureWorkspace") => ("driver.ensure_workspace", "ensure_workspace"), + Some("DeleteWorkspace") => ("driver.delete_workspace", "delete_workspace"), + _ => ("driver.unknown", "unknown"), + } +} + #[must_use] pub fn provider_for(endpoint: Option<&str>) -> (Option, Option) { openshell_otel::provider_for(endpoint.map(|endpoint| OtlpTraceConfig { @@ -57,6 +136,20 @@ mod tests { use openshell_otel_test_support::OtlpTestServer; use tracing_subscriber::layer::SubscriberExt as _; + #[test] + fn compute_driver_rpc_names_are_explicitly_mapped_and_schema_bounded() { + assert_eq!( + super::compute_driver_rpc_operation( + "/openshell.compute.v1.ComputeDriver/CreateSandbox" + ), + ("driver.create_sandbox", "create_sandbox") + ); + assert_eq!( + super::compute_driver_rpc_operation("/openshell.compute.v1.ComputeDriver/FutureMethod"), + ("driver.unknown", "unknown") + ); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tracing_docker_driver_spans_reach_otlp_collector_with_distinct_service_name() { let _tracing_lock = super::test_lock().await; diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index 656ae43bb6..5773fb2bb1 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -153,6 +153,10 @@ For maintainer-level implementation details, refer to the [Docker driver README] Select Docker with `compute_drivers = ["docker"]` in `[openshell.gateway]`. Configure Docker driver values such as `socket_path`, `grpc_endpoint`, `network_name`, `supervisor_bin`, `supervisor_image`, `image_pull_policy`, `ssh_socket_path`, `sandbox_pids_limit`, and `guest_tls_*` in `[openshell.drivers.docker]`. When `socket_path` is unset, the driver uses the same responsive local socket selected by auto-detection. An explicitly selected Docker driver falls back to `/var/run/docker.sock` when no candidate responds. +When operating `openshell-driver-docker` as an external driver, set +`OPENSHELL_OTLP_ENDPOINT` to export its spans. The driver continues W3C trace +context from gateway RPCs and reports as `openshell-driver-docker`. + Stop stops the existing Docker container without removing its writable layer or attached volumes. Start starts that same container. A durably stopped container stays stopped across gateway restart, and delete remains From 8be8b62ab58e42ee08213494b0d556ebc829e5de Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Wed, 26 Aug 2026 06:31:42 +0000 Subject: [PATCH 023/505] ci(vouch): close approved request discussions (#2929) Signed-off-by: Evan Lezar --- .github/DISCUSSION_TEMPLATE/vouch-request.yml | 2 +- .github/workflows/vouch-command.yml | 15 ++++++++++++++- CONTRIBUTING.md | 3 ++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/DISCUSSION_TEMPLATE/vouch-request.yml b/.github/DISCUSSION_TEMPLATE/vouch-request.yml index 58d98e300c..b5ee84de53 100644 --- a/.github/DISCUSSION_TEMPLATE/vouch-request.yml +++ b/.github/DISCUSSION_TEMPLATE/vouch-request.yml @@ -8,7 +8,7 @@ body: OpenShell uses a vouch system for first-time contributors. Fill out this form to request approval. A maintainer will review and comment `/vouch` - if approved. + if approved. Approved requests are closed automatically. **Write in your own words.** Do not have an AI generate this request. Requests that read like LLM output will be denied. diff --git a/.github/workflows/vouch-command.yml b/.github/workflows/vouch-command.yml index 366dd6a0e3..2c258e8e13 100644 --- a/.github/workflows/vouch-command.yml +++ b/.github/workflows/vouch-command.yml @@ -53,6 +53,16 @@ jobs: await github.graphql(mutation, { discussionId, body }); } + async function closeDiscussion() { + const discussionId = await getDiscussionId(); + const mutation = `mutation($discussionId: ID!) { + closeDiscussion(input: {discussionId: $discussionId}) { + discussion { id } + } + }`; + await github.graphql(mutation, { discussionId }); + } + // --- Authorization --- let isMaintainer = false; @@ -134,6 +144,7 @@ jobs: await postDiscussionComment( `@${discussionAuthor} is already vouched. They can submit pull requests.` ); + await closeDiscussion(); return; } @@ -185,4 +196,6 @@ jobs: 'Please read [CONTRIBUTING.md](https://github.com/NVIDIA/OpenShell/blob/main/CONTRIBUTING.md) before submitting.', ].join('\n')); - console.log(`Vouched ${discussionAuthor} (approved by ${commenter}).`); + await closeDiscussion(); + + console.log(`Vouched ${discussionAuthor} (approved by ${commenter}) and closed the discussion.`); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9d43e1f4f6..8b33c58fae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,8 @@ We use a vouch system. This exists because AI makes it trivial to generate plaus 1. Open a [Vouch Request](https://github.com/NVIDIA/OpenShell/discussions/new?category=vouch-request) discussion. 2. Describe what you want to change and why. 3. Write in your own words. AI-generated vouch requests will be denied. -4. A maintainer will comment `/vouch` if approved. +4. A maintainer will comment `/vouch` if approved, and the request discussion + will close automatically. 5. Once vouched, you can submit pull requests. **If you are not vouched, any pull request you open will be automatically closed.** Org members and collaborators with push access bypass this check. From 8d16a59ea341a40732bcba88bd872a7bdea96e9d Mon Sep 17 00:00:00 2001 From: Simon Scatton <44714756+SDAChess@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:09:00 +0000 Subject: [PATCH 024/505] ci(branch-checks): run Rust checks in Nix shells (#2876) * ci(branch-checks): run Rust checks in Nix shells Signed-off-by: Simon Scatton * ci(branch-checks): run Rust tests with nextest Signed-off-by: Simon Scatton * ci(branch-checks): cache Rust workspace artifacts Signed-off-by: Simon Scatton * ci(branch-checks): run cargo-deny in Nix shell Signed-off-by: Simon Scatton --------- Signed-off-by: Simon Scatton --- .config/nextest.toml | 8 ++ .github/workflows/branch-checks.yml | 141 ++++++++++------------------ flake.nix | 2 + 3 files changed, 60 insertions(+), 91 deletions(-) create mode 100644 .config/nextest.toml diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000000..43783c18e9 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[profile.ci] +fail-fast = false +status-level = "fail" +final-status-level = "fail" +failure-output = "immediate-final" diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index bece5c8825..2d9dc9914f 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -84,129 +84,88 @@ jobs: needs: pr_metadata if: needs.pr_metadata.outputs.should_run == 'true' runs-on: linux-amd64-cpu8 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + defaults: + run: + shell: nix develop .#devShells.x86_64-linux.default -c bash -euo pipefail {0} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install tools - run: mise install --locked + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 + with: + name: openshell + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} - name: Check dependencies - run: mise run rust:deny:policy + run: cargo deny check licenses bans sources rust: - name: Rust (${{ matrix.runner }}) + name: Rust (${{ matrix.system }}) needs: pr_metadata if: needs.pr_metadata.outputs.should_run == 'true' strategy: fail-fast: false matrix: - runner: [linux-amd64-cpu8, linux-arm64-cpu8] + include: + - runner: linux-amd64-cpu8 + system: x86_64-linux + - runner: linux-arm64-cpu8 + system: aarch64-linux + - runner: macos-15-xlarge + system: aarch64-darwin runs-on: ${{ matrix.runner }} - env: - SCCACHE_GHA_ENABLED: "true" - SCCACHE_GHA_VERSION: branch-checks-rust-${{ matrix.runner }} - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + defaults: + run: + shell: nix develop .#devShells.${{ matrix.system }}.default -c bash -euo pipefail {0} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Configure GHA sccache backend - uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} - - name: Install tools - run: mise install --locked + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 + with: + name: openshell + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - name: Realize Nix development shell + shell: bash + run: nix build --no-link ".#devShells.${{ matrix.system }}.default" - name: Cache Rust target and registry uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: - # Keep branch-check caches partitioned by runner architecture; lint + # Keep branch-check caches partitioned by target system; lint # and test intentionally share the same job-local target directory. - shared-key: rust-checks-${{ matrix.runner }} + shared-key: rust-checks-${{ matrix.system }} # Preserve compiled artifacts from failed lint/test runs so the next # push to the same PR branch does not start from a cold cache. cache-on-failure: "true" + cache-workspace-crates: "true" + cache-bin: "false" + cmd-format: nix develop .#devShells.${{ matrix.system }}.default -c {0} - name: Format - run: mise run rust:format:check - - - name: Lint - run: mise run rust:lint - - - name: Test - run: mise run test:rust - - - name: Verify telemetry can be compiled out - run: mise run rust:verify:telemetry-off - - - name: Verify system CA roots build mode compiles and excludes bundled Mozilla roots - run: mise run rust:verify:system-ca-roots - - - 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 - - rust-macos: - name: Rust lint (macOS) - needs: pr_metadata - if: needs.pr_metadata.outputs.should_run == 'true' - runs-on: macos-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Install mise run: | - curl --proto '=https' --tlsv1.2 -sSf https://mise.run | MISE_VERSION=v2026.4.25 sh - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - echo "$HOME/.local/share/mise/shims" >> "$GITHUB_PATH" - - - name: Configure GHA sccache backend - uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10 + 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 - - name: Install Rust and Clippy + - name: Lint run: | - mise install --locked rust - rustup component add clippy + 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 - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - shared-key: rust-clippy-macos - cache-on-failure: "true" - - - name: Lint macOS-sensitive crates - # Formatting is target-independent and already checked by the Linux jobs. - # The full mise lint covers every workspace/E2E target and requires extra - # native dependencies such as Z3; keep this guard focused on macOS cfgs. + - name: Test + env: + OPENSHELL_TELEMETRY_ENABLED: "false" run: | - cargo clippy \ - -p openshell-sandbox \ - -p openshell-core \ - -p openshell-cli \ - -p openshell-driver-db-credstore \ - -p openshell-driver-docker \ - -p openshell-driver-kubernetes \ - -p openshell-driver-kubernetes-secrets \ - -p openshell-driver-podman \ - -p openshell-driver-vault \ - --all-targets \ - -- -D warnings + cargo nextest run --profile ci --workspace --features openshell-server/test-support python: name: Python (${{ matrix.runner }}) diff --git a/flake.nix b/flake.nix index 9491ebeae3..8e9fb728b1 100644 --- a/flake.nix +++ b/flake.nix @@ -46,6 +46,8 @@ }; testGuestPkgs = import nixpkgs-test-guest { inherit system; }; commonDevShellPackages = with pkgs; [ + cargo-deny + cargo-nextest # Assemble Debian artifacts on macOS and Linux. dpkg # Required to find packages. From c3993426498d5b602ec080bc1d3cbad5e8460f4a Mon Sep 17 00:00:00 2001 From: krishicks Date: Wed, 26 Aug 2026 14:44:47 +0000 Subject: [PATCH 025/505] feat(dev): unify local Kubernetes gateway workflow (#2914) Make the local k3s gateway workflow match the Docker and Podman flows by registering and selecting successful plaintext Skaffold deployments with the OpenShell CLI. Derive the registration name from the worktree-specific k3d cluster name so parallel worktrees retain independent gateway metadata. Add helm:k3s:forward as the standard way to expose the Kubernetes gateway on localhost:8090, and update the development and debugging guidance to use the active registered gateway instead of one-off endpoint flags. Signed-off-by: Kris Hicks --- .../skills/debug-openshell-cluster/SKILL.md | 7 ++- .agents/skills/helm-dev-environment/SKILL.md | 23 ++++++-- tasks/helm.toml | 12 ++-- tasks/scripts/helm-k3s-local.sh | 58 ++++++++++++++++++- 4 files changed, 87 insertions(+), 13 deletions(-) diff --git a/.agents/skills/debug-openshell-cluster/SKILL.md b/.agents/skills/debug-openshell-cluster/SKILL.md index 3fc53df904..9db860995c 100644 --- a/.agents/skills/debug-openshell-cluster/SKILL.md +++ b/.agents/skills/debug-openshell-cluster/SKILL.md @@ -435,11 +435,14 @@ kubectl -n openshell get endpoints openshell For local port-forward testing: ```bash -kubectl -n openshell port-forward svc/openshell 8080:8080 -openshell gateway add http://127.0.0.1:8080 --local --name local +mise run helm:k3s:forward +openshell gateway list openshell status ``` +A successful plaintext `helm:skaffold:run` registers and selects the local +gateway; the forwarding task does not modify gateway metadata. + If the gateway is healthy but sandbox creation fails: ```bash diff --git a/.agents/skills/helm-dev-environment/SKILL.md b/.agents/skills/helm-dev-environment/SKILL.md index 2dad568c79..0c6afb8162 100644 --- a/.agents/skills/helm-dev-environment/SKILL.md +++ b/.agents/skills/helm-dev-environment/SKILL.md @@ -81,7 +81,17 @@ sidecar-mTLS profile reuses `ci/values-sidecar.yaml` and restores Job that runs `openshell-gateway generate-certs`) generates mTLS secrets on first install. Envoy Gateway opt-in; see the Optional Add-ons section below. -The gateway Service uses ClusterIP. Access is via Envoy Gateway (port `8080`) or `kubectl port-forward`. +The gateway Service uses ClusterIP. Access is via Envoy Gateway (port `8080`) or +the local forwarding task: + +```bash +mise run helm:k3s:forward +``` + +The task forwards the Kubernetes gateway to `http://127.0.0.1:8090`. A +successful plaintext `helm:skaffold:run` or `helm:skaffold:run:sidecar` +registers and selects the gateway under the worktree-specific k3d cluster name. +Keep the forwarding task running while using the endpoint. **HA test deploy** (two gateway replicas + external PostgreSQL Secret): uncomment `#- ci/values-high-availability.yaml` in `deploy/helm/openshell/skaffold.yaml`, @@ -99,19 +109,20 @@ plaintext by default. To test sidecar topology with TLS enabled, use | Skaffold dev (default) | `true` | `http://` | | TLS enabled | `false` (or omitted) | `https://` | -### Connecting via port-forward +### Connecting through the forwarding task -Port `8080` is already bound by the k3d load balancer when Envoy Gateway is active, so -the port-forward uses local port `8090` to avoid a collision: +Port `8080` is already bound by the k3d load balancer when Envoy Gateway is +active, so the forwarding task uses local port `8090` for the gateway. In a +second terminal, confirm that the gateway is registered and active: ```bash -KUBECONFIG=kubeconfig kubectl port-forward -n openshell svc/openshell 8090:8080 +openshell gateway list ``` **Plaintext (default Skaffold deploy):** ```bash -openshell sandbox list --gateway-endpoint http://localhost:8090 +openshell sandbox list ``` **With mTLS enabled** — extract the client cert the PKI hook wrote to the cluster, diff --git a/tasks/helm.toml b/tasks/helm.toml index 33a61c022c..7f78ce4a8f 100644 --- a/tasks/helm.toml +++ b/tasks/helm.toml @@ -69,14 +69,14 @@ dir = "deploy/helm/openshell" run = "skaffold dev -p sidecar-mtls" ["helm:skaffold:run"] -description = "Run skaffold run for deploy/helm/openshell (one-shot deploy)" +description = "Run a one-shot Skaffold deploy and register its local plaintext gateway" dir = "deploy/helm/openshell" -run = "skaffold run" +run = "skaffold run && ../../../tasks/scripts/helm-k3s-local.sh register" ["helm:skaffold:run:sidecar"] -description = "Run skaffold run with the Kubernetes supervisor sidecar topology" +description = "Run a sidecar Skaffold deploy and register its local plaintext gateway" dir = "deploy/helm/openshell" -run = "skaffold run -p sidecar" +run = "skaffold run -p sidecar && ../../../tasks/scripts/helm-k3s-local.sh register" ["helm:skaffold:run:sidecar-mtls"] description = "Run skaffold run with the Kubernetes supervisor sidecar topology and TLS/mTLS enabled" @@ -130,6 +130,10 @@ description = "List local k3d clusters" run = "tasks/scripts/helm-k3s-local.sh status" hide = true +["helm:k3s:forward"] +description = "Forward the local Kubernetes gateway to port 8090" +run = "tasks/scripts/helm-k3s-local.sh forward" + # Install Envoy Gateway's GatewayClass into the cluster ["helm:gateway:apply"] diff --git a/tasks/scripts/helm-k3s-local.sh b/tasks/scripts/helm-k3s-local.sh index 82b8d5cfc8..2234497b44 100755 --- a/tasks/scripts/helm-k3s-local.sh +++ b/tasks/scripts/helm-k3s-local.sh @@ -38,6 +38,13 @@ PRELOAD_SANDBOX_IMAGE="${HELM_K3S_PRELOAD_SANDBOX_IMAGE-${DEFAULT_SANDBOX_PRELOA # exercise the v1alpha1 controller release. AGENT_SANDBOX_VERSION="${AGENT_SANDBOX_VERSION:-v0.5.0}" +# Host endpoint registered for the Skaffold-deployed gateway. Derive the +# gateway name from the worktree-specific cluster name so concurrent local +# clusters do not overwrite each other's CLI metadata. +GATEWAY_NAMESPACE="openshell" +GATEWAY_HOST_PORT="${HELM_K3S_GATEWAY_HOST_PORT:-8090}" +GATEWAY_NAME="${HELM_K3S_GATEWAY_NAME:-${CLUSTER_NAME}}" + default_kubeconfig="${ROOT}/kubeconfig" if [[ -n "${HELM_K3S_KUBECONFIG:-}" ]]; then KUBECONFIG_TARGET="${HELM_K3S_KUBECONFIG}" @@ -54,7 +61,7 @@ fi usage() { cat >&2 < +usage: $(basename "$0") Environment: HELM_K3S_CLUSTER_NAME k3d cluster name (default: openshell-dev-) @@ -65,6 +72,9 @@ Environment: HELM_K3S_PRELOAD_SANDBOX_IMAGE Sandbox image to docker pull and import into k3d (default: ${DEFAULT_SANDBOX_PRELOAD_IMAGE}; set empty to skip) + HELM_K3S_GATEWAY_HOST_PORT Host port forwarded to the gateway (default: 8090) + HELM_K3S_GATEWAY_NAME CLI gateway registration name + (default: worktree-specific k3d cluster name) macOS uses k3d from mise (Docker required). Linux can use this flow only when k3d is installed explicitly; otherwise use kind or an existing cluster context. @@ -312,6 +322,50 @@ cmd_status() { k3d cluster list } +register_local_gateway() { + local config_home openshell_dir gateway_dir endpoint + + if [[ ! "${GATEWAY_NAME}" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "error: HELM_K3S_GATEWAY_NAME must contain only letters, numbers, dots, underscores, or dashes" >&2 + return 2 + fi + + config_home="${XDG_CONFIG_HOME:-${HOME}/.config}" + openshell_dir="${config_home}/openshell" + gateway_dir="${openshell_dir}/gateways/${GATEWAY_NAME}" + endpoint="http://127.0.0.1:${GATEWAY_HOST_PORT}" + + mkdir -p "${gateway_dir}" + chmod 700 "${gateway_dir}" 2>/dev/null || true + cat >"${gateway_dir}/metadata.json" </dev/null || true + printf '%s' "${GATEWAY_NAME}" >"${openshell_dir}/active_gateway" + chmod 600 "${openshell_dir}/active_gateway" 2>/dev/null || true + + echo "Registered and selected local gateway '${GATEWAY_NAME}' at ${endpoint}." +} + +cmd_forward() { + require_supported_os + require_kubectl + + echo "Forwarding gateway to http://127.0.0.1:${GATEWAY_HOST_PORT}" + echo "Press Ctrl-C to stop." + kubectl \ + --kubeconfig="${KUBECONFIG_TARGET}" \ + --context="$(k3d_context_name)" \ + --namespace="${GATEWAY_NAMESPACE}" \ + port-forward service/openshell "${GATEWAY_HOST_PORT}:8080" +} + main() { local sub="${1:-}" case "${sub}" in @@ -320,6 +374,8 @@ main() { start) cmd_start ;; stop) cmd_stop ;; status) cmd_status ;; + register) register_local_gateway ;; + forward) cmd_forward ;; -h | --help | help | "") usage ; [[ -n "${sub}" ]] || exit 1 ;; *) echo "error: unknown command '${sub}'" >&2 From 572843baf8a68263dda80a46441d1697e201ba74 Mon Sep 17 00:00:00 2001 From: krishicks Date: Wed, 26 Aug 2026 15:39:18 +0000 Subject: [PATCH 026/505] docs(agents): clarify user-visible PR review feedback (#2910) Agents will often give a ton of feedback, but it can be hard to quantify the impact of the issue the feedback is about. This change updates the PR review feedback template to be more human-readable, rooting any concerns in user-visible behavior when appropriate, and comparing the new behavior to old behavior so that PR authors can make a determination of whether they want to accept or reject the feedback. Signed-off-by: Kris Hicks --- .agents/skills/review-github-pr/SKILL.md | 27 ++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/.agents/skills/review-github-pr/SKILL.md b/.agents/skills/review-github-pr/SKILL.md index 1058bfb3e9..21cd131885 100644 --- a/.agents/skills/review-github-pr/SKILL.md +++ b/.agents/skills/review-github-pr/SKILL.md @@ -102,7 +102,9 @@ Read through the full diff (and the PR description if available). Produce a summ ### Potential Concerns <- omit if none -- +- **** — Before this PR, + experienced . With this PR, , so + . Details: `:`. ``` **Guidelines for the summary:** @@ -110,7 +112,28 @@ Read through the full diff (and the PR description if available). Produce a summ - **Overview**: State what changed and why. Pull context from the PR description if available. - **Key Design Decisions**: Focus on _why_ something was done a particular way, not _what_ changed. Include `file_path:line_number` references. Examples: choice of algorithm, new abstraction introduced, API contract change, migration strategy. - **Notable Code**: Include only the most instructive or surprising snippets. Keep each snippet under 15 lines. Always include the file path above the code block. -- **Potential Concerns**: Only include if there are genuine risks — missing error handling, breaking changes, performance implications, security issues. Do not fabricate concerns. +- **Potential Concerns**: Only include genuine risks that warrant a change or a + deliberate accept/reject decision. Describe each concern in terms of observable + behavior for the affected persona, such as a sandbox creator, sandbox user, + operator, administrator, SDK consumer, or developer maintaining the system. + Always compare the previous behavior with the new concerning behavior and state + the resulting user-visible impact. Prefer the compact form: "Before this PR, + `` experienced ``. With this PR, ``, so + ``." Add only the minimum file and line references needed to substantiate + the finding. + - Use the PR base as the normal previous-behavior baseline. Review older history + only when the change is fixing or extending an earlier feature and that history + is necessary to explain the behavioral contract. In that case, describe the + relevant transitions explicitly: "Before ``, ... After ``, ... + With this PR, ...". + - Translate internal failure modes and race conditions into what the affected + person would observe. Internal implementation details belong in the trailing + file and line references, not in place of the behavior description. + - Do not assign P0/P1/P2 or similar priority labels. The behavioral comparison + and impact should give maintainers enough context to accept or reject the + suggested change. + - Do not fabricate concerns or claim a behavioral regression without evidence + for both the prior and proposed behavior. - **Agent infrastructure**: When the PR changes behavior, commands, or development workflows, use the `sync-agent-infra` maintenance map to check that related skills were updated. When it adds, removes, or renames skills or crates; changes workflow relationships or skill coverage; modifies issue or PR templates; or changes agent cross-references, apply the full consistency checklist. Report missing companion updates or drift under **Potential Concerns**. ## Step 5: Output From d0dfb22baf32449dc565196fe55ac87769e83231 Mon Sep 17 00:00:00 2001 From: krishicks Date: Wed, 26 Aug 2026 21:22:51 +0000 Subject: [PATCH 027/505] feat(kubernetes): export driver traces over OTLP (#2958) Mirror the VM, Podman, and Docker driver tracing setup for Kubernetes. Export standalone driver spans through OTLP/gRPC as the distinct openshell-driver-kubernetes service, preserve gateway trace context, record lifecycle operations and gRPC failures, and flush spans on shutdown. Kubernetes currently runs in-process when selected as a built-in gateway driver. Use the temporary server-boundary shim shared with Podman and Docker so traces retain the shape they will have when Kubernetes moves to a separate process. Move the common ComputeDriver RPC tracing layer into openshell-otel to keep all drivers aligned. Propagate the active W3C context through the controller-reserved Sandbox annotation and enable Agent Sandbox OTLP export in the local k3s workflow. This connects asynchronous controller reconciliation spans to the originating OpenShell create trace. Expose gateway OTLP configuration through Helm and add an Aspire collector to the local k3s workflow. Extend helm:k3s:forward with OTLP ingest and trace UI forwarding for Kubernetes and local container gateway development. Signed-off-by: Kris Hicks --- .../skills/debug-openshell-cluster/SKILL.md | 8 +- .agents/skills/helm-dev-environment/SKILL.md | 44 +- Cargo.lock | 8 + crates/openshell-driver-kubernetes/Cargo.toml | 8 + crates/openshell-driver-kubernetes/README.md | 11 + .../openshell-driver-kubernetes/src/driver.rs | 159 ++++- .../openshell-driver-kubernetes/src/grpc.rs | 567 ++++++++++++++---- crates/openshell-driver-kubernetes/src/lib.rs | 1 + .../openshell-driver-kubernetes/src/main.rs | 52 +- .../src/otel_tracing.rs | 108 ++++ .../src/otel_tracing.rs | 87 +-- .../openshell-driver-vm/src/otel_tracing.rs | 83 +-- crates/openshell-otel/src/grpc.rs | 133 +++- crates/openshell-otel/src/lib.rs | 9 +- crates/openshell-otel/src/propagation.rs | 23 + crates/openshell-server/src/compute/mod.rs | 2 +- crates/openshell-server/src/tracing_setup.rs | 19 +- deploy/helm/openshell/README.md | 2 + deploy/helm/openshell/ci/values-skaffold.yaml | 2 + .../openshell/templates/gateway-config.yaml | 10 + .../openshell/tests/gateway_config_test.yaml | 26 + deploy/helm/openshell/values.yaml | 6 + docs/reference/gateway-config.mdx | 16 + tasks/helm.toml | 4 +- tasks/scripts/gateway-docker.sh | 3 + tasks/scripts/gateway-podman.sh | 3 + tasks/scripts/gateway-vm.sh | 3 + tasks/scripts/gateway.sh | 3 + tasks/scripts/helm-k3s-local.sh | 187 +++++- 29 files changed, 1280 insertions(+), 307 deletions(-) create mode 100644 crates/openshell-driver-kubernetes/src/otel_tracing.rs diff --git a/.agents/skills/debug-openshell-cluster/SKILL.md b/.agents/skills/debug-openshell-cluster/SKILL.md index 9db860995c..80d5057b17 100644 --- a/.agents/skills/debug-openshell-cluster/SKILL.md +++ b/.agents/skills/debug-openshell-cluster/SKILL.md @@ -440,8 +440,12 @@ openshell gateway list openshell status ``` -A successful plaintext `helm:skaffold:run` registers and selects the local -gateway; the forwarding task does not modify gateway metadata. +The forwarding task always exposes the collector on ports `4317` and `18888`. +It exposes the gateway on port `8090` only when the `openshell` Service exists, +so collector-only Docker or Podman development remains valid before a Skaffold +deployment. A successful plaintext `helm:skaffold:run` registers the local +gateway and selects it as active; the forwarding task does not modify gateway +metadata. If the gateway is healthy but sandbox creation fails: diff --git a/.agents/skills/helm-dev-environment/SKILL.md b/.agents/skills/helm-dev-environment/SKILL.md index 0c6afb8162..ebd9a01595 100644 --- a/.agents/skills/helm-dev-environment/SKILL.md +++ b/.agents/skills/helm-dev-environment/SKILL.md @@ -28,8 +28,10 @@ mise run helm:k3s:create Creates a k3d cluster and merges its kubeconfig into the worktree-local `kubeconfig` file. Also applies the upstream agent-sandbox CRDs/controller (pinned via `AGENT_SANDBOX_VERSION` in `tasks/scripts/helm-k3s-local.sh`, fetched from `github.com/kubernetes-sigs/agent-sandbox` -releases) and preloads the default community sandbox image into k3d so the first sandbox -create does not wait on a large registry pull. Traefik is disabled at cluster creation time. +releases), enables its OTLP tracing on v0.5 and later, installs an OTLP trace +collector and UI in the `observability` namespace, +and preloads the default community sandbox image into k3d so the first sandbox create +does not wait on a large registry pull. Traefik is disabled at cluster creation time. **Multi-worktree support:** the cluster name is derived from the last component of the current git branch (e.g. branch `kube-support/local-dev/tmutch` → cluster @@ -47,6 +49,9 @@ Override with env vars before running `helm:k3s:create`: - `HELM_K3S_LB_HOST_PORT` (default: `8080`) - `HELM_K3S_PRELOAD_SANDBOX_IMAGE` (default: `ghcr.io/nvidia/openshell-community/sandboxes/base:latest`; set to an empty value to skip) +- `HELM_K3S_COLLECTOR_IMAGE` (default: + `mcr.microsoft.com/dotnet/aspire-dashboard:latest`) +- `HELM_K3S_COLLECTOR_HEALTH_TIMEOUT` (default: `120` seconds) ### 2. Deploy OpenShell @@ -79,19 +84,42 @@ must be at least `1000` and distinct from the workload UID. The sidecar-mTLS profile reuses `ci/values-sidecar.yaml` and restores `server.disableTls=false` inline for Skaffold. The `pkiInitJob` hook (a pre-install Job that runs `openshell-gateway generate-certs`) generates mTLS secrets on first -install. Envoy Gateway opt-in; see the Optional Add-ons section below. +install. The default Skaffold values export gateway and Kubernetes-driver traces to +the collector service installed by `helm:k3s:create`. Envoy Gateway opt-in; see the +Optional Add-ons section below. The gateway Service uses ClusterIP. Access is via Envoy Gateway (port `8080`) or -the local forwarding task: +the unified local forwarding task: ```bash mise run helm:k3s:forward ``` -The task forwards the Kubernetes gateway to `http://127.0.0.1:8090`. A -successful plaintext `helm:skaffold:run` or `helm:skaffold:run:sidecar` -registers and selects the gateway under the worktree-specific k3d cluster name. -Keep the forwarding task running while using the endpoint. +The task forwards OTLP/gRPC to `http://127.0.0.1:4317` and the trace UI to +`http://127.0.0.1:18888`. When Skaffold has deployed a Kubernetes gateway, it +also forwards the gateway to `http://127.0.0.1:8090`; otherwise it continues +with the collector ports only. A successful plaintext `helm:skaffold:run` or +`helm:skaffold:run:sidecar` registers the gateway under the worktree-specific +k3d cluster name and selects it as the active gateway. Keep the forwarding +task running while using those endpoints. + +### Viewing local traces + +The gateway exports OTLP/gRPC to +`http://openshell-collector.observability.svc.cluster.local:4317` through the +default Skaffold values. Forward OTLP/gRPC and the trace UI to the host: + +```bash +mise run helm:k3s:forward +``` + +Open `http://127.0.0.1:18888` and exercise the gateway to inspect gateway and +Kubernetes compute-driver spans under their distinct service names, along with +Agent Sandbox controller reconciliation spans linked through the Sandbox +trace-context annotation. The same command exposes OTLP/gRPC on +`http://127.0.0.1:4317` and, when deployed, the Kubernetes gateway on +`http://127.0.0.1:8090`; the local `gateway`, `gateway:docker`, and `gateway:vm` +tasks export to the collector endpoint automatically. **HA test deploy** (two gateway replicas + external PostgreSQL Secret): uncomment `#- ci/values-high-availability.yaml` in `deploy/helm/openshell/skaffold.yaml`, diff --git a/Cargo.lock b/Cargo.lock index 65a50f0f39..afe6c33c7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3974,15 +3974,22 @@ dependencies = [ name = "openshell-driver-kubernetes" version = "0.0.0" dependencies = [ + "bytes", "clap", "futures", + "http 1.4.0", + "http-body-util", "k8s-openapi", "kube", "kube-runtime", "miette", "notify", "openshell-core", + "openshell-otel", + "openshell-otel-test-support", "openshell-policy", + "opentelemetry", + "opentelemetry_sdk", "prost", "prost-types", "serde", @@ -3993,6 +4000,7 @@ dependencies = [ "tokio-stream", "toml", "tonic", + "tower 0.5.3", "tracing", "tracing-subscriber", ] diff --git a/crates/openshell-driver-kubernetes/Cargo.toml b/crates/openshell-driver-kubernetes/Cargo.toml index 714b7d05c9..d7fcf19a17 100644 --- a/crates/openshell-driver-kubernetes/Cargo.toml +++ b/crates/openshell-driver-kubernetes/Cargo.toml @@ -16,6 +16,7 @@ path = "src/main.rs" [dependencies] openshell-core = { path = "../openshell-core", default-features = false } +openshell-otel = { path = "../openshell-otel" } openshell-policy = { path = "../openshell-policy" } tokio = { workspace = true } @@ -37,8 +38,15 @@ miette = { workspace = true } notify = "8" [dev-dependencies] +openshell-otel-test-support = { path = "../openshell-otel-test-support" } +opentelemetry = { workspace = true } +opentelemetry_sdk = { workspace = true, features = ["testing"] } +bytes = { workspace = true } +http = { workspace = true } +http-body-util = { workspace = true } temp-env = "0.3" toml = { workspace = true } +tower = { workspace = true } [lints] workspace = true diff --git a/crates/openshell-driver-kubernetes/README.md b/crates/openshell-driver-kubernetes/README.md index fac220c83b..b352f6efe9 100644 --- a/crates/openshell-driver-kubernetes/README.md +++ b/crates/openshell-driver-kubernetes/README.md @@ -19,6 +19,17 @@ workspace namespace modes via `workspace_mode`: gateway state; it never deletes or otherwise accesses the operator-managed Kubernetes namespace. +When the gateway configures `[openshell.gateway.otlp]`, Kubernetes +compute-driver spans export to the same OTLP/gRPC collector with the service +name `openshell-driver-kubernetes`. The driver preserves the gateway trace +context and uses the same compute-driver RPC span names in its in-process and +standalone forms. + +When it creates an Agent Sandbox resource, the driver serializes the active W3C +trace context into the controller-reserved `opentelemetry.io/trace-context` +annotation. An OTLP-enabled Agent Sandbox controller can therefore attach its +asynchronous reconciliation spans to the originating OpenShell create trace. + Workspace namespace modes assume exclusive control of the sandbox identity resource chain. In shared and managed modes, only the gateway and its trusted Agent Sandbox controller may administer the sandbox namespace, Sandbox CRs, diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index 84d7029de4..c1885870e8 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -62,6 +62,7 @@ pub type WatchStream = Pin> + Send>>; const MANAGED_SSH_NETWORK_POLICY_NAME: &str = "openshell-sandbox-ssh"; +const AGENT_SANDBOX_TRACE_CONTEXT_ANNOTATION: &str = "opentelemetry.io/trace-context"; #[derive(Debug, thiserror::Error)] pub enum KubernetesDriverError { @@ -467,6 +468,23 @@ impl std::fmt::Debug for KubernetesComputeDriver { } impl KubernetesComputeDriver { + #[cfg(test)] + pub(crate) fn new_for_test(config: KubernetesComputeConfig) -> Self { + let service = tower::service_fn(|_request: http::Request| async { + Ok::<_, std::convert::Infallible>(http::Response::new(http_body_util::Empty::< + bytes::Bytes, + >::new())) + }); + let client = Client::new(service, "default"); + Self { + client: client.clone(), + watch_client: client, + sandbox_api_version: Arc::new(OnceCell::new()), + config, + operator_allowlist: None, + } + } + pub async fn new( config: KubernetesComputeConfig, shutdown_rx: tokio::sync::watch::Receiver, @@ -1339,7 +1357,24 @@ impl KubernetesComputeDriver { } #[allow(clippy::similar_names)] + #[tracing::instrument( + name = "kubernetes.create_sandbox", + skip(self, sandbox), + fields( + otel.name = "kubernetes.create_sandbox", + otel.status_code = tracing::field::Empty, + sandbox.id = %sandbox.id, + sandbox.name = %sandbox.name, + ) + )] pub async fn create_sandbox(&self, sandbox: &Sandbox) -> Result<(), KubernetesDriverError> { + let span_status = openshell_otel::ErrorStatusGuard::current(); + let result = self.create_sandbox_inner(sandbox).await; + span_status.finish(result) + } + + #[allow(clippy::similar_names)] + async fn create_sandbox_inner(&self, sandbox: &Sandbox) -> Result<(), KubernetesDriverError> { let gpu_requirements = sandbox .spec .as_ref() @@ -1444,6 +1479,7 @@ impl KubernetesComputeDriver { let kube_name = self.config.kube_resource_name(workspace, name); let mut obj = DynamicObject::new(&kube_name, &agent_sandbox_api.resource); let mut annotations = sandbox_annotations(sandbox); + add_trace_context_annotation(&mut annotations); for key in [ crate::config::ANNOTATION_SCC_UID_RANGE, crate::config::ANNOTATION_SCC_SUPPLEMENTAL_GROUPS, @@ -1499,7 +1535,22 @@ impl KubernetesComputeDriver { } } + #[tracing::instrument( + name = "kubernetes.stop_sandbox", + skip(self), + fields( + otel.name = "kubernetes.stop_sandbox", + otel.status_code = tracing::field::Empty, + sandbox.id = %sandbox_id, + ) + )] pub async fn stop_sandbox(&self, sandbox_id: &str) -> Result<(), KubernetesDriverError> { + let span_status = openshell_otel::ErrorStatusGuard::current(); + let result = self.stop_sandbox_inner(sandbox_id).await; + span_status.finish(result) + } + + async fn stop_sandbox_inner(&self, sandbox_id: &str) -> Result<(), KubernetesDriverError> { let (agent_sandbox_api, kube_name, pod_name, namespace, stop_timeout) = self .patch_sandbox_operating_state(sandbox_id, false) .await?; @@ -1554,10 +1605,22 @@ impl KubernetesComputeDriver { } } + #[tracing::instrument( + name = "kubernetes.start_sandbox", + skip(self), + fields( + otel.name = "kubernetes.start_sandbox", + otel.status_code = tracing::field::Empty, + sandbox.id = %sandbox_id, + ) + )] pub async fn start_sandbox(&self, sandbox_id: &str) -> Result<(), KubernetesDriverError> { - self.patch_sandbox_operating_state(sandbox_id, true) + let span_status = openshell_otel::ErrorStatusGuard::current(); + let result = self + .patch_sandbox_operating_state(sandbox_id, true) .await - .map(|_| ()) + .map(|_| ()); + span_status.finish(result) } async fn patch_sandbox_operating_state( @@ -1648,7 +1711,22 @@ impl KubernetesComputeDriver { )) } + #[tracing::instrument( + name = "kubernetes.delete_sandbox", + skip(self), + fields( + otel.name = "kubernetes.delete_sandbox", + otel.status_code = tracing::field::Empty, + sandbox.id = %sandbox_id, + ) + )] pub async fn delete_sandbox(&self, sandbox_id: &str) -> Result { + let span_status = openshell_otel::ErrorStatusGuard::current(); + let result = self.delete_sandbox_inner(sandbox_id).await; + span_status.finish(result) + } + + async fn delete_sandbox_inner(&self, sandbox_id: &str) -> Result { info!( sandbox_id = %sandbox_id, workspace_mode = %self.config.workspace_mode, @@ -1968,6 +2046,15 @@ impl KubernetesComputeDriver { } } +fn add_trace_context_annotation(annotations: &mut BTreeMap) { + let Some(carrier) = openshell_otel::current_trace_context_carrier() else { + return; + }; + if let Ok(value) = serde_json::to_string(&carrier) { + annotations.insert(AGENT_SANDBOX_TRACE_CONTEXT_ANNOTATION.to_string(), value); + } +} + fn should_try_next_sandbox_api_version(err: &KubeError) -> bool { // Kubernetes returns a structured 404 for some missing API resources and a // raw "404 page not found" body for others. Both mean the probed @@ -4561,6 +4648,74 @@ mod tests { static ENV_LOCK: std::sync::LazyLock> = std::sync::LazyLock::new(|| std::sync::Mutex::new(())); + #[tokio::test] + async fn tracing_create_sandbox_failure_exports_a_kubernetes_operation_span() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = crate::otel_tracing::test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry().with(crate::otel_tracing::layer(&provider)); + let driver = KubernetesComputeDriver::new_for_test(KubernetesComputeConfig::default()); + + driver + .create_sandbox(&Sandbox::default()) + .with_subscriber(subscriber) + .await + .expect_err("missing sandbox name should fail"); + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|span| span.name == "kubernetes.create_sandbox") + .expect("create operation span"); + assert!(matches!( + span.status, + opentelemetry::trace::Status::Error { .. } + )); + provider.shutdown().unwrap(); + } + + #[tokio::test] + async fn sandbox_annotation_propagates_the_active_w3c_trace_context() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = crate::otel_tracing::test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter) + .build(); + let subscriber = tracing_subscriber::registry().with(crate::otel_tracing::layer(&provider)); + + let annotations = tracing::subscriber::with_default(subscriber, || { + let span = tracing::info_span!("kubernetes.create_sandbox"); + let _entered = span.enter(); + let mut annotations = BTreeMap::new(); + add_trace_context_annotation(&mut annotations); + annotations + }); + + let carrier: serde_json::Value = serde_json::from_str( + annotations + .get("opentelemetry.io/trace-context") + .expect("agent-sandbox trace-context annotation"), + ) + .expect("annotation should contain a JSON propagation carrier"); + let traceparent = carrier["traceparent"] + .as_str() + .expect("carrier should contain traceparent"); + assert!(traceparent.starts_with("00-")); + assert_eq!(traceparent.len(), 55); + + provider.shutdown().unwrap(); + } + fn json_struct(value: serde_json::Value) -> Struct { let serde_json::Value::Object(object) = value else { panic!("expected JSON object"); diff --git a/crates/openshell-driver-kubernetes/src/grpc.rs b/crates/openshell-driver-kubernetes/src/grpc.rs index ce23cf0189..383ddebe0c 100644 --- a/crates/openshell-driver-kubernetes/src/grpc.rs +++ b/crates/openshell-driver-kubernetes/src/grpc.rs @@ -14,21 +14,109 @@ use openshell_core::proto::compute::v1::{ ValidateSandboxCreateRequest, ValidateSandboxCreateResponse, WatchSandboxesEvent, WatchSandboxesRequest, compute_driver_server::ComputeDriver, }; +use std::future::Future; use std::pin::Pin; +use std::task::{Context, Poll}; use tonic::{Request, Response, Status}; +use tracing::Instrument as _; use crate::KubernetesComputeDriver; use crate::WorkspaceMode; +type ComputeDriverWatchStream = + Pin> + Send + 'static>>; + +struct TracedWatchStream { + inner: ComputeDriverWatchStream, + span: tracing::Span, +} + +impl Stream for TracedWatchStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let span = self.span.clone(); + let _entered = span.enter(); + let result = self.inner.as_mut().poll_next(cx); + match &result { + Poll::Ready(Some(Err(status))) => { + openshell_otel::mark_error(&self.span); + self.span + .record("rpc.grpc.status_code", status.code() as i32); + } + Poll::Ready(None) => { + self.span + .record("rpc.grpc.status_code", tonic::Code::Ok as i32); + } + Poll::Pending | Poll::Ready(Some(Ok(_))) => {} + } + result + } +} + #[derive(Debug, Clone)] pub struct ComputeDriverService { driver: KubernetesComputeDriver, + trace_in_process_rpc: bool, } impl ComputeDriverService { #[must_use] pub fn new(driver: KubernetesComputeDriver) -> Self { - Self { driver } + Self { + driver, + trace_in_process_rpc: false, + } + } + + #[must_use] + pub fn new_in_process(driver: KubernetesComputeDriver) -> Self { + Self { + driver, + trace_in_process_rpc: true, + } + } + + fn in_process_rpc_span( + &self, + operation: &'static str, + method: &'static str, + ) -> Option { + self.trace_in_process_rpc.then(|| { + tracing::info_span!( + target: "openshell_driver_kubernetes::otel_tracing", + "driver_rpc", + otel.name = operation, + otel.kind = "server", + otel.status_code = tracing::field::Empty, + rpc.system = "grpc", + rpc.service = "openshell.compute.v1.ComputeDriver", + rpc.method = method, + rpc.grpc.status_code = tracing::field::Empty, + ) + }) + } + + async fn trace_rpc( + &self, + operation: &'static str, + method: &'static str, + future: impl Future>, + ) -> Result { + let Some(span) = self.in_process_rpc_span(operation, method) else { + return future.await; + }; + let result = future.instrument(span.clone()).await; + match &result { + Ok(_) => { + span.record("rpc.grpc.status_code", tonic::Code::Ok as i32); + } + Err(status) => { + openshell_otel::mark_error(&span); + span.record("rpc.grpc.status_code", status.code() as i32); + } + } + result } } @@ -38,193 +126,250 @@ impl ComputeDriver for ComputeDriverService { &self, _request: Request, ) -> Result, Status> { - self.driver - .capabilities() - .map(Response::new) - .map_err(Status::internal) + self.trace_rpc("driver.get_capabilities", "get_capabilities", async { + self.driver + .capabilities() + .map(Response::new) + .map_err(Status::internal) + }) + .await } async fn get_gateway_listener_requirements( &self, _request: Request, ) -> Result, Status> { - Ok(Response::new(GetGatewayListenerRequirementsResponse { - requirements: Vec::new(), - })) + self.trace_rpc( + "driver.get_gateway_listener_requirements", + "get_gateway_listener_requirements", + async { + Ok(Response::new(GetGatewayListenerRequirementsResponse { + requirements: Vec::new(), + })) + }, + ) + .await } async fn validate_sandbox_create( &self, request: Request, ) -> Result, Status> { - let sandbox = request - .into_inner() - .sandbox - .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; - self.driver.validate_sandbox_create(&sandbox).await?; - Ok(Response::new(ValidateSandboxCreateResponse {})) + self.trace_rpc( + "driver.validate_sandbox_create", + "validate_sandbox_create", + async { + let sandbox = request + .into_inner() + .sandbox + .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; + self.driver.validate_sandbox_create(&sandbox).await?; + Ok(Response::new(ValidateSandboxCreateResponse {})) + }, + ) + .await } async fn get_sandbox( &self, request: Request, ) -> Result, Status> { - let request = request.into_inner(); - if request.sandbox_id.is_empty() { - return Err(Status::invalid_argument("sandbox_id is required")); - } - - let sandbox = self - .driver - .get_sandbox(&request.sandbox_id) - .await - .map_err(Status::internal)? - .ok_or_else(|| Status::not_found("sandbox not found"))?; - - Ok(Response::new(GetSandboxResponse { - sandbox: Some(sandbox), - })) + self.trace_rpc("driver.get_sandbox", "get_sandbox", async { + let request = request.into_inner(); + if request.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } + let sandbox = self + .driver + .get_sandbox(&request.sandbox_id) + .await + .map_err(Status::internal)? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + Ok(Response::new(GetSandboxResponse { + sandbox: Some(sandbox), + })) + }) + .await } async fn list_sandboxes( &self, _request: Request, ) -> Result, Status> { - let sandboxes = self - .driver - .list_sandboxes() - .await - .map_err(Status::internal)?; - Ok(Response::new(ListSandboxesResponse { sandboxes })) + self.trace_rpc("driver.list_sandboxes", "list_sandboxes", async { + let sandboxes = self + .driver + .list_sandboxes() + .await + .map_err(Status::internal)?; + Ok(Response::new(ListSandboxesResponse { sandboxes })) + }) + .await } async fn create_sandbox( &self, request: Request, ) -> Result, Status> { - let sandbox = request - .into_inner() - .sandbox - .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; - self.driver - .create_sandbox(&sandbox) - .await - .map_err(|e| Status::from(openshell_core::ComputeDriverError::from(e)))?; - Ok(Response::new(CreateSandboxResponse {})) + self.trace_rpc("driver.create_sandbox", "create_sandbox", async { + let sandbox = request + .into_inner() + .sandbox + .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; + self.driver + .create_sandbox(&sandbox) + .await + .map_err(|e| Status::from(openshell_core::ComputeDriverError::from(e)))?; + Ok(Response::new(CreateSandboxResponse {})) + }) + .await } async fn stop_sandbox( &self, request: Request, ) -> Result, Status> { - let request = request.into_inner(); - if request.sandbox_id.is_empty() { - return Err(Status::invalid_argument("sandbox_id is required")); - } - self.driver - .stop_sandbox(&request.sandbox_id) - .await - .map_err(|error| Status::from(openshell_core::ComputeDriverError::from(error)))?; - Ok(Response::new(StopSandboxResponse {})) + self.trace_rpc("driver.stop_sandbox", "stop_sandbox", async { + let request = request.into_inner(); + if request.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } + self.driver + .stop_sandbox(&request.sandbox_id) + .await + .map_err(|error| Status::from(openshell_core::ComputeDriverError::from(error)))?; + Ok(Response::new(StopSandboxResponse {})) + }) + .await } async fn start_sandbox( &self, request: Request, ) -> Result, Status> { - let request = request.into_inner(); - if request.sandbox_id.is_empty() { - return Err(Status::invalid_argument("sandbox_id is required")); - } - self.driver - .start_sandbox(&request.sandbox_id) - .await - .map_err(|error| Status::from(openshell_core::ComputeDriverError::from(error)))?; - Ok(Response::new(StartSandboxResponse {})) + self.trace_rpc("driver.start_sandbox", "start_sandbox", async { + let request = request.into_inner(); + if request.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } + self.driver + .start_sandbox(&request.sandbox_id) + .await + .map_err(|error| Status::from(openshell_core::ComputeDriverError::from(error)))?; + Ok(Response::new(StartSandboxResponse {})) + }) + .await } async fn delete_sandbox( &self, request: Request, ) -> Result, Status> { - let request = request.into_inner(); - if request.sandbox_id.is_empty() { - return Err(Status::invalid_argument("sandbox_id is required")); - } - let deleted = self - .driver - .delete_sandbox(&request.sandbox_id) - .await - .map_err(Status::internal)?; - Ok(Response::new(DeleteSandboxResponse { deleted })) + self.trace_rpc("driver.delete_sandbox", "delete_sandbox", async { + let request = request.into_inner(); + if request.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } + let deleted = self + .driver + .delete_sandbox(&request.sandbox_id) + .await + .map_err(Status::internal)?; + Ok(Response::new(DeleteSandboxResponse { deleted })) + }) + .await } - type WatchSandboxesStream = - Pin> + Send + 'static>>; + type WatchSandboxesStream = ComputeDriverWatchStream; async fn watch_sandboxes( &self, _request: Request, ) -> Result, Status> { - let stream = self - .driver - .watch_sandboxes() - .await - .map_err(Status::internal)?; - let stream = stream.map(|item| item.map_err(|err| Status::internal(err.to_string()))); - Ok(Response::new(Box::pin(stream))) + let create_stream = async { + let stream = self + .driver + .watch_sandboxes() + .await + .map_err(Status::internal)?; + let stream = stream.map(|item| item.map_err(|err| Status::internal(err.to_string()))); + Ok::(Box::pin(stream)) + }; + let Some(span) = self.in_process_rpc_span("driver.watch_sandboxes", "watch_sandboxes") + else { + return create_stream.await.map(Response::new); + }; + match create_stream.instrument(span.clone()).await { + Ok(stream) => Ok(Response::new(Box::pin(TracedWatchStream { + inner: stream, + span, + }))), + Err(status) => { + openshell_otel::mark_error(&span); + span.record("rpc.grpc.status_code", status.code() as i32); + Err(status) + } + } } async fn ensure_workspace( &self, request: Request, ) -> Result, Status> { - let workspace = request.into_inner().workspace; - if workspace.is_empty() { - return Err(Status::invalid_argument("workspace is required")); - } - self.driver - .validate_workspace_namespace(&workspace) - .map_err(|error| Status::from(openshell_core::ComputeDriverError::from(error)))?; - match self.driver.workspace_mode() { - WorkspaceMode::Managed => { - self.driver - .ensure_namespace(&workspace) - .await - .map_err(|e| Status::internal(e.to_string()))?; + self.trace_rpc("driver.ensure_workspace", "ensure_workspace", async { + let workspace = request.into_inner().workspace; + if workspace.is_empty() { + return Err(Status::invalid_argument("workspace is required")); } - WorkspaceMode::Operator => { - if let Some(allowlist) = self.driver.operator_allowlist() - && !allowlist.contains(&workspace) - { - return Err(Status::permission_denied(format!( - "workspace '{workspace}' is not in the operator namespace allowlist" - ))); + self.driver + .validate_workspace_namespace(&workspace) + .map_err(|error| Status::from(openshell_core::ComputeDriverError::from(error)))?; + match self.driver.workspace_mode() { + WorkspaceMode::Managed => { + self.driver + .ensure_namespace(&workspace) + .await + .map_err(|e| Status::internal(e.to_string()))?; + } + WorkspaceMode::Operator => { + if let Some(allowlist) = self.driver.operator_allowlist() + && !allowlist.contains(&workspace) + { + return Err(Status::permission_denied(format!( + "workspace '{workspace}' is not in the operator namespace allowlist" + ))); + } } + WorkspaceMode::Shared => {} } - WorkspaceMode::Shared => {} - } - Ok(Response::new(EnsureWorkspaceResponse {})) + Ok(Response::new(EnsureWorkspaceResponse {})) + }) + .await } async fn delete_workspace( &self, request: Request, ) -> Result, Status> { - let workspace = request.into_inner().workspace; - if workspace.is_empty() { - return Err(Status::invalid_argument("workspace is required")); - } - if workspace_delete_requires_namespace_access(self.driver.workspace_mode()) { - self.driver - .validate_workspace_namespace(&workspace) - .map_err(|error| Status::from(openshell_core::ComputeDriverError::from(error)))?; - self.driver - .delete_namespace(&workspace) - .await - .map_err(|e| Status::internal(e.to_string()))?; - } - Ok(Response::new(DeleteWorkspaceResponse {})) + self.trace_rpc("driver.delete_workspace", "delete_workspace", async { + let workspace = request.into_inner().workspace; + if workspace.is_empty() { + return Err(Status::invalid_argument("workspace is required")); + } + if workspace_delete_requires_namespace_access(self.driver.workspace_mode()) { + self.driver + .validate_workspace_namespace(&workspace) + .map_err(|error| { + Status::from(openshell_core::ComputeDriverError::from(error)) + })?; + self.driver + .delete_namespace(&workspace) + .await + .map_err(|e| Status::internal(e.to_string()))?; + } + Ok(Response::new(DeleteWorkspaceResponse {})) + }) + .await } } @@ -239,6 +384,192 @@ mod tests { use openshell_core::ComputeDriverError; use tonic::Status; + #[tokio::test] + async fn tracing_in_process_service_preserves_the_driver_rpc_server_boundary() { + use super::*; + use crate::KubernetesComputeConfig; + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = crate::otel_tracing::test_lock().await; + let gateway_exporter = InMemorySpanExporterBuilder::new().build(); + let gateway_provider = SdkTracerProvider::builder() + .with_simple_exporter(gateway_exporter.clone()) + .build(); + let driver_exporter = InMemorySpanExporterBuilder::new().build(); + let driver_provider = SdkTracerProvider::builder() + .with_simple_exporter(driver_exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry() + .with(openshell_otel::layer_excluding_target_prefix( + &gateway_provider, + "gateway-test", + Some(crate::otel_tracing::IN_PROCESS_TARGET_PREFIX), + )) + .with(crate::otel_tracing::in_process_layer(&driver_provider)); + let service = ComputeDriverService::new_in_process(KubernetesComputeDriver::new_for_test( + KubernetesComputeConfig::default(), + )); + + async { + let gateway_span = tracing::info_span!( + target: "openshell_server::compute", + "driver", + otel.name = "driver.get_capabilities", + otel.kind = "client" + ); + ComputeDriver::get_capabilities(&service, Request::new(GetCapabilitiesRequest {})) + .instrument(gateway_span) + .await?; + + ComputeDriver::validate_sandbox_create( + &service, + Request::new(ValidateSandboxCreateRequest { sandbox: None }), + ) + .await + } + .with_subscriber(subscriber) + .await + .expect_err("missing sandbox should fail"); + gateway_provider.force_flush().unwrap(); + driver_provider.force_flush().unwrap(); + + let gateway_spans = gateway_exporter.get_finished_spans().unwrap(); + let driver_spans = driver_exporter.get_finished_spans().unwrap(); + let client = gateway_spans + .iter() + .find(|span| span.name == "driver.get_capabilities") + .expect("gateway client span"); + let server = driver_spans + .iter() + .find(|span| span.name == "driver.get_capabilities") + .expect("in-process server span"); + assert_eq!( + server.span_context.trace_id(), + client.span_context.trace_id() + ); + assert_eq!(server.parent_span_id, client.span_context.span_id()); + assert_eq!(server.span_kind, opentelemetry::trace::SpanKind::Server); + assert!(server.attributes.iter().any(|attribute| { + attribute.key.as_str() == "rpc.grpc.status_code" + && attribute.value.to_string() == (tonic::Code::Ok as i32).to_string() + })); + let failed = driver_spans + .iter() + .find(|span| span.name == "driver.validate_sandbox_create") + .expect("failed in-process server span"); + assert!(matches!( + failed.status, + opentelemetry::trace::Status::Error { .. } + )); + gateway_provider.shutdown().unwrap(); + driver_provider.shutdown().unwrap(); + } + + #[tokio::test] + async fn tracing_in_process_stream_span_lives_until_stream_failure() { + use super::*; + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = crate::otel_tracing::test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = + tracing_subscriber::registry().with(crate::otel_tracing::in_process_layer(&provider)); + + async { + let span = tracing::info_span!( + target: "openshell_driver_kubernetes::otel_tracing", + "driver_rpc", + otel.name = "driver.watch_sandboxes", + otel.kind = "server", + otel.status_code = tracing::field::Empty, + rpc.grpc.status_code = tracing::field::Empty, + ); + let inner: ComputeDriverWatchStream = Box::pin(futures::stream::iter([Err( + Status::internal("watch failed"), + )])); + let mut stream = TracedWatchStream { inner, span }; + + provider.force_flush().unwrap(); + assert!( + exporter.get_finished_spans().unwrap().is_empty(), + "server span must remain open while the response stream is alive" + ); + stream + .next() + .await + .expect("stream item") + .expect_err("stream should fail"); + drop(stream); + } + .with_subscriber(subscriber) + .await; + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|span| span.name == "driver.watch_sandboxes") + .expect("watch server span should be exported when the stream ends"); + assert!(matches!( + span.status, + opentelemetry::trace::Status::Error { .. } + )); + provider.shutdown().unwrap(); + } + + #[tokio::test] + async fn tracing_in_process_stream_records_ok_when_stream_completes() { + use super::*; + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = crate::otel_tracing::test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = + tracing_subscriber::registry().with(crate::otel_tracing::in_process_layer(&provider)); + + async { + let span = tracing::info_span!( + target: "openshell_driver_kubernetes::otel_tracing", + "driver_rpc", + otel.name = "driver.watch_sandboxes", + otel.kind = "server", + otel.status_code = tracing::field::Empty, + rpc.grpc.status_code = tracing::field::Empty, + ); + let inner: ComputeDriverWatchStream = Box::pin(futures::stream::empty()); + let mut stream = TracedWatchStream { inner, span }; + + assert!(stream.next().await.is_none()); + drop(stream); + } + .with_subscriber(subscriber) + .await; + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|span| span.name == "driver.watch_sandboxes") + .expect("watch server span should be exported when the stream completes"); + assert!(span.attributes.iter().any(|attribute| { + attribute.key.as_str() == "rpc.grpc.status_code" + && attribute.value.to_string() == (tonic::Code::Ok as i32).to_string() + })); + provider.shutdown().unwrap(); + } + #[test] fn precondition_driver_errors_map_to_failed_precondition_status() { let status: Status = ComputeDriverError::from(KubernetesDriverError::Precondition( diff --git a/crates/openshell-driver-kubernetes/src/lib.rs b/crates/openshell-driver-kubernetes/src/lib.rs index d69f9749a1..edc0a3e97e 100644 --- a/crates/openshell-driver-kubernetes/src/lib.rs +++ b/crates/openshell-driver-kubernetes/src/lib.rs @@ -4,6 +4,7 @@ pub mod config; pub mod driver; pub mod grpc; +pub mod otel_tracing; pub use config::{ AppArmorProfile, DEFAULT_GATEWAY_ID, DEFAULT_PROXY_UID, DEFAULT_SANDBOX_SERVICE_ACCOUNT_NAME, diff --git a/crates/openshell-driver-kubernetes/src/main.rs b/crates/openshell-driver-kubernetes/src/main.rs index 3a805c8685..712cc1cbda 100644 --- a/crates/openshell-driver-kubernetes/src/main.rs +++ b/crates/openshell-driver-kubernetes/src/main.rs @@ -8,9 +8,11 @@ use std::net::SocketAddr; use std::path::PathBuf; use tracing::info; use tracing_subscriber::EnvFilter; +use tracing_subscriber::prelude::*; use openshell_core::VERSION; use openshell_core::proto::compute::v1::compute_driver_server::ComputeDriverServer; +use openshell_driver_kubernetes::otel_tracing::compute_driver_rpc_layer; use openshell_driver_kubernetes::{ AppArmorProfile, ComputeDriverService, DEFAULT_GATEWAY_ID, DEFAULT_PROXY_UID, DEFAULT_SANDBOX_SERVICE_ACCOUNT_NAME, KubernetesComputeConfig, KubernetesComputeDriver, @@ -37,6 +39,9 @@ struct Args { #[arg(long, env = "OPENSHELL_LOG_LEVEL", default_value = "info")] log_level: String, + #[arg(long, env = "OPENSHELL_OTLP_ENDPOINT")] + otlp_endpoint: Option, + #[arg(long, env = "OPENSHELL_WORKSPACE_MODE", default_value = "shared")] workspace_mode: WorkspaceMode, @@ -210,11 +215,22 @@ async fn shutdown_signal() { #[tokio::main] async fn main() -> Result<()> { let args = Args::parse(); - tracing_subscriber::fmt() - .with_env_filter( - EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level)), + let (tracer_provider, setup_error) = + openshell_driver_kubernetes::otel_tracing::provider_for(args.otlp_endpoint.as_deref()); + tracing_subscriber::registry() + .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level))) + .with(tracing_subscriber::fmt::layer()) + .with( + tracer_provider + .as_ref() + .map(openshell_driver_kubernetes::otel_tracing::layer), ) .init(); + if let Some(error) = setup_error { + tracing::error!(%error, "OTLP exporting could not be started"); + } else if let Some(endpoint) = &args.otlp_endpoint { + info!(endpoint, "OTLP exporting enabled"); + } let managed_ssh_gateway_pod_selector = args .managed_ssh_gateway_pod_selector @@ -296,13 +312,14 @@ async fn main() -> Result<()> { shutdown_signal().await; let _ = shutdown_tx.send(true); }; - if let Some(socket_path) = args.bind_socket { + let result = if let Some(socket_path) = args.bind_socket { let listener = openshell_core::external_driver_socket::bind_private(&socket_path) .map_err(|err| miette::miette!("{err}"))?; let _cleanup = openshell_core::external_driver_socket::SocketCleanup::new(socket_path.clone()); info!(socket = %socket_path.display(), "Starting Kubernetes compute driver"); tonic::transport::Server::builder() + .layer(compute_driver_rpc_layer()) .add_service(service) .serve_with_incoming_shutdown( openshell_core::external_driver_socket::SameUidUnixIncoming::new(listener), @@ -313,9 +330,36 @@ async fn main() -> Result<()> { } else { info!(address = %args.bind_address, "Starting Kubernetes compute driver"); tonic::transport::Server::builder() + .layer(compute_driver_rpc_layer()) .add_service(service) .serve_with_shutdown(args.bind_address, shutdown) .await .into_diagnostic() + }; + if let Some(provider) = &tracer_provider + && let Err(error) = provider.shutdown() + { + tracing::warn!(%error, "OTLP tracer provider shutdown failed"); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_gateway_otlp_endpoint() { + let args = Args::try_parse_from([ + "openshell-driver-kubernetes", + "--otlp-endpoint", + "http://collector.example:4317", + ]) + .expect("OTLP endpoint should parse"); + + assert_eq!( + args.otlp_endpoint.as_deref(), + Some("http://collector.example:4317") + ); } } diff --git a/crates/openshell-driver-kubernetes/src/otel_tracing.rs b/crates/openshell-driver-kubernetes/src/otel_tracing.rs new file mode 100644 index 0000000000..1a8d40071d --- /dev/null +++ b/crates/openshell-driver-kubernetes/src/otel_tracing.rs @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OpenTelemetry trace exporting for the Kubernetes compute driver. + +use openshell_otel::{OtlpTraceConfig, SdkTracerProvider, ServiceName, SetupError}; +pub use openshell_otel::{compute_driver_rpc_layer, compute_driver_rpc_operation}; +use tracing::Subscriber; +use tracing_subscriber::registry::LookupSpan; + +const SERVICE_NAME: &str = "openshell-driver-kubernetes"; +const INSTRUMENTATION_SCOPE: &str = "openshell-driver-kubernetes"; +pub const IN_PROCESS_TARGET_PREFIX: &str = "openshell_driver_kubernetes"; + +#[must_use] +pub fn provider_for(endpoint: Option<&str>) -> (Option, Option) { + openshell_otel::provider_for(endpoint.map(|endpoint| OtlpTraceConfig { + endpoint, + service_name: ServiceName::Fixed(SERVICE_NAME), + service_version: Some(openshell_core::VERSION), + resource_attributes: Vec::new(), + })) +} + +pub fn layer(provider: &SdkTracerProvider) -> openshell_otel::OtlpLayer +where + S: Subscriber + for<'span> LookupSpan<'span>, +{ + openshell_otel::layer(provider, INSTRUMENTATION_SCOPE) +} + +pub fn in_process_layer(provider: &SdkTracerProvider) -> openshell_otel::TargetOtlpLayer +where + S: Subscriber + for<'span> LookupSpan<'span>, +{ + openshell_otel::layer_for_target_prefix( + provider, + INSTRUMENTATION_SCOPE, + IN_PROCESS_TARGET_PREFIX, + ) +} + +#[cfg(test)] +pub(crate) async fn test_lock() -> tokio::sync::MutexGuard<'static, ()> { + static LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + static INITIALIZED: std::sync::LazyLock<()> = std::sync::LazyLock::new(|| { + tracing::subscriber::set_global_default(tracing_subscriber::registry()) + .expect("test tracing subscriber installs once"); + }); + + let guard = LOCK.lock().await; + std::sync::LazyLock::force(&INITIALIZED); + guard +} + +#[cfg(test)] +mod tests { + use openshell_otel_test_support::OtlpTestServer; + use tracing_subscriber::layer::SubscriberExt as _; + + #[test] + fn compute_driver_rpc_names_are_explicitly_mapped_and_schema_bounded() { + assert_eq!( + super::compute_driver_rpc_operation( + "/openshell.compute.v1.ComputeDriver/GetCapabilities" + ), + ("driver.get_capabilities", "get_capabilities") + ); + assert_eq!( + super::compute_driver_rpc_operation( + "/openshell.compute.v1.ComputeDriver/AttackerControlled12345" + ), + ("driver.unknown", "unknown") + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn tracing_kubernetes_driver_spans_reach_otlp_collector_with_distinct_service_name() { + let _tracing_lock = super::test_lock().await; + let collector = OtlpTestServer::start().await; + let (provider, error) = super::provider_for(Some(collector.endpoint())); + assert!(error.is_none()); + let provider = provider.expect("provider"); + let subscriber = tracing_subscriber::registry().with(super::layer(&provider)); + tracing::subscriber::with_default(subscriber, || { + let span = tracing::info_span!("kubernetes.create_sandbox", sandbox.id = "sb-otlp"); + drop(span.enter()); + drop(span); + }); + provider.force_flush().unwrap(); + collector.wait_for_export().await; + provider.shutdown().unwrap(); + let received = collector.shutdown().await; + + assert!( + received + .spans + .iter() + .any(|span| span.name == "kubernetes.create_sandbox") + ); + assert!( + received + .service_names + .iter() + .any(|name| name == super::SERVICE_NAME) + ); + } +} diff --git a/crates/openshell-driver-podman/src/otel_tracing.rs b/crates/openshell-driver-podman/src/otel_tracing.rs index 51d42a88fe..00163c206e 100644 --- a/crates/openshell-driver-podman/src/otel_tracing.rs +++ b/crates/openshell-driver-podman/src/otel_tracing.rs @@ -3,92 +3,15 @@ //! OpenTelemetry trace exporting for the Podman compute driver. -use http::Request; -use openshell_otel::{ - HeaderMapExtractor, OtlpTraceConfig, RecordGrpcFailure, RecordGrpcStatus, SdkTracerProvider, - ServiceName, SetupError, -}; -use opentelemetry::propagation::TextMapPropagator as _; -use opentelemetry::trace::TraceContextExt as _; -use opentelemetry_sdk::propagation::TraceContextPropagator; -use tower_http::trace::{GrpcMakeClassifier, MakeSpan, TraceLayer}; -use tracing::{Span, Subscriber}; -use tracing_opentelemetry::OpenTelemetrySpanExt as _; +use openshell_otel::{OtlpTraceConfig, SdkTracerProvider, ServiceName, SetupError}; +pub use openshell_otel::{compute_driver_rpc_layer, compute_driver_rpc_operation}; +use tracing::Subscriber; use tracing_subscriber::registry::LookupSpan; const SERVICE_NAME: &str = "openshell-driver-podman"; const INSTRUMENTATION_SCOPE: &str = "openshell-driver-podman"; -const COMPUTE_DRIVER_SERVICE: &str = "openshell.compute.v1.ComputeDriver"; pub const IN_PROCESS_TARGET_PREFIX: &str = "openshell_driver_podman"; -pub fn compute_driver_rpc_layer() -> TraceLayer< - GrpcMakeClassifier, - ComputeDriverRpcSpan, - (), - RecordGrpcStatus, - (), - RecordGrpcStatus, - RecordGrpcFailure, -> { - TraceLayer::new_for_grpc() - .make_span_with(ComputeDriverRpcSpan) - .on_request(()) - .on_response(RecordGrpcStatus) - .on_body_chunk(()) - .on_eos(RecordGrpcStatus) - .on_failure(RecordGrpcFailure) -} - -#[derive(Debug, Clone, Copy)] -pub struct ComputeDriverRpcSpan; - -impl MakeSpan for ComputeDriverRpcSpan { - fn make_span(&mut self, request: &Request) -> Span { - let (operation, method) = compute_driver_rpc_operation(request.uri().path()); - let span = tracing::info_span!( - "driver_rpc", - otel.name = operation, - otel.kind = "server", - otel.status_code = tracing::field::Empty, - rpc.system = "grpc", - rpc.service = COMPUTE_DRIVER_SERVICE, - rpc.method = method, - rpc.grpc.status_code = tracing::field::Empty, - ); - let parent = TraceContextPropagator::new().extract_with_context( - &opentelemetry::Context::new(), - &HeaderMapExtractor::new(request.headers()), - ); - if parent.span().span_context().is_valid() { - let _ = span.set_parent(parent); - } - span - } -} - -pub(crate) fn compute_driver_rpc_operation(path: &str) -> (&'static str, &'static str) { - match path.rsplit('/').next() { - Some("GetCapabilities") => ("driver.get_capabilities", "get_capabilities"), - Some("GetGatewayListenerRequirements") => ( - "driver.get_gateway_listener_requirements", - "get_gateway_listener_requirements", - ), - Some("ValidateSandboxCreate") => { - ("driver.validate_sandbox_create", "validate_sandbox_create") - } - Some("CreateSandbox") => ("driver.create_sandbox", "create_sandbox"), - Some("GetSandbox") => ("driver.get_sandbox", "get_sandbox"), - Some("ListSandboxes") => ("driver.list_sandboxes", "list_sandboxes"), - Some("StopSandbox") => ("driver.stop_sandbox", "stop_sandbox"), - Some("StartSandbox") => ("driver.start_sandbox", "start_sandbox"), - Some("DeleteSandbox") => ("driver.delete_sandbox", "delete_sandbox"), - Some("WatchSandboxes") => ("driver.watch_sandboxes", "watch_sandboxes"), - Some("EnsureWorkspace") => ("driver.ensure_workspace", "ensure_workspace"), - Some("DeleteWorkspace") => ("driver.delete_workspace", "delete_workspace"), - _ => ("driver.unknown", "unknown"), - } -} - #[must_use] pub fn provider_for(endpoint: Option<&str>) -> (Option, Option) { openshell_otel::provider_for(endpoint.map(|endpoint| OtlpTraceConfig { @@ -200,7 +123,7 @@ mod tests { let provider = provider.expect("provider"); let subscriber = tracing_subscriber::registry().with(super::layer(&provider)); tracing::subscriber::with_default(subscriber, || { - let span = tracing::info_span!("podman.create_sandbox", sandbox.id = "sb-otlp"); + let span = tracing::info_span!("podman.create", sandbox.id = "sb-otlp"); drop(span.enter()); drop(span); }); @@ -213,7 +136,7 @@ mod tests { received .spans .iter() - .any(|span| span.name == "podman.create_sandbox") + .any(|span| span.name == "podman.create") ); assert!( received diff --git a/crates/openshell-driver-vm/src/otel_tracing.rs b/crates/openshell-driver-vm/src/otel_tracing.rs index e956e8310d..f63066bb88 100644 --- a/crates/openshell-driver-vm/src/otel_tracing.rs +++ b/crates/openshell-driver-vm/src/otel_tracing.rs @@ -3,90 +3,13 @@ //! OpenTelemetry trace exporting. -use http::Request; -use openshell_otel::{ - HeaderMapExtractor, OtlpTraceConfig, RecordGrpcFailure, RecordGrpcStatus, SdkTracerProvider, - ServiceName, SetupError, -}; -use opentelemetry::propagation::TextMapPropagator; -use opentelemetry::trace::TraceContextExt as _; -use opentelemetry_sdk::propagation::TraceContextPropagator; -use tower_http::trace::{GrpcMakeClassifier, MakeSpan, TraceLayer}; -use tracing::{Span, Subscriber}; -use tracing_opentelemetry::OpenTelemetrySpanExt as _; +use openshell_otel::{OtlpTraceConfig, SdkTracerProvider, ServiceName, SetupError}; +pub use openshell_otel::{compute_driver_rpc_layer, compute_driver_rpc_operation}; +use tracing::Subscriber; use tracing_subscriber::registry::LookupSpan; const SERVICE_NAME: &str = "openshell-driver-vm"; const INSTRUMENTATION_SCOPE: &str = "openshell-driver-vm"; -const COMPUTE_DRIVER_SERVICE: &str = "openshell.compute.v1.ComputeDriver"; - -/// Trace every inbound compute-driver RPC at the tonic service boundary. -pub fn compute_driver_rpc_layer() -> TraceLayer< - GrpcMakeClassifier, - ComputeDriverRpcSpan, - (), - RecordGrpcStatus, - (), - RecordGrpcStatus, - RecordGrpcFailure, -> { - TraceLayer::new_for_grpc() - .make_span_with(ComputeDriverRpcSpan) - .on_request(()) - .on_response(RecordGrpcStatus) - .on_body_chunk(()) - .on_eos(RecordGrpcStatus) - .on_failure(RecordGrpcFailure) -} - -/// Creates the server span for an inbound compute-driver request. -#[derive(Debug, Clone, Copy)] -pub struct ComputeDriverRpcSpan; - -impl MakeSpan for ComputeDriverRpcSpan { - fn make_span(&mut self, request: &Request) -> Span { - let (operation, method) = compute_driver_rpc_operation(request.uri().path()); - let span = tracing::info_span!( - "driver_rpc", - otel.name = operation, - otel.kind = "server", - otel.status_code = tracing::field::Empty, - rpc.system = "grpc", - rpc.service = COMPUTE_DRIVER_SERVICE, - rpc.method = method, - rpc.grpc.status_code = tracing::field::Empty, - ); - let parent = TraceContextPropagator::new().extract_with_context( - &opentelemetry::Context::new(), - &HeaderMapExtractor::new(request.headers()), - ); - if parent.span().span_context().is_valid() { - let _ = span.set_parent(parent); - } - span - } -} - -fn compute_driver_rpc_operation(path: &str) -> (&'static str, &'static str) { - match path.rsplit('/').next() { - Some("GetCapabilities") => ("driver.get_capabilities", "get_capabilities"), - Some("GetGatewayListenerRequirements") => ( - "driver.get_gateway_listener_requirements", - "get_gateway_listener_requirements", - ), - Some("ValidateSandboxCreate") => { - ("driver.validate_sandbox_create", "validate_sandbox_create") - } - Some("CreateSandbox") => ("driver.create_sandbox", "create_sandbox"), - Some("GetSandbox") => ("driver.get_sandbox", "get_sandbox"), - Some("ListSandboxes") => ("driver.list_sandboxes", "list_sandboxes"), - Some("StopSandbox") => ("driver.stop_sandbox", "stop_sandbox"), - Some("StartSandbox") => ("driver.start_sandbox", "start_sandbox"), - Some("DeleteSandbox") => ("driver.delete_sandbox", "delete_sandbox"), - Some("WatchSandboxes") => ("driver.watch_sandboxes", "watch_sandboxes"), - _ => ("driver.unknown", "unknown"), - } -} /// Build a tracer provider for the configured OTLP/gRPC endpoint. #[must_use] diff --git a/crates/openshell-otel/src/grpc.rs b/crates/openshell-otel/src/grpc.rs index 564f889aeb..3c38ddd74a 100644 --- a/crates/openshell-otel/src/grpc.rs +++ b/crates/openshell-otel/src/grpc.rs @@ -3,9 +3,87 @@ //! Shared gRPC tracing adapters. +use http::Request; +use opentelemetry::propagation::TextMapPropagator as _; +use opentelemetry::trace::TraceContextExt as _; +use opentelemetry_sdk::propagation::TraceContextPropagator; use tower_http::classify::GrpcFailureClass; -use tower_http::trace::{OnEos, OnFailure, OnResponse}; +use tower_http::trace::{GrpcMakeClassifier, MakeSpan, OnEos, OnFailure, OnResponse, TraceLayer}; use tracing::Span; +use tracing_opentelemetry::OpenTelemetrySpanExt as _; + +const COMPUTE_DRIVER_SERVICE: &str = "openshell.compute.v1.ComputeDriver"; + +/// Trace every inbound compute-driver RPC at the tonic service boundary. +pub fn compute_driver_rpc_layer() -> TraceLayer< + GrpcMakeClassifier, + ComputeDriverRpcSpan, + (), + RecordGrpcStatus, + (), + RecordGrpcStatus, + RecordGrpcFailure, +> { + TraceLayer::new_for_grpc() + .make_span_with(ComputeDriverRpcSpan) + .on_request(()) + .on_response(RecordGrpcStatus) + .on_body_chunk(()) + .on_eos(RecordGrpcStatus) + .on_failure(RecordGrpcFailure) +} + +/// Creates a bounded server span for an inbound compute-driver request. +#[derive(Debug, Clone, Copy)] +pub struct ComputeDriverRpcSpan; + +impl MakeSpan for ComputeDriverRpcSpan { + fn make_span(&mut self, request: &Request) -> Span { + let (operation, method) = compute_driver_rpc_operation(request.uri().path()); + let span = tracing::info_span!( + "driver_rpc", + otel.name = operation, + otel.kind = "server", + otel.status_code = tracing::field::Empty, + rpc.system = "grpc", + rpc.service = COMPUTE_DRIVER_SERVICE, + rpc.method = method, + rpc.grpc.status_code = tracing::field::Empty, + ); + let parent = TraceContextPropagator::new().extract_with_context( + &opentelemetry::Context::new(), + &crate::HeaderMapExtractor::new(request.headers()), + ); + if parent.span().span_context().is_valid() { + let _ = span.set_parent(parent); + } + span + } +} + +/// Maps the generated compute-driver RPC schema to low-cardinality span names. +pub fn compute_driver_rpc_operation(path: &str) -> (&'static str, &'static str) { + match path.rsplit('/').next() { + Some("GetCapabilities") => ("driver.get_capabilities", "get_capabilities"), + Some("GetGatewayListenerRequirements") => ( + "driver.get_gateway_listener_requirements", + "get_gateway_listener_requirements", + ), + Some("ValidateSandboxCreate") => { + ("driver.validate_sandbox_create", "validate_sandbox_create") + } + Some("CreateSandbox") => ("driver.create_sandbox", "create_sandbox"), + Some("GetSandbox") => ("driver.get_sandbox", "get_sandbox"), + Some("ListSandboxes") => ("driver.list_sandboxes", "list_sandboxes"), + Some("StopSandbox") => ("driver.stop_sandbox", "stop_sandbox"), + Some("StartSandbox") => ("driver.start_sandbox", "start_sandbox"), + Some("DeleteSandbox") => ("driver.delete_sandbox", "delete_sandbox"), + Some("WatchSandboxes") => ("driver.watch_sandboxes", "watch_sandboxes"), + Some("EnsureWorkspace") => ("driver.ensure_workspace", "ensure_workspace"), + Some("DeleteWorkspace") => ("driver.delete_workspace", "delete_workspace"), + _ => ("driver.unknown", "unknown"), + } +} /// Records a non-OK gRPC outcome on the request span. #[derive(Debug, Clone, Copy)] @@ -70,6 +148,59 @@ mod tests { use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; use tracing_subscriber::layer::SubscriberExt as _; + #[test] + fn compute_driver_rpc_names_are_explicitly_mapped_and_schema_bounded() { + for (rpc, operation, method) in [ + ( + "GetCapabilities", + "driver.get_capabilities", + "get_capabilities", + ), + ( + "GetGatewayListenerRequirements", + "driver.get_gateway_listener_requirements", + "get_gateway_listener_requirements", + ), + ( + "ValidateSandboxCreate", + "driver.validate_sandbox_create", + "validate_sandbox_create", + ), + ("CreateSandbox", "driver.create_sandbox", "create_sandbox"), + ("GetSandbox", "driver.get_sandbox", "get_sandbox"), + ("ListSandboxes", "driver.list_sandboxes", "list_sandboxes"), + ("StopSandbox", "driver.stop_sandbox", "stop_sandbox"), + ("StartSandbox", "driver.start_sandbox", "start_sandbox"), + ("DeleteSandbox", "driver.delete_sandbox", "delete_sandbox"), + ( + "WatchSandboxes", + "driver.watch_sandboxes", + "watch_sandboxes", + ), + ( + "EnsureWorkspace", + "driver.ensure_workspace", + "ensure_workspace", + ), + ( + "DeleteWorkspace", + "driver.delete_workspace", + "delete_workspace", + ), + ] { + assert_eq!( + compute_driver_rpc_operation(&format!("/openshell.compute.v1.ComputeDriver/{rpc}")), + (operation, method) + ); + } + assert_eq!( + compute_driver_rpc_operation( + "/openshell.compute.v1.ComputeDriver/AttackerControlled12345" + ), + ("driver.unknown", "unknown") + ); + } + #[test] fn grpc_status_records_and_marks_non_ok_trailer_status() { let _tracing_lock = crate::test_lock(); diff --git a/crates/openshell-otel/src/lib.rs b/crates/openshell-otel/src/lib.rs index 81d9f7bf01..30a9d29730 100644 --- a/crates/openshell-otel/src/lib.rs +++ b/crates/openshell-otel/src/lib.rs @@ -6,8 +6,13 @@ mod grpc; mod propagation; -pub use grpc::{RecordGrpcFailure, RecordGrpcStatus}; -pub use propagation::{HeaderMapExtractor, MetadataMapInjector, TraceContextInterceptor}; +pub use grpc::{ + ComputeDriverRpcSpan, RecordGrpcFailure, RecordGrpcStatus, compute_driver_rpc_layer, + compute_driver_rpc_operation, +}; +pub use propagation::{ + HeaderMapExtractor, MetadataMapInjector, TraceContextInterceptor, current_trace_context_carrier, +}; use opentelemetry::KeyValue; use opentelemetry::trace::TracerProvider as _; diff --git a/crates/openshell-otel/src/propagation.rs b/crates/openshell-otel/src/propagation.rs index 8deb2ffe20..c6e51a0a6b 100644 --- a/crates/openshell-otel/src/propagation.rs +++ b/crates/openshell-otel/src/propagation.rs @@ -3,6 +3,8 @@ //! W3C trace-context propagation for HTTP and tonic transports. +use std::collections::BTreeMap; + use http::HeaderMap; use opentelemetry::propagation::{Extractor, Injector, TextMapPropagator}; use opentelemetry_sdk::propagation::TraceContextPropagator; @@ -52,6 +54,27 @@ impl Injector for MetadataMapInjector<'_> { } } +#[derive(Debug)] +struct TraceContextMapInjector<'a>(&'a mut BTreeMap); + +impl Injector for TraceContextMapInjector<'_> { + fn set(&mut self, key: &str, value: String) { + self.0.insert(key.to_string(), value); + } +} + +/// Serialize the active span's W3C propagation fields into a string map. +/// +/// Returns `None` when the current span has no valid OpenTelemetry context. +#[must_use] +pub fn current_trace_context_carrier() -> Option> { + let context = tracing::Span::current().context(); + let mut carrier = BTreeMap::new(); + TraceContextPropagator::new() + .inject_context(&context, &mut TraceContextMapInjector(&mut carrier)); + carrier.contains_key("traceparent").then_some(carrier) +} + /// Injects the active W3C trace context into an outbound tonic request. #[derive(Debug, Clone, Copy)] pub struct TraceContextInterceptor; diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 51f05a395c..65411d28f1 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -755,7 +755,7 @@ impl ComputeRuntime { .await .map_err(|err| ComputeError::Message(err.to_string()))?; let operator_allowlist_arc = driver.operator_allowlist().cloned(); - let driver: SharedComputeDriver = Arc::new(KubernetesDriverService::new(driver)); + let driver: SharedComputeDriver = Arc::new(KubernetesDriverService::new_in_process(driver)); let runtime = Self::from_driver( ComputeDriverKind::Kubernetes.as_str().to_string(), driver, diff --git a/crates/openshell-server/src/tracing_setup.rs b/crates/openshell-server/src/tracing_setup.rs index bbd98f16c6..c5b8d9f4a3 100644 --- a/crates/openshell-server/src/tracing_setup.rs +++ b/crates/openshell-server/src/tracing_setup.rs @@ -41,6 +41,8 @@ enum InProcessDriverTracing { #[cfg(feature = "in-tree-compute-drivers")] Docker, #[cfg(feature = "in-tree-compute-drivers")] + Kubernetes, + #[cfg(feature = "in-tree-compute-drivers")] Podman, } @@ -49,6 +51,7 @@ impl InProcessDriverTracing { fn target_prefix(self) -> &'static str { match self { Self::Docker => openshell_driver_docker::otel_tracing::IN_PROCESS_TARGET_PREFIX, + Self::Kubernetes => openshell_driver_kubernetes::otel_tracing::IN_PROCESS_TARGET_PREFIX, Self::Podman => openshell_driver_podman::otel_tracing::IN_PROCESS_TARGET_PREFIX, } } @@ -63,6 +66,9 @@ fn in_process_driver_tracing(driver: &ConfiguredComputeDriver) -> Option { Some(InProcessDriverTracing::Podman) } + ConfiguredComputeDriver::Registered(registration) if registration.name == "kubernetes" => { + Some(InProcessDriverTracing::Kubernetes) + } _ => None, } #[cfg(not(feature = "in-tree-compute-drivers"))] @@ -93,6 +99,9 @@ fn in_process_driver_provider( Some(InProcessDriverTracing::Docker) => { openshell_driver_docker::otel_tracing::provider_for(endpoint) } + Some(InProcessDriverTracing::Kubernetes) => { + openshell_driver_kubernetes::otel_tracing::provider_for(endpoint) + } Some(InProcessDriverTracing::Podman) => { openshell_driver_podman::otel_tracing::provider_for(endpoint) } @@ -120,6 +129,9 @@ where Some(InProcessDriverTracing::Docker) => { openshell_driver_docker::otel_tracing::in_process_layer(provider) } + Some(InProcessDriverTracing::Kubernetes) => { + openshell_driver_kubernetes::otel_tracing::in_process_layer(provider) + } Some(InProcessDriverTracing::Podman) => { openshell_driver_podman::otel_tracing::in_process_layer(provider) } @@ -185,7 +197,7 @@ mod tests { #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] #[test] - fn in_process_driver_tracing_selects_docker_and_podman() { + fn in_process_driver_tracing_selects_registered_compute_drivers() { let registry = crate::install_default_compute_drivers(); let registered = |name| { ConfiguredComputeDriver::Registered( @@ -203,7 +215,10 @@ mod tests { in_process_driver_tracing(®istered("docker")), Some(InProcessDriverTracing::Docker) ); - assert_eq!(in_process_driver_tracing(®istered("kubernetes")), None); + assert_eq!( + in_process_driver_tracing(®istered("kubernetes")), + Some(InProcessDriverTracing::Kubernetes) + ); assert_eq!( in_process_driver_tracing(&ConfiguredComputeDriver::Remote { name: "custom".to_string(), diff --git a/deploy/helm/openshell/README.md b/deploy/helm/openshell/README.md index 5879d617ff..25550f3cab 100644 --- a/deploy/helm/openshell/README.md +++ b/deploy/helm/openshell/README.md @@ -261,6 +261,8 @@ discovery endpoint or its TLS CA. | server.oidc.rolesClaim | string | `""` | Dot-separated path to the roles array in the JWT claims. Keycloak: "realm_access.roles", Entra ID: "roles", Okta: "groups". | | server.oidc.scopesClaim | string | `""` | Dot-separated path to the scopes array in the JWT claims. | | server.oidc.userRole | string | `""` | Role name for standard user access. | +| server.otlp.endpoint | string | `""` | OTLP/gRPC collector endpoint, conventionally using port 4317. | +| server.otlp.serviceName | string | `""` | Gateway OpenTelemetry service name. Empty uses openshell-gateway. | | server.policyValidationFailureMode | string | `"fail_closed"` | Posture when a candidate sandbox policy fails validation. `fail_closed` deactivates the previous policy; `retain_last_valid` keeps it active. | | server.providerTokenGrants.spiffe.enabled | bool | `false` | Mount the SPIFFE Workload API socket into gateway and sandbox pods for dynamic provider token grants. | | server.providerTokenGrants.spiffe.workloadApiSocketPath | string | `"/spiffe-workload-api/spire-agent.sock"` | Path to the SPIFFE Workload API socket mounted into gateway and sandbox pods. | diff --git a/deploy/helm/openshell/ci/values-skaffold.yaml b/deploy/helm/openshell/ci/values-skaffold.yaml index 795c056f68..15ff87554e 100644 --- a/deploy/helm/openshell/ci/values-skaffold.yaml +++ b/deploy/helm/openshell/ci/values-skaffold.yaml @@ -4,6 +4,8 @@ # Merge with values.yaml for Skaffold-driven local image builds (see skaffold.yaml). server: sandboxImagePullPolicy: IfNotPresent + otlp: + endpoint: http://openshell-collector.observability.svc.cluster.local:4317 # Comment out to enforce mTLS (uses PKI secrets generated by pkiInitJob). disableTls: true auth: diff --git a/deploy/helm/openshell/templates/gateway-config.yaml b/deploy/helm/openshell/templates/gateway-config.yaml index 9d24dbd917..60420cc535 100644 --- a/deploy/helm/openshell/templates/gateway-config.yaml +++ b/deploy/helm/openshell/templates/gateway-config.yaml @@ -13,6 +13,7 @@ One value is intentionally NOT rendered here: --db-url arg for SQLite */}} {{- $credentialDrivers := list -}} +{{- $otlp := .Values.server.otlp | default dict -}} {{- if .Values.server.credentialDrivers.kubernetesSecrets.enabled -}} {{- $credentialDrivers = append $credentialDrivers "kubernetes-secrets" -}} {{- end -}} @@ -88,6 +89,15 @@ data: {{- fail "server.grpcRateLimit requires both requests and windowSeconds to be positive to enable rate limiting, or both 0/unset to disable it" }} {{- end }} + {{- if $otlp.endpoint }} + + [openshell.gateway.otlp] + endpoint = {{ $otlp.endpoint | quote }} + {{- if $otlp.serviceName }} + service_name = {{ $otlp.serviceName | quote }} + {{- end }} + {{- end }} + {{- if not .Values.server.disableTls }} [openshell.gateway.tls] diff --git a/deploy/helm/openshell/tests/gateway_config_test.yaml b/deploy/helm/openshell/tests/gateway_config_test.yaml index eaa2140862..10e14d6554 100644 --- a/deploy/helm/openshell/tests/gateway_config_test.yaml +++ b/deploy/helm/openshell/tests/gateway_config_test.yaml @@ -62,6 +62,32 @@ tests: name: OPENSHELL_TELEMETRY_ENABLED value: "false" + - it: renders OTLP tracing configuration when configured + template: templates/gateway-config.yaml + set: + server.otlp.endpoint: http://otel-collector.observability.svc:4317 + server.otlp.serviceName: production-gateway + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?ms)\[openshell\.gateway\.otlp\].*?endpoint\s*=\s*"http://otel-collector\.observability\.svc:4317".*?service_name\s*=\s*"production-gateway"' + + - it: omits OTLP tracing configuration by default + template: templates/gateway-config.yaml + asserts: + - notMatchRegex: + path: data["gateway.toml"] + pattern: '\[openshell\.gateway\.otlp\]' + + - it: treats a null OTLP map as disabled + template: templates/gateway-config.yaml + set: + server.otlp: null + asserts: + - notMatchRegex: + path: data["gateway.toml"] + pattern: '\[openshell\.gateway\.otlp\]' + - it: mounts the OIDC CA bundle when TLS is disabled template: templates/statefulset.yaml set: diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index f2c3c28dd8..6c0889773c 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -191,6 +191,12 @@ affinity: {} server: # -- Gateway log level. logLevel: info + # OpenTelemetry trace export over OTLP/gRPC. Leave endpoint empty to disable. + otlp: + # -- OTLP/gRPC collector endpoint, conventionally using port 4317. + endpoint: "" + # -- Gateway OpenTelemetry service name. Empty uses openshell-gateway. + serviceName: "" # -- Enable anonymous OpenShell telemetry from the gateway and the sandbox # supervisors it launches. telemetryEnabled: true diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index d1fed9ae44..a066b14f01 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -234,6 +234,22 @@ Only OpenTelemetry traces are exported. Inbound gRPC and HTTP requests produce s The gateway forwards the OTLP configuration and W3C trace context to managed external drivers. Built-in Docker and Podman drivers also export their spans to the same collector through dedicated in-process providers. Driver spans retain the gateway trace context and use a distinct service name such as `openshell-driver-docker` or `openshell-driver-podman`. +For Helm deployments, set `server.otlp.endpoint` to render this table. The +optional `server.otlp.serviceName` value overrides the gateway service name; +driver service names remain fixed. + +The local `mise run helm:k3s:create` workflow installs a trace collector and UI, +enables Agent Sandbox controller tracing on supported releases, and configures +both the controller and the existing Skaffold deployment to export traces to it +automatically. +Run `mise run helm:k3s:forward` to forward OTLP/gRPC to local port `4317` and +the trace UI to local port `18888`. When Skaffold has deployed a Kubernetes +gateway, the task also forwards it to local port `8090`; otherwise it continues +with the collector ports only. The Skaffold run task registers the forwarded +gateway under the worktree-specific k3d cluster name; select it with +`openshell gateway select `. The local Podman, Docker, and VM gateway +tasks export to the forwarded receiver automatically. + ### Tuning This table decides whether and where to export. How the SDK exports is controlled by the standard OpenTelemetry environment variables, which the gateway reads through the SDK rather than mirroring as TOML keys: diff --git a/tasks/helm.toml b/tasks/helm.toml index 7f78ce4a8f..f7db8f9c57 100644 --- a/tasks/helm.toml +++ b/tasks/helm.toml @@ -108,7 +108,7 @@ hide = true # should install k3d explicitly or point workflows at an existing/kind cluster. ["helm:k3s:create"] -description = "Create a local k3s cluster with k3d and merge kubeconfig (macOS via mise; Linux requires explicit k3d install; use with helm:skaffold:dev)" +description = "Create a local k3s cluster with agent-sandbox and an OTLP trace collector, then merge kubeconfig" run = "tasks/scripts/helm-k3s-local.sh create" ["helm:k3s:delete"] @@ -131,7 +131,7 @@ run = "tasks/scripts/helm-k3s-local.sh status" hide = true ["helm:k3s:forward"] -description = "Forward the local Kubernetes gateway to port 8090" +description = "Forward collector OTLP ingest (4317), trace UI (18888), and the local gateway (8090) when deployed" run = "tasks/scripts/helm-k3s-local.sh forward" # Install Envoy Gateway's GatewayClass into the cluster diff --git a/tasks/scripts/gateway-docker.sh b/tasks/scripts/gateway-docker.sh index ef51f37182..6e7b85634a 100644 --- a/tasks/scripts/gateway-docker.sh +++ b/tasks/scripts/gateway-docker.sh @@ -202,6 +202,9 @@ version = 1 compute_drivers = ["docker"] disable_tls = true +[openshell.gateway.otlp] +endpoint = "http://127.0.0.1:4317" + [openshell.gateway.auth] allow_unauthenticated_users = true diff --git a/tasks/scripts/gateway-podman.sh b/tasks/scripts/gateway-podman.sh index 2b70e3a85b..8b6d98ca0c 100644 --- a/tasks/scripts/gateway-podman.sh +++ b/tasks/scripts/gateway-podman.sh @@ -207,6 +207,9 @@ compute_drivers = ["podman"] default_image = "${SANDBOX_IMAGE}" disable_tls = true +[openshell.gateway.otlp] +endpoint = "http://127.0.0.1:4317" + [openshell.gateway.auth] allow_unauthenticated_users = true diff --git a/tasks/scripts/gateway-vm.sh b/tasks/scripts/gateway-vm.sh index 22ba1b039f..c4aac270da 100755 --- a/tasks/scripts/gateway-vm.sh +++ b/tasks/scripts/gateway-vm.sh @@ -327,6 +327,9 @@ version = 1 compute_drivers = ["vm"] disable_tls = ${DISABLE_TLS} +[openshell.gateway.otlp] +endpoint = "http://127.0.0.1:4317" + [openshell.gateway.auth] allow_unauthenticated_users = true diff --git a/tasks/scripts/gateway.sh b/tasks/scripts/gateway.sh index 604476a4b6..895e545862 100644 --- a/tasks/scripts/gateway.sh +++ b/tasks/scripts/gateway.sh @@ -251,6 +251,9 @@ compute_drivers = ["${DRIVER}"] default_image = "${SANDBOX_IMAGE}" disable_tls = true +[openshell.gateway.otlp] +endpoint = "http://127.0.0.1:4317" + [openshell.gateway.auth] allow_unauthenticated_users = true diff --git a/tasks/scripts/helm-k3s-local.sh b/tasks/scripts/helm-k3s-local.sh index 2234497b44..39fed2f4b4 100755 --- a/tasks/scripts/helm-k3s-local.sh +++ b/tasks/scripts/helm-k3s-local.sh @@ -38,12 +38,18 @@ PRELOAD_SANDBOX_IMAGE="${HELM_K3S_PRELOAD_SANDBOX_IMAGE-${DEFAULT_SANDBOX_PRELOA # exercise the v1alpha1 controller release. AGENT_SANDBOX_VERSION="${AGENT_SANDBOX_VERSION:-v0.5.0}" +# Local OTLP receiver and trace UI. The current Aspire implementation accepts +# OTLP/gRPC directly, so the development cluster needs only one deployment. +OBSERVABILITY_NAMESPACE="observability" +COLLECTOR_IMAGE="${HELM_K3S_COLLECTOR_IMAGE:-mcr.microsoft.com/dotnet/aspire-dashboard:latest}" +COLLECTOR_HEALTH_TIMEOUT="${HELM_K3S_COLLECTOR_HEALTH_TIMEOUT:-120}" # Host endpoint registered for the Skaffold-deployed gateway. Derive the # gateway name from the worktree-specific cluster name so concurrent local # clusters do not overwrite each other's CLI metadata. GATEWAY_NAMESPACE="openshell" GATEWAY_HOST_PORT="${HELM_K3S_GATEWAY_HOST_PORT:-8090}" GATEWAY_NAME="${HELM_K3S_GATEWAY_NAME:-${CLUSTER_NAME}}" +FORWARD_PIDS=() default_kubeconfig="${ROOT}/kubeconfig" if [[ -n "${HELM_K3S_KUBECONFIG:-}" ]]; then @@ -72,6 +78,10 @@ Environment: HELM_K3S_PRELOAD_SANDBOX_IMAGE Sandbox image to docker pull and import into k3d (default: ${DEFAULT_SANDBOX_PRELOAD_IMAGE}; set empty to skip) + HELM_K3S_COLLECTOR_IMAGE Image used for the local OTLP receiver and trace UI + (default: mcr.microsoft.com/dotnet/aspire-dashboard:latest) + HELM_K3S_COLLECTOR_HEALTH_TIMEOUT + Seconds to wait for collector readiness (default: 120) HELM_K3S_GATEWAY_HOST_PORT Host port forwarded to the gateway (default: 8090) HELM_K3S_GATEWAY_NAME CLI gateway registration name (default: worktree-specific k3d cluster name) @@ -156,6 +166,116 @@ apply_base_manifests() { kubectl --kubeconfig="${KUBECONFIG_TARGET}" apply -f "${base}/manifest.yaml" } +install_trace_collector() { + require_kubectl + + echo "Installing local trace collector in namespace '${OBSERVABILITY_NAMESPACE}'..." + kubectl --kubeconfig="${KUBECONFIG_TARGET}" \ + create namespace "${OBSERVABILITY_NAMESPACE}" --dry-run=client -o yaml \ + | kubectl --kubeconfig="${KUBECONFIG_TARGET}" apply -f - + + kubectl --kubeconfig="${KUBECONFIG_TARGET}" apply -f - </dev/null || true + done + for pid in "${FORWARD_PIDS[@]}"; do + wait "${pid}" 2>/dev/null || true + done +} + cmd_forward() { require_supported_os require_kubectl - echo "Forwarding gateway to http://127.0.0.1:${GATEWAY_HOST_PORT}" - echo "Press Ctrl-C to stop." kubectl \ + --kubeconfig="${KUBECONFIG_TARGET}" \ + --context="$(k3d_context_name)" \ + --namespace="${OBSERVABILITY_NAMESPACE}" \ + get service/openshell-collector >/dev/null + + echo "Forwarding collector OTLP/gRPC to http://127.0.0.1:4317" + echo "Forwarding trace UI to http://127.0.0.1:18888" + + trap cleanup_forwards EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + + if kubectl \ --kubeconfig="${KUBECONFIG_TARGET}" \ --context="$(k3d_context_name)" \ --namespace="${GATEWAY_NAMESPACE}" \ - port-forward service/openshell "${GATEWAY_HOST_PORT}:8080" + get service/openshell >/dev/null 2>&1; then + echo "Forwarding gateway to http://127.0.0.1:${GATEWAY_HOST_PORT}" + kubectl \ + --kubeconfig="${KUBECONFIG_TARGET}" \ + --context="$(k3d_context_name)" \ + --namespace="${GATEWAY_NAMESPACE}" \ + port-forward service/openshell "${GATEWAY_HOST_PORT}:8080" & + FORWARD_PIDS+=("$!") + else + echo "No Kubernetes gateway service found; forwarding collector ports only." + fi + + kubectl \ + --kubeconfig="${KUBECONFIG_TARGET}" \ + --context="$(k3d_context_name)" \ + --namespace="${OBSERVABILITY_NAMESPACE}" \ + port-forward service/openshell-collector 4317:4317 18888:18888 & + FORWARD_PIDS+=("$!") + + echo "Press Ctrl-C to stop." + + local pid status + while true; do + for pid in "${FORWARD_PIDS[@]}"; do + if ! kill -0 "${pid}" 2>/dev/null; then + status=0 + wait "${pid}" || status=$? + if [[ ${status} -eq 0 ]]; then + status=1 + fi + echo "error: a port-forward process exited; stopping the remaining forwards" >&2 + return "${status}" + fi + done + sleep 1 + done } main() { From 0618ab0465680a569b2f99a869ae04f468ca3290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?William=20Tr=C3=A9panier?= <39393794+wtrep@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:29:56 +0000 Subject: [PATCH 028/505] fix(supervisor): log unmatched L7 route denials (#2916) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requests denied by relay_with_route_selection because no L7 endpoint path matched returned a 403 without emitting an OCSF event or recording policy activity, leaving the deny invisible to operators and telemetry. Every other early-return deny in that loop already logs. Emit an HTTP Activity deny event and an l7_policy activity record before writing the denial. Split event construction out of emit_l7_request_log so the event shape is directly testable, and narrow both helpers to take the request action rather than the full L7RequestInfo, which they only used for that field. Signed-off-by: William Trépanier --- .../src/l7/relay.rs | 154 +++++++++++++++--- 1 file changed, 134 insertions(+), 20 deletions(-) diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 44f3f8023a..2697fedb3c 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -559,11 +559,13 @@ where } }; let Some(config) = select_l7_config_for_path(configs, &route_target) else { + let reason = "no L7 endpoint path matched request"; + emit_l7_request_log(ctx, &req.action, &route_target, "deny", "l7", reason, None); crate::l7::rest::RestProvider::default() .deny_with_redacted_target( &req, &ctx.policy_name, - "no L7 endpoint path matched request", + reason, client, None, Some(crate::l7::rest::DenyResponseContext::from_l7_context(ctx)), @@ -724,12 +726,12 @@ where l7_protocol_log_summary(graphql_info.as_ref(), jsonrpc_info.as_ref()); emit_l7_request_log( ctx, - &request_info, + &request_info.action, &redacted_target, decision_str, engine_type, &reason, - &protocol_summary, + protocol_summary.as_deref(), ); if allowed || (config.enforcement == EnforcementMode::Audit && !force_deny) { @@ -935,13 +937,35 @@ fn select_l7_config_for_path<'a>( fn emit_l7_request_log( ctx: &L7EvalContext, - request_info: &L7RequestInfo, + action: &str, redacted_target: &str, decision_str: &str, engine_type: &str, reason: &str, - protocol_summary: &str, + protocol_summary: Option<&str>, ) { + let event = build_l7_request_event( + ctx, + action, + redacted_target, + decision_str, + engine_type, + reason, + protocol_summary, + ); + ocsf_emit!(event); + emit_activity(ctx, decision_str == "deny", "l7_policy"); +} + +fn build_l7_request_event( + ctx: &L7EvalContext, + action: &str, + redacted_target: &str, + decision_str: &str, + engine_type: &str, + reason: &str, + protocol_summary: Option<&str>, +) -> openshell_ocsf::OcsfEvent { let (action_id, disposition_id, severity) = match decision_str { "deny" => (ActionId::Denied, DispositionId::Blocked, SeverityId::Medium), "allow" | "audit" => ( @@ -955,43 +979,44 @@ fn emit_l7_request_log( SeverityId::Informational, ), }; - let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let protocol_suffix = + protocol_summary.map_or_else(String::new, |summary| format!(" {summary}")); + let message = format!( + "L7_REQUEST {decision_str} {action} {}:{}{}{protocol_suffix} reason={reason}", + ctx.host, ctx.port, redacted_target, + ); + HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Other) .action(action_id) .disposition(disposition_id) .severity(severity) .http_request(HttpRequest::new( - &request_info.action, + action, OcsfUrl::new("http", &ctx.host, redacted_target, ctx.port), )) .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) .firewall_rule(&ctx.policy_name, engine_type) - .message(format!( - "L7_REQUEST {decision_str} {} {}:{}{}{} reason={}", - request_info.action, ctx.host, ctx.port, redacted_target, protocol_summary, reason, - )) - .build(); - ocsf_emit!(event); - emit_activity(ctx, decision_str == "deny", "l7_policy"); + .message(message) + .build() } fn l7_protocol_log_summary( graphql_info: Option<&crate::l7::graphql::GraphqlRequestInfo>, jsonrpc_info: Option<&crate::l7::jsonrpc::JsonRpcRequestInfo>, -) -> String { +) -> Option { if let Some(info) = graphql_info { - return format!(" {}", crate::l7::graphql::log_summary(info)); + return Some(crate::l7::graphql::log_summary(info)); } if let Some(info) = jsonrpc_info { - return format!( - " rule_methods={} tools={}", + return Some(format!( + "rule_methods={} tools={}", rule_method_names_for_log(info), tool_names_for_log(info) - ); + )); } - String::new() + None } fn emit_activity(ctx: &L7EvalContext, denied: bool, deny_group: &'static str) { @@ -7360,6 +7385,95 @@ network_policies: } } + #[test] + fn unmatched_route_path_builds_denied_http_activity_event() { + let ctx = L7EvalContext { + host: "gateway.example.test".into(), + port: 443, + policy_name: "route_api".into(), + ..Default::default() + }; + + let event = build_l7_request_event( + &ctx, + "GET", + "/other", + "deny", + "l7", + "no L7 endpoint path matched request", + None, + ); + + assert_eq!(event.class_uid(), 4002); + assert_eq!(event.base().severity, SeverityId::Medium); + assert_eq!( + event.format_shorthand(), + "HTTP:GET [MED] DENIED GET http://gateway.example.test:443/other [policy:route_api engine:l7] [reason:L7_REQUEST deny GET gateway.example.test:443/other reason=no L7 endpoint path matched request]" + ); + } + + #[tokio::test] + async fn route_selected_unmatched_path_emits_denied_policy_activity() { + let engine = OpaEngine::from_strings(TEST_POLICY, ENCODED_SLASH_SCOPING_POLICY).unwrap(); + let tunnel_engine = engine + .clone_engine_for_tunnel(engine.current_generation()) + .unwrap(); + let configs = encoded_slash_scoping_configs(); + let (activity_tx, mut activity_rx) = tokio::sync::mpsc::channel(1); + let ctx = L7EvalContext { + activity_tx: Some(activity_tx), + ..encoded_slash_scoping_ctx() + }; + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_route_selection( + &configs, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + app.write_all( + b"GET /other HTTP/1.1\r\nHost: gateway.example.test\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + + let mut response = [0u8; 1024]; + let n = tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut response)) + .await + .expect("denial should reach client") + .unwrap(); + assert!(String::from_utf8_lossy(&response[..n]).contains("403 Forbidden")); + + let mut upstream_bytes = [0u8; 16]; + assert!(matches!( + tokio::time::timeout( + std::time::Duration::from_millis(100), + upstream.read(&mut upstream_bytes) + ) + .await, + Err(_) | Ok(Ok(0)) + )); + let activity = tokio::time::timeout(std::time::Duration::from_secs(1), activity_rx.recv()) + .await + .expect("policy activity should be emitted") + .expect("activity channel should remain open"); + assert!(activity.denied); + assert_eq!(activity.deny_group, "l7_policy"); + + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + } + /// Canonicalization runs before the matching config is known, so /// `allow_encoded_slash` is taken permissively across the whole /// host:port. The endpoint that did *not* opt in must still reject a From 56088d0811f35d01e5af8f975335c9d0e30524be Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Wed, 26 Aug 2026 23:26:25 +0000 Subject: [PATCH 029/505] fix(supervisor-network): distinguish absent policy binary from filesystem-access failure (#2948) * fix(supervisor-network): distinguish absent policy binary from filesystem-access failure Signed-off-by: Artem Lytvyn * fix(supervisor-network): resolve symlink chains at the SYMLOOP_MAX limit Signed-off-by: Artem Lytvyn --------- Signed-off-by: Artem Lytvyn --- .../openshell-supervisor-network/src/opa.rs | 513 +++++++++++++++--- 1 file changed, 438 insertions(+), 75 deletions(-) diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 35cef601d8..63aa2c2c70 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -1624,19 +1624,6 @@ fn normalize_l7_rule_aliases( } } -/// Resolve a policy binary path through the container's root filesystem. -/// -/// On Linux, `/proc//root/` provides access to the container's mount -/// namespace. If the policy path is a symlink inside the container -/// (e.g., `/usr/bin/python3` → `/usr/bin/python3.11`), returns the -/// canonical target path. Returns `None` if: -/// - Not on Linux -/// - `entrypoint_pid` is 0 (container not yet started) -/// - Path contains glob characters -/// - Path is not a symlink -/// - Resolution fails (binary doesn't exist in container) -/// - Resolved path equals the original -/// /// Normalize a path by resolving `.` and `..` components without touching /// the filesystem. Only works correctly for absolute paths. #[cfg(any(target_os = "linux", test))] @@ -1654,10 +1641,72 @@ fn normalize_path(path: &Path) -> PathBuf { result } +// Only the Linux resolver constructs the non-`Literal` variants; on other +// platforms the stub returns `Literal`, so the rest look dead there. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +#[derive(Debug, PartialEq, Eq)] +enum BinaryResolution { + /// Symlink resolved to a different canonical target — add it to policy. + Resolved(String), + /// Nothing to add: glob, pid 0, not a symlink, or already canonical. + Literal, + /// Candidate absent under an accessible process root — expected, quiet. + Absent, + /// The process root `/proc//root` itself is unreachable (pid gone or + /// denied) — actionable, and `CAP_SYS_PTRACE` guidance is relevant. + Inaccessible(std::io::ErrorKind), + /// The process root is reachable but the candidate path itself failed for a + /// non-NotFound reason (a parent component is permission-denied, or the path + /// forms a symlink loop) — actionable, but a target-path problem rather than + /// a process-root one, so `CAP_SYS_PTRACE` guidance does not apply. + CandidateInaccessible(std::io::ErrorKind), + /// A component mid symlink-chain failed to resolve. + ChainBroken(std::io::ErrorKind), +} + +impl BinaryResolution { + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + const fn classify_first_probe_error(kind: std::io::ErrorKind) -> Self { + // Callers reach this only after confirming the process root is + // reachable, so a non-NotFound failure here is specific to the + // candidate path, not the process root. + match kind { + std::io::ErrorKind::NotFound => Self::Absent, + _ => Self::CandidateInaccessible(kind), + } + } +} + +/// Resolve a policy binary path through the container's root filesystem. +/// +/// On Linux, `/proc//root/` provides access to the container's mount +/// namespace. If the policy path is a symlink inside the container +/// (e.g., `/usr/bin/python3` → `/usr/bin/python3.11`), the canonical target is +/// returned as [`BinaryResolution::Resolved`]. The outcome is classified as: +/// - [`BinaryResolution::Literal`] — not on Linux, `entrypoint_pid` is 0 +/// (container not yet started), the path contains glob characters, it is not +/// a symlink, or the resolved path equals the original. +/// - [`BinaryResolution::Absent`] — the candidate does not exist under an +/// otherwise reachable process root (expected; the caller logs it quietly). +/// - [`BinaryResolution::Inaccessible`] — `/proc//root` itself is +/// unreachable (pid gone or access denied). +/// - [`BinaryResolution::CandidateInaccessible`] — the process root is +/// reachable but the candidate path failed for a non-NotFound reason. +/// - [`BinaryResolution::ChainBroken`] — a component mid symlink-chain failed, +/// or the chain forms a cycle / exceeds the kernel symlink limit. #[cfg(target_os = "linux")] -fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> Option { +fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> BinaryResolution { if policy_path.contains('*') || entrypoint_pid == 0 { - return None; + return BinaryResolution::Literal; + } + + // Confirm the process root itself is reachable before probing candidates. + // A leaf `ENOENT` (absent candidate) and an unreachable process root + // (pid gone -> `ENOENT`, or denied -> `EACCES`) are indistinguishable at + // the leaf path, so check the root first. Any failure here is an access + // problem, not an absent candidate, and must stay actionable. + if let Err(e) = std::fs::metadata(format!("/proc/{entrypoint_pid}/root")) { + return BinaryResolution::Inaccessible(e.kind()); } // Walk the symlink chain inside the container filesystem using @@ -1667,6 +1716,11 @@ fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> Option // the specified symlink, keeping us in the container's namespace. let mut resolved = PathBuf::from(policy_path); + // Set once the walk reaches a real (non-symlink) target or a terminal + // dead end. If the iteration cap is hit while every component is still a + // symlink, this stays false and the chain is treated as broken. + let mut reached_target = false; + // Linux SYMLOOP_MAX is 40; stop before infinite loops for _ in 0..40 { let container_path = format!("/proc/{entrypoint_pid}/root{}", resolved.display()); @@ -1678,76 +1732,93 @@ fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> Option let meta = match std::fs::symlink_metadata(&container_path) { Ok(m) => m, Err(e) => { - // Only warn on the first iteration (the original policy path). - // On subsequent iterations, the intermediate target may - // legitimately not exist (broken symlink chain). + // First iteration is the original policy path: an absent + // candidate (NotFound) is expected, any other error means the + // process root itself is unreachable. Later iterations are + // chain components, so a failure there is a broken symlink + // chain. Classify without logging; the caller emits the log at + // the appropriate level. if resolved.as_os_str() == policy_path { - tracing::warn!( - "Cannot access container filesystem for symlink resolution: \ - path={policy_path} container_path={container_path} pid={entrypoint_pid} \ - error={e}. Binary paths in policy will be matched literally. \ - If this binary is a symlink (e.g., /usr/bin/python3 -> python3.11), \ - use the canonical path instead, or run with CAP_SYS_PTRACE." - ); - } else { - tracing::warn!( - "Symlink chain broken during resolution: \ - original={policy_path} current={} pid={entrypoint_pid} error={e}. \ - Binary will be matched by original path only.", - resolved.display() - ); + // The up-front root check and this probe are separate + // syscalls; if the process exited in between, the whole + // `/proc/` tree is gone and the leaf probe also + // reports NotFound. Re-check the root so a vanished process + // stays classified as Inaccessible (actionable) rather than + // being masked as an absent candidate (quiet). + if e.kind() == std::io::ErrorKind::NotFound + && let Err(root_error) = + std::fs::metadata(format!("/proc/{entrypoint_pid}/root")) + { + return BinaryResolution::Inaccessible(root_error.kind()); + } + return BinaryResolution::classify_first_probe_error(e.kind()); } - return None; + return BinaryResolution::ChainBroken(e.kind()); } }; if !meta.file_type().is_symlink() { // Reached a non-symlink — this is the final resolved target + reached_target = true; break; } let target = match std::fs::read_link(&container_path) { Ok(t) => t, - Err(e) => { - tracing::warn!( - "Symlink detected but read_link failed: \ - path={policy_path} current={} pid={entrypoint_pid} error={e}. \ - Binary will be matched by original path only.", - resolved.display() - ); - return None; - } + // A symlink whose target can't be read is a broken chain; the + // caller logs it. + Err(e) => return BinaryResolution::ChainBroken(e.kind()), }; if target.is_absolute() { resolved = target; - } else { + } else if let Some(parent) = resolved.parent() { // Relative symlink: resolve against the containing directory // e.g., /usr/bin/python3 -> python3.11 becomes /usr/bin/python3.11 - if let Some(parent) = resolved.parent() { - resolved = normalize_path(&parent.join(&target)); - } else { - break; + resolved = normalize_path(&parent.join(&target)); + } else { + // No parent to resolve a relative target against — terminal. + reached_target = true; + break; + } + } + + if !reached_target { + // The cap was exhausted while following symlinks. Linux SYMLOOP_MAX is + // 40, so a chain of exactly 40 symlinks ending at a real file is still + // valid — after the 40th hop `resolved` may already point at that file. + // Inspect it once more without following another link: a non-symlink is + // the valid final target (fall through to the Literal/Resolved logic + // below), while another symlink (or an error) is a genuine cycle + // (a -> b -> a) or a chain deeper than the kernel allows. read_link + // resolves one hop at a time, so the kernel never surfaces ELOOP; treat + // those as a broken chain so the caller logs it and matches literally. + let container_path = format!("/proc/{entrypoint_pid}/root{}", resolved.display()); + + match std::fs::symlink_metadata(&container_path) { + Ok(meta) if !meta.file_type().is_symlink() => {} + Ok(_) => { + return BinaryResolution::ChainBroken( + std::io::Error::from_raw_os_error(libc::ELOOP).kind(), + ); } + Err(e) => return BinaryResolution::ChainBroken(e.kind()), } } let resolved_str = resolved.to_string_lossy().into_owned(); if resolved_str == policy_path { - None + BinaryResolution::Literal } else { - tracing::info!( - "Resolved policy binary symlink via container filesystem: \ - original={policy_path} resolved={resolved_str} pid={entrypoint_pid}" - ); - Some(resolved_str) + // The caller logs the resolution at info level. + BinaryResolution::Resolved(resolved_str) } } #[cfg(not(target_os = "linux"))] -fn resolve_binary_in_container(_policy_path: &str, _entrypoint_pid: u32) -> Option { - None +fn resolve_binary_in_container(_policy_path: &str, _entrypoint_pid: u32) -> BinaryResolution { + BinaryResolution::Literal } fn l7_matchers_to_json( @@ -2015,8 +2086,46 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St entry }; let mut entries = vec![binary_entry(&b.path)]; - if let Some(resolved) = resolve_binary_in_container(&b.path, entrypoint_pid) { - entries.push(binary_entry(&resolved)); + match resolve_binary_in_container(&b.path, entrypoint_pid) { + BinaryResolution::Resolved(resolved) => { + tracing::info!( + "Resolved policy binary symlink: original={} resolved={resolved} pid={entrypoint_pid}", + b.path + ); + entries.push(binary_entry(&resolved)); + } + BinaryResolution::Absent => { + tracing::debug!( + "Policy binary candidate not present in container: path={} pid={entrypoint_pid}. Matched literally.", + b.path + ); + } + BinaryResolution::Inaccessible(kind) => { + tracing::warn!( + "Cannot access container filesystem for symlink resolution: path={} pid={entrypoint_pid} \ + error_kind={kind:?}. Binary paths in policy will be matched literally. \ + If this binary is a symlink (e.g., /usr/bin/python3 -> python3.11), \ + use the canonical path instead, or run with CAP_SYS_PTRACE.", + b.path + ); + } + BinaryResolution::CandidateInaccessible(kind) => { + tracing::warn!( + "Cannot access policy binary candidate path: path={} pid={entrypoint_pid} \ + error_kind={kind:?}. Binary path will be matched literally. \ + A parent component is permission-denied or the path forms a symlink loop; \ + the process root itself is reachable.", + b.path + ); + } + BinaryResolution::ChainBroken(kind) => { + tracing::warn!( + "Symlink chain broken during resolution: path={} pid={entrypoint_pid} error_kind={kind:?}. \ + Matched by original path only.", + b.path + ); + } + BinaryResolution::Literal => {}, } entries }) @@ -7189,22 +7298,36 @@ process: #[test] fn resolve_binary_skips_glob_paths() { // Glob patterns should never be resolved — they're matched differently - assert!(resolve_binary_in_container("/usr/bin/*", 1).is_none()); - assert!(resolve_binary_in_container("/usr/local/bin/**", 1).is_none()); + assert_eq!( + resolve_binary_in_container("/usr/bin/*", 1), + BinaryResolution::Literal + ); + assert_eq!( + resolve_binary_in_container("/usr/local/bin/**", 1), + BinaryResolution::Literal + ); } #[test] fn resolve_binary_skips_pid_zero() { // pid=0 means the container hasn't started yet - assert!(resolve_binary_in_container("/usr/bin/python3", 0).is_none()); + assert_eq!( + resolve_binary_in_container("/usr/bin/python3", 0), + BinaryResolution::Literal + ); } #[test] - fn resolve_binary_returns_none_for_nonexistent_path() { - // A path that doesn't exist in any container should gracefully return None + fn resolve_binary_does_not_resolve_nonexistent_path() { + // A path that doesn't exist should never yield a Resolved target. The + // exact non-Resolved variant depends on platform/privilege (Absent when + // the process root is readable, Inaccessible when it is not, Literal on + // the non-Linux stub), so assert only that nothing is resolved. + let result = + resolve_binary_in_container("/nonexistent/binary/path/that/will/never/exist", 1); assert!( - resolve_binary_in_container("/nonexistent/binary/path/that/will/never/exist", 1) - .is_none() + !matches!(result, BinaryResolution::Resolved(_)), + "nonexistent path must not resolve, got: {result:?}" ); } @@ -7412,6 +7535,243 @@ network_policies: ); } + #[test] + fn classify_not_found_is_absent() { + // A missing candidate under an accessible root is expected, not an error. + assert_eq!( + BinaryResolution::classify_first_probe_error(std::io::ErrorKind::NotFound), + BinaryResolution::Absent + ); + } + + #[test] + fn classify_permission_denied_is_candidate_inaccessible() { + // A non-NotFound failure at the candidate probe (root already confirmed + // reachable) is a target-path problem, not a process-root one, so it + // must not emit the CAP_SYS_PTRACE process-root guidance. + assert_eq!( + BinaryResolution::classify_first_probe_error(std::io::ErrorKind::PermissionDenied), + BinaryResolution::CandidateInaccessible(std::io::ErrorKind::PermissionDenied) + ); + } + + #[test] + #[cfg(target_os = "linux")] + fn absent_candidate_resolves_to_absent() { + // Regression for #2883: a candidate path that does not exist under an + // accessible /proc//root must classify as Absent (quiet), not as a + // container-filesystem-access failure. + if !procfs_root_accessible() { + eprintln!("Skipping: /proc//root/ not accessible in this environment"); + return; + } + + // Use a path guaranteed absent: a real temp dir with an uncreated + // child. A hard-coded path like /app/.venv/bin/python could exist in a + // valid environment and make the resolver return Resolved/Literal. + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing-python"); + let pid = std::process::id(); // our own live, accessible root + assert_eq!( + resolve_binary_in_container(missing.to_str().unwrap(), pid), + BinaryResolution::Absent + ); + } + + #[test] + #[cfg(target_os = "linux")] + fn unreachable_process_root_is_inaccessible() { + // A pid whose /proc//root does not exist (process gone) must be + // reported as an access failure, not silently treated as an absent + // candidate — even though both surface as ENOENT at the leaf path. + // u32::MAX is far above /proc/sys/kernel/pid_max, so it never exists. + let dead_pid = u32::MAX; + assert!( + matches!( + resolve_binary_in_container("/usr/bin/python3", dead_pid), + BinaryResolution::Inaccessible(_) + ), + "an unreachable process root must classify as Inaccessible" + ); + } + + #[test] + #[cfg(target_os = "linux")] + fn symlink_cycle_exhausts_limit_as_chain_broken() { + // A symlink cycle (a -> b -> a) never resolves to a real target. The + // manual read_link walk resolves one hop at a time, so the kernel never + // raises ELOOP; exhausting the iteration cap must classify as + // ChainBroken, not silently pass as Resolved/Literal. + use std::os::unix::fs::symlink; + + if !procfs_root_accessible() { + eprintln!("Skipping: /proc//root/ not accessible in this environment"); + return; + } + + let dir = tempfile::tempdir().unwrap(); + let a = dir.path().join("a"); + let b = dir.path().join("b"); + symlink(&b, &a).unwrap(); // a -> b + symlink(&a, &b).unwrap(); // b -> a + let pid = std::process::id(); + assert!( + matches!( + resolve_binary_in_container(a.to_str().unwrap(), pid), + BinaryResolution::ChainBroken(_) + ), + "a symlink cycle must classify as ChainBroken" + ); + } + + #[test] + #[cfg(target_os = "linux")] + fn symlink_chain_at_limit_resolves_to_target() { + // Linux SYMLOOP_MAX is 40: a chain of exactly 40 symlinks ending at a + // real file resolves successfully in the kernel. The manual walk must + // follow all 40 hops and accept the final target rather than exhausting + // the cap and reporting ChainBroken (off-by-one regression). + use std::os::unix::fs::symlink; + + if !procfs_root_accessible() { + eprintln!("Skipping: /proc//root/ not accessible in this environment"); + return; + } + + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("real"); + std::fs::write(&target, b"").unwrap(); + + // link0 -> link1 -> ... -> link39 -> real (40 symlinks, absolute + // targets so the resolver takes the is_absolute() branch). + let link = |i: usize| dir.path().join(format!("link{i}")); + symlink(&target, link(39)).unwrap(); + for i in (0..39).rev() { + symlink(link(i + 1), link(i)).unwrap(); + } + + let pid = std::process::id(); + let result = resolve_binary_in_container(link(0).to_str().unwrap(), pid); + assert!( + matches!(result, BinaryResolution::Resolved(_)), + "a 40-link chain ending at a real file must resolve, got {result:?}" + ); + } + + #[test] + #[cfg(target_os = "linux")] + fn symlink_chain_over_limit_is_chain_broken() { + // A chain of 41 symlinks exceeds SYMLOOP_MAX: the walk must give up and + // classify as ChainBroken, proving the 40-hop budget stays enforced. + use std::os::unix::fs::symlink; + + if !procfs_root_accessible() { + eprintln!("Skipping: /proc//root/ not accessible in this environment"); + return; + } + + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("real"); + std::fs::write(&target, b"").unwrap(); + + // link0 -> ... -> link40 -> real (41 symlinks). + let link = |i: usize| dir.path().join(format!("link{i}")); + symlink(&target, link(40)).unwrap(); + for i in (0..40).rev() { + symlink(link(i + 1), link(i)).unwrap(); + } + + let pid = std::process::id(); + let result = resolve_binary_in_container(link(0).to_str().unwrap(), pid); + assert!( + matches!(result, BinaryResolution::ChainBroken(_)), + "a 41-link chain must classify as ChainBroken, got {result:?}" + ); + } + + #[test] + #[cfg(target_os = "linux")] + fn absent_candidates_emit_no_warnings() { + // Regression for #2883: several expected-but-absent compatibility + // candidates under an accessible process root must not produce a burst + // of WARN-level noise. Logging now lives at the caller, so drive the + // real caller (proto_to_opa_data_json) and count WARN events. + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tracing::Subscriber; + use tracing_subscriber::layer::{Context, Layer, SubscriberExt}; + use tracing_subscriber::registry::LookupSpan; + + #[derive(Clone)] + struct WarnCounter(Arc); + impl LookupSpan<'a>> Layer for WarnCounter { + fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) { + if *event.metadata().level() == tracing::Level::WARN { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + } + + if !procfs_root_accessible() { + eprintln!("Skipping: /proc//root/ not accessible in this environment"); + return; + } + + let warns = Arc::new(AtomicUsize::new(0)); + let subscriber = tracing_subscriber::registry().with(WarnCounter(Arc::clone(&warns))); + + // Candidates that do not exist under our accessible /proc//root. + // Historically each absent candidate emitted its own WARN. Use uncreated + // children of a real temp dir so the paths are guaranteed absent (not + // merely conventional): a hard-coded path could exist on a runner and + // stop exercising the Absent branch, or be inaccessible and emit a + // legitimate warning that fails this test. + let dir = tempfile::tempdir().unwrap(); + let candidates = [ + dir.path().join("app-python"), + dir.path().join("sandbox-python"), + dir.path().join("opt-python"), + ]; + let mut network_policies = std::collections::HashMap::new(); + network_policies.insert( + "pypi".to_string(), + NetworkPolicyRule { + name: "pypi".to_string(), + endpoints: vec![NetworkEndpoint { + host: "pypi.org".to_string(), + port: 443, + ..Default::default() + }], + binaries: candidates + .iter() + .map(|p| NetworkBinary { + path: p.to_str().unwrap().to_string(), + ..Default::default() + }) + .collect(), + }, + ); + let proto = ProtoSandboxPolicy { + version: 1, + filesystem: None, + landlock: None, + process: None, + network_policies, + network_middlewares: std::collections::HashMap::default(), + }; + + let pid = std::process::id(); // accessible root, leaf paths absent + tracing::subscriber::with_default(subscriber, || { + let _ = proto_to_opa_data_json(&proto, pid); + }); + + assert_eq!( + warns.load(Ordering::SeqCst), + 0, + "absent compatibility candidates must not emit warnings" + ); + } + #[test] fn hot_reload_preserves_symlink_expansion_behavior() { // Simulates the hot-reload path: initial load at pid=0, then reload @@ -7886,7 +8246,10 @@ network_policies: let pid = std::process::id(); let link_path = link.to_string_lossy().to_string(); // Actually attempt the same resolution our production code uses - resolve_binary_in_container(&link_path, pid).is_some() + matches!( + resolve_binary_in_container(&link_path, pid), + BinaryResolution::Resolved(_) + ) } #[cfg(target_os = "linux")] @@ -7915,11 +8278,9 @@ network_policies: let link_path = link.to_string_lossy().to_string(); let result = resolve_binary_in_container(&link_path, our_pid); - assert!( - result.is_some(), - "Should resolve symlink via /proc//root/" - ); - let resolved = result.unwrap(); + let BinaryResolution::Resolved(resolved) = result else { + panic!("Should resolve symlink via /proc//root/, got: {result:?}"); + }; assert!( resolved.ends_with("python3.11"), "Resolved path should point to target: {resolved}" @@ -7945,9 +8306,10 @@ network_policies: let path = tmp.path().to_string_lossy().to_string(); let result = resolve_binary_in_container(&path, our_pid); - assert!( - result.is_none(), - "Non-symlink file should return None, got: {result:?}" + assert_eq!( + result, + BinaryResolution::Literal, + "Non-symlink file should not expand, got: {result:?}" ); } @@ -7975,8 +8337,9 @@ network_policies: let link_path = top_link.to_string_lossy().to_string(); let result = resolve_binary_in_container(&link_path, our_pid); - assert!(result.is_some(), "Should resolve multi-level symlink chain"); - let resolved = result.unwrap(); + let BinaryResolution::Resolved(resolved) = result else { + panic!("Should resolve multi-level symlink chain, got: {result:?}"); + }; assert!( resolved.ends_with("cpython3.11"), "Should resolve to final target: {resolved}" From bcd517bbe08cc80860c9be57699390cd32e8445f Mon Sep 17 00:00:00 2001 From: jamieknvidia Date: Thu, 27 Aug 2026 07:24:04 +0000 Subject: [PATCH 030/505] feat(driver-mxc): native Windows MXC compute driver + server wiring (#2721) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(driver): add MXC compute driver for Windows isolation sessions Introduces the openshell-driver-mxc crate implementing ComputeDriver backed by Microsoft MXC isolation sessions (Windows only). Wires the new driver into the server's build_compute_runtime dispatch and adds the Mxc variant to ComputeDriverKind. Also adds a local protobuf-src stub (tools/protobuf-src-local) to unblock Windows builds that lack MSYS2/MinGW, and pins the zig Windows x64 toolchain in mise.lock. (cherry picked from commit 4f7012224efb18fbfeb47aa87e0cfd3f036f32f0) Signed-off-by: Jamie King * wip(mxc): checkpoint hung-agent work (recon, policy_map embed, A1 wiring, demo artifacts) Safety checkpoint of uncommitted work from the background agent run that stalled mid-Step-7. Includes: mxc-driver-recon.md (Step 0.5), policy_map.rs (~876L embedded mapper), A1 policy-threading edits across driver.rs/policy.rs/mxc.rs/compute/mod.rs, and examples/ (demo.yaml + mxc-gateway.toml). Not yet verified to compile end-to-end; to be reorganized into the skill's Step 11 commit sequence. (cherry picked from commit 38e42c03870be3d10e984a54f17a3b61122ff510) Signed-off-by: Jamie King * test(mxc): fix lifecycle and policy unit-test compile drift - Bring futures::StreamExt into scope for the watch-stream `.next()` call in driver::lifecycle_tests so the negative policy proof test compiles. - Bind a local `mapper` and drop the unused/deprecated NetworkBinary in the embedded-mapper network-policy rejection test. Signed-off-by: Jamie King (cherry picked from commit 039b0baf98735ca672dae52be8d3af2417dc0c1a) Signed-off-by: Jamie King * fix(mxc): downgrade missing sandbox_token to debug log The gateway mints `sandbox_token` only when a sandbox-JWT issuer is configured. There is no in-sandbox supervisor on MXC (supervisor-removal design — D1/D4), so no component ever consumes the token; requiring it on the driver side blocks the demo's `--disable-tls` smoke gateway with a spurious `invalid_argument`. Log the absence and proceed instead. Signed-off-by: Jamie King (cherry picked from commit cea209797d0edcb1d152251e748900b0a63cca62) Signed-off-by: Jamie King * fix(mxc): keep sandbox Ready after a successful one-shot agent exec monitor_exec demoted Ready->Error on exit 0 (reason ExecCompleted), so the positive demo (write hello.txt + exit) landed in Error phase. Keep Ready=True (reason AgentCompleted) on success; only non-zero exits go to ExecFailed. Tighten the positive lifecycle test to assert the terminal condition stays Ready=True/AgentCompleted. Verified live via gateway mock round-trip: phase now Provisioning->Ready with no demotion. (cherry picked from commit 54ab030f03ca0f83d0050d8dd843b633651684ad) Signed-off-by: Jamie King * feat(mxc): add processContainer backend for default-deny enforcement Add a backend selector to the MXC driver (isolation_session default | process_container). process_container drives a one-shot AppContainer that is genuinely default-deny: a write to any ungranted path is denied by the OS, unlike isolation_session which is grant-only and cannot deny. The lifecycle forks on the flag - isolation_session keeps provision/start/exec, process_container runs a single ephemeral container via run_oneshot. Also: run-demo.ps1 gains -Backend and hardens the CLI register/create calls; docs corrected to state isolation_session does NOT deny out-of-policy writes and that the negative proof requires process_container. Verified end-to-end on a real demo box (gateway -> CLI -> driver -> MXC): in-policy write succeeds, out-of-policy write denied (PermissionDenied), OVERALL: PASS. (cherry picked from commit c6cde3860bbe1b8edb3147d3e840f6bf0ece32d8) Signed-off-by: Jamie King * refactor(driver-mxc): embed policy mapper as a module; remove standalone crate Adopt the proto-based mapper (map_to_mxc) as the single source of truth, embedded in openshell-driver-mxc as a Windows-gated `policy_map` module. Rewire EmbeddedPolicyMapper to call it directly on the typed SandboxPolicy, deleting the serde_yaml proto->YAML bridge. Move the CLI to a windows-gated example and the parity tests into the crate; delete openshell-policy-mapper. - gate policy_map + seam Windows-only (MXC is Windows-only) - drop serde_yaml; add dev-deps openshell-policy, clap, anyhow - normalize mapped paths to Windows form in the seam, in one place - docs: add driver-mxc to AGENTS.md table; correct design doc section 17 test lane Signed-off-by: Giedrius Burachas (cherry picked from commit f22f9c7a25b9a651c5c5cc73f62fb01c4d6c1a8d) Signed-off-by: Jamie King * feat(driver-mxc): implement lossless split_policy for proxy-delegated egress Signed-off-by: Giedrius Burachas (cherry picked from commit 96d6afa0e2dc6a1d54edd12c34a0ceb0a30dadd0) Signed-off-by: Jamie King * feat(driver-mxc): implement Pattern-C governed-egress split through the policy seam - split_policy: SocketAddr proxy_redirect (replaces bare port), processcontainer containment guard naming MXC M1, version preserved in the trimmed proxy_policy, delegation reported as an info loss item - seam: MappedConfig carries trimmed_policy + proxy_addr; MapCtx.egress selects the split path; coarse path unchanged when egress is disabled - driver: [openshell.drivers.mxc] egress_proxy / egress_proxy_addr config, validated at create (isolation_session rejected until M1); lifecycle threads the redirect into provision and stores the trimmed policy per sandbox, emitting an EgressRedirect platform event - mxc: optional MxcNetwork block (defaultPolicy=block + proxy) in provision and one-shot configs; mock records configs for test assertions - tests: lossless-invariant suite over all example policies (validate + serialize round-trip), split lifecycle proof, M1 rejection; example gains --split --proxy-addr writing mxc-config.json / trimmed-policy.yaml / loss-report.json Signed-off-by: Giedrius Burachas (cherry picked from commit 34d54ad9f25dc6034c3ba15668555ff0d22cddd8) Signed-off-by: Jamie King * fix(driver-mxc): emit MXC network.proxy as {localhost: port} Verified against the real wxc-exec 0.6.0-alpha via --dry-run: MXC accepts only the {localhost: N} proxy shape (the form the design doc specifies) and rejects {host, port} with a parse error. Schema 0.6.0-alpha can express only a loopback port, so non-127.0.0.1 redirect addresses are now rejected: split_policy emits an error loss (no proxy block) and the driver refuses egress_proxy_addr values off 127.0.0.1. Per-sandbox attribution must use per-sandbox ports until the schema widens. Signed-off-by: Giedrius Burachas (cherry picked from commit edde8d5434571fd5398409204fcf6862672c0793) Signed-off-by: Jamie King * fix(driver-mxc): serialize isolation_session stop/deprovision as unit variants Empirical contract finding from the real test lane (build 26300.8553, wxc-exec 2026-06-10): the stop and deprovision experimental blocks are unit variants in the wxc-exec schema and must serialize as null; sending {} is rejected with malformed_request (invalid type: map, expected unit), while provision/start accept maps. The production invoker, the real-lane test, the probe script, and the e2e runner all sent {} - the driver could provision and run an agent but never stop or delete an isolation-session sandbox against this build. Pinned by a unit test. Signed-off-by: Giedrius Burachas (cherry picked from commit 0df39ca0b22ebc21eb965b2b567a5b2cac26af32) Signed-off-by: Jamie King * test(driver-mxc): add Tier-0 mapper coverage matrix with schema drift guard Three-quadrant, table-driven matrix (38 tests): mappable fields assert exact MXC output; every OpenShell field MXC cannot express asserts a loss item with the expected severity (and seam rejection on error); an empty policy asserts the restrictive default-deny posture for every MXC knob OpenShell does not control. The handled_fields_inventory drift guard serializes a fully-populated policy and compares its YAML keys against the mapper-handled field lists, so a new openshell-policy field fails the suite until consciously mapped, delegated, or reported as loss. Re-exports the policy seam types for integration tests; adds serde_yml, base64, serde_json as dev-dependencies. Signed-off-by: Giedrius Burachas (cherry picked from commit 91807f984a3b16846e35d6ca0d5ec41057aafa3a) Signed-off-by: Jamie King * feat(driver-mxc): inject agent_env into sandbox process.env Add MxcComputeConfig.agent_env: each entry is either KEY=VALUE (verbatim) or a bare KEY resolved from the gateway host environment at launch, keeping secrets (e.g. inference API keys) out of the config file. Wire it into the agent process so gateway-launched agents can authenticate to cloud endpoints (process.env was previously hardcoded empty). Unit-tested via resolve_agent_env_passthrough_and_host_lookup. Also add a gateway-driven cloud-inference (T1) test harness: mxc-inference.toml (agent_env + curl agent), inference.yaml policy, and run-inference-test.ps1 which starts the gateway, creates an isolation_session sandbox, runs an authenticated Nemotron call, and bundles redacted results. Documented agent_env in mxc-gateway.toml. Validated end-to-end on the test box (chat HTTP 200 + completion via the gateway). (cherry picked from commit 94d9e827b8b77e0af9dc654943ea8e7d8400cc9f) Signed-off-by: Jamie King * test(driver-mxc): avoid unsafe env mutation in resolve_agent_env test Replace std::env::{set,remove}_var (unsafe + racy under parallel test execution in edition 2024) with a read-only PATH lookup. Preserves all three behaviors under test and drops the #[allow(unsafe_code)]. (cherry picked from commit ac5766eeab2db4e8cc6fcd8d8a97809edaf3df30) Signed-off-by: Jamie King * fix(mxc): adapt MXC driver to current GitHub OpenShell API The MXC driver crate was authored on GitLab against an earlier proto/core API. Adapt it to the API on GitHub main: - build_capabilities_response no longer takes supports_interactive_session - DriverSandboxSpec.gpu (bool) is now resource_requirements; detect GPU via effective_driver_gpu_count(driver_gpu_requirements(..)) - DriverSandbox gained a `workspace` field - SandboxPolicy gained `network_middlewares`: pass it through the proxy split, emit a loss item on the coarse MXC path, and account for it in the mapper drift-guard test Verified: cargo check + 75 mock-based tests pass (lib 27, examples 10, policy_mapper_matrix 38). Signed-off-by: Jamie King * feat(server): wire the MXC compute driver into the gateway on Windows Register openshell-driver-mxc as the Windows-only in-process compute backend so compute_driver = "mxc" resolves to a working runtime: - ComputeRuntime::new_mxc, adapted to the current 11-arg from_driver - mxc_policy_sink A1 side channel, staged in create_sandbox before dispatch - mxc_config_from_context loader and the Mxc dispatch arm (Windows constructs; other targets return an explicit "Windows-only" error) - Windows-gated openshell-driver-mxc dependency - Mxc arms for the telemetry, config-file required-fields, and CLI reserved-builtin matches to keep them exhaustive/correct Verified with cargo check --workspace --features openshell-prover/bundled-z3 on x86_64-pc-windows-msvc, stacked on PR #2496. Signed-off-by: Jamie King * fix(driver-mxc): implement GetGatewayListenerRequirements for #2496 base Signed-off-by: Jamie King * test(driver-mxc): add probe-gated real wxc-exec test lane (no mocks) - tests/wxc_exec_real.rs: ignored-by-default integration tests against a real wxc-exec. Six --dry-run contract tests run wherever the binary exists (they caught the network.proxy shape mismatch); enforcement tests (processcontainer default-deny positive/negative, isolation session lifecycle round trip with a deprovision drop-guard) probe the backend and SKIP with a recorded reason where it is not live. - examples/probe-mxc-host.ps1: classifies a host (OS build, --probe, per-backend trial) and emits a JSON capability verdict. - examples/run-mxc-e2e.ps1 + e2e-policies/: scenario runner generalizing run-demo.ps1 (fs-rw, fs-readonly, fs-default-deny-empty, network-policy-rejected) with PASS/FAIL/SKIP gating and a stale OPENSHELL_MXC_MOCK_WXC guard in real mode. - tasks/windows.toml: windows:test:mxc-real:x64, windows:e2e:mxc, windows:e2e:mxc:mock. Signed-off-by: Giedrius Burachas (cherry picked from commit 49afafe892caded59c4df50a9b652011ad97f41c) Signed-off-by: Jamie King * fix(driver-mxc): address PR review feedback Signed-off-by: Shailendra Singh * docs: defer public MXC documentation Signed-off-by: Drew Newberry <385+drew@users.noreply.github.com> * fix(driver-mxc): build Windows capabilities response Signed-off-by: Drew Newberry <385+drew@users.noreply.github.com> * fix(server): gate in-tree tracing on Windows Signed-off-by: Drew Newberry <385+drew@users.noreply.github.com> * test(windows): fix cross-platform test assumptions Signed-off-by: Drew Newberry <385+drew@users.noreply.github.com> * test(windows): verify process and PEM portably Signed-off-by: Drew Newberry <385+drew@users.noreply.github.com> * test(windows): keep lifecycle command in policy Signed-off-by: Drew Newberry <385+drew@users.noreply.github.com> --------- Signed-off-by: Jamie King Signed-off-by: Giedrius Burachas Signed-off-by: Shailendra Singh Signed-off-by: Drew Newberry <385+drew@users.noreply.github.com> Co-authored-by: Prashant K Co-authored-by: Giedrius Burachas Co-authored-by: Shailendra Singh Co-authored-by: Drew Newberry <385+drew@users.noreply.github.com> --- .agents/skills/openshell-cli/SKILL.md | 4 +- .agents/skills/openshell-cli/cli-reference.md | 4 +- Cargo.lock | 21 + architecture/README.md | 4 + architecture/windows-msvc-build.md | 12 +- crates/openshell-core/src/config.rs | 6 +- crates/openshell-core/src/telemetry.rs | 4 + crates/openshell-driver-mxc/Cargo.toml | 47 + crates/openshell-driver-mxc/README.md | 118 ++ .../openshell-driver-mxc/examples/demo.yaml | 24 + .../examples/e2e-policies/fs-empty.yaml | 15 + .../examples/e2e-policies/fs-readonly.yaml | 16 + .../examples/e2e-policies/fs-rw.yaml | 14 + .../examples/e2e-policies/network-reject.yaml | 29 + .../examples/mxc-gateway.toml | 29 + .../examples/probe-mxc-host.ps1 | 341 +++++ .../examples/run-mxc-e2e.ps1 | 484 ++++++ crates/openshell-driver-mxc/src/driver.rs | 1260 ++++++++++++++++ crates/openshell-driver-mxc/src/grpc.rs | 209 +++ crates/openshell-driver-mxc/src/lib.rs | 44 + crates/openshell-driver-mxc/src/mxc.rs | 867 +++++++++++ crates/openshell-driver-mxc/src/policy.rs | 294 ++++ .../src/policy_map/config.rs | 143 ++ .../src/policy_map/loss.rs | 68 + .../src/policy_map/map.rs | 704 +++++++++ .../src/policy_map/mod.rs | 42 + .../src/policy_map/report.rs | 140 ++ .../tests/policy_mapper_examples.rs | 492 ++++++ .../tests/policy_mapper_matrix.rs | 1332 +++++++++++++++++ .../tests/wxc_exec_real.rs | 795 ++++++++++ .../openshell-extension-core/src/transport.rs | 11 +- crates/openshell-server/Cargo.toml | 5 + crates/openshell-server/src/cli.rs | 8 +- .../src/compute/driver_config.rs | 13 + crates/openshell-server/src/compute/mod.rs | 60 + crates/openshell-server/src/config_file.rs | 15 +- crates/openshell-server/src/grpc/policy.rs | 36 + crates/openshell-server/src/lib.rs | 55 +- crates/openshell-server/src/tracing_setup.rs | 24 +- tasks/windows.toml | 15 + 40 files changed, 7761 insertions(+), 43 deletions(-) create mode 100644 crates/openshell-driver-mxc/Cargo.toml create mode 100644 crates/openshell-driver-mxc/README.md create mode 100644 crates/openshell-driver-mxc/examples/demo.yaml create mode 100644 crates/openshell-driver-mxc/examples/e2e-policies/fs-empty.yaml create mode 100644 crates/openshell-driver-mxc/examples/e2e-policies/fs-readonly.yaml create mode 100644 crates/openshell-driver-mxc/examples/e2e-policies/fs-rw.yaml create mode 100644 crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml create mode 100644 crates/openshell-driver-mxc/examples/mxc-gateway.toml create mode 100644 crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 create mode 100644 crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 create mode 100644 crates/openshell-driver-mxc/src/driver.rs create mode 100644 crates/openshell-driver-mxc/src/grpc.rs create mode 100644 crates/openshell-driver-mxc/src/lib.rs create mode 100644 crates/openshell-driver-mxc/src/mxc.rs create mode 100644 crates/openshell-driver-mxc/src/policy.rs create mode 100644 crates/openshell-driver-mxc/src/policy_map/config.rs create mode 100644 crates/openshell-driver-mxc/src/policy_map/loss.rs create mode 100644 crates/openshell-driver-mxc/src/policy_map/map.rs create mode 100644 crates/openshell-driver-mxc/src/policy_map/mod.rs create mode 100644 crates/openshell-driver-mxc/src/policy_map/report.rs create mode 100644 crates/openshell-driver-mxc/tests/policy_mapper_examples.rs create mode 100644 crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs create mode 100644 crates/openshell-driver-mxc/tests/wxc_exec_real.rs diff --git a/.agents/skills/openshell-cli/SKILL.md b/.agents/skills/openshell-cli/SKILL.md index 313e239883..dd1bd1051b 100644 --- a/.agents/skills/openshell-cli/SKILL.md +++ b/.agents/skills/openshell-cli/SKILL.md @@ -371,7 +371,7 @@ stopped. Delete remains the operation that removes retained state. This is the most important multi-step workflow. It enables a tight feedback cycle where sandbox policy is refined based on observed activity. -**Key concept**: Policies have static fields (immutable after creation: `filesystem_policy`, `landlock`, `process`) and two dynamic fields: `network_policies` and `network_middlewares`. Both dynamic fields can be updated without recreating the sandbox. +**Key concept**: Policies have static fields (immutable after creation: `filesystem_policy`, `landlock`, `process`) and two dynamic fields: `network_policies` and `network_middlewares`. Both dynamic fields can be updated without recreating the sandbox when the selected compute driver supports live policy updates. MXC rejects live policy replacement and merge updates; delete and recreate an MXC sandbox instead. An endpoint with omitted `protocol` retains explicit-proxy behavior. Explicit `protocol: tcp` requests policy DNS and transparent TCP and currently requires @@ -441,7 +441,7 @@ Edit `current-policy.yaml` to allow the blocked actions. **For policy content au - Binary matching patterns - Ordered `network_middlewares`, host selection, HTTP and WebSocket bindings, and `fail_open` or `fail_closed` behavior -`network_policies` and `network_middlewares` can be modified at runtime. If `filesystem_policy`, `landlock`, or `process` need changes, the sandbox must be recreated. Built-in middleware such as `openshell/regex` needs no gateway registration. An operator-run middleware must already be registered under `[[openshell.supervisor.middleware]]`; changing that static registration requires a gateway restart. +`network_policies` and `network_middlewares` can be modified at runtime when the selected compute driver supports live policy updates. MXC rejects live policy replacement and merge updates; delete and recreate an MXC sandbox instead. If `filesystem_policy`, `landlock`, or `process` need changes, the sandbox must be recreated. Built-in middleware such as `openshell/regex` needs no gateway registration. An operator-run middleware must already be registered under `[[openshell.supervisor.middleware]]`; changing that static registration requires a gateway restart. Middleware can inspect parsed HTTP request bodies and complete client-to-upstream WebSocket text messages over both `ws://` and `wss://` when the implementation advertises the matching binding. The built-in `openshell/regex` advertises both bindings and applies its fixed patterns to UTF-8 text. A host-matched HTTP-only attachment can inspect the upgrade GET but does not join the WebSocket chain; look for `binding_not_selected` coverage. Binary messages pass under both `on_error` modes and active stages emit `unsupported_message_type` coverage; upstream-to-client messages remain uninspected. A broken fail-open WebSocket stage is disabled for the rest of that connection; inspect sandbox OCSF logs for `openshell.middleware.websocket_stage_disabled`. diff --git a/.agents/skills/openshell-cli/cli-reference.md b/.agents/skills/openshell-cli/cli-reference.md index 695aceedfe..856fe32d07 100644 --- a/.agents/skills/openshell-cli/cli-reference.md +++ b/.agents/skills/openshell-cli/cli-reference.md @@ -381,7 +381,7 @@ The sandbox name defaults to the last-used sandbox. ### `openshell policy update [name]` -Incrementally merge live network policy changes into the current sandbox policy. Multiple flags in one invocation are applied as one atomic batch and create at most one new revision. +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 | |------|---------|-------------| @@ -411,7 +411,7 @@ Notes: ### `openshell policy set [name] --policy ` -Replace the full policy on a live sandbox. Only the dynamic `network_policies` field can be changed at runtime. +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 | |------|---------|-------------| diff --git a/Cargo.lock b/Cargo.lock index afe6c33c7b..207535ab10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4024,6 +4024,26 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "openshell-driver-mxc" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "futures", + "openshell-core", + "openshell-policy", + "serde", + "serde_json", + "serde_yml", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tonic", + "tracing", + "uuid", +] + [[package]] name = "openshell-driver-podman" version = "0.0.0" @@ -4351,6 +4371,7 @@ dependencies = [ "openshell-driver-docker", "openshell-driver-kubernetes", "openshell-driver-kubernetes-secrets", + "openshell-driver-mxc", "openshell-driver-podman", "openshell-driver-vault", "openshell-extension-core", diff --git a/architecture/README.md b/architecture/README.md index d6cde146b2..0d814a7a22 100644 --- a/architecture/README.md +++ b/architecture/README.md @@ -141,6 +141,10 @@ 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 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, diff --git a/architecture/windows-msvc-build.md b/architecture/windows-msvc-build.md index df875bc8ef..e940b83273 100644 --- a/architecture/windows-msvc-build.md +++ b/architecture/windows-msvc-build.md @@ -1,14 +1,15 @@ # Windows MSVC Build Design This page records the design decisions for the native Windows MSVC build lane. -It is intentionally build-only. It does not make Windows a Docker, Kubernetes, -Podman, or VM runtime host. +It provides the native build lane and validates the in-process MXC compute +driver. It does not make Windows a Docker, Kubernetes, Podman, or VM runtime host. ## Goals - Compile the OpenShell gateway and CLI for `x86_64-pc-windows-msvc` and `aarch64-pc-windows-msvc`. - 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. - 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 @@ -18,7 +19,7 @@ Podman, or VM runtime host. - Do not support Docker Desktop, WSL, Hyper-V, Podman machine, Podman Desktop, Kubernetes, or VM-backed sandbox execution on Windows. - Do not ship Windows standalone binaries for Docker, Kubernetes, Podman, or VM drivers. -- Do not implement named-pipe driver IPC, Windows services, MSI packaging, Credential Manager integration, DPAPI integration, or MXC policy translation in this build lane. +- Do not implement named-pipe driver IPC, Windows services, MSI packaging, Credential Manager integration, or DPAPI integration in this lane. ## Unsupported Driver Strategy @@ -42,6 +43,7 @@ on Windows. | 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. | +| 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 creating misleading Windows driver artifacts. @@ -62,7 +64,7 @@ Windows validation is exposed through `tasks/windows.toml`: | `windows:check:arm64` | Check the ARM64 MSVC gateway/CLI build graph. | | `windows:build:x64` | Build release x64 `openshell-gateway.exe` and `openshell.exe`. | | `windows:build:arm64` | Build release ARM64 `openshell-gateway.exe` and `openshell.exe`. | -| `windows:test:x64` | Run native x64 workspace tests, excluding unsupported Windows packages as top-level test targets. | +| `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:arm64` | Run the same focused contracts natively on ARM64. | @@ -159,5 +161,5 @@ A successful Windows build report should include: - Focused unsupported-driver contract test status. - Artifact size and SHA256 for each Windows binary. -Warnings from Linux-only dead code are acceptable in this build-only phase when +Warnings from Linux-only dead code are acceptable in the native Windows lane when they come from code paths intentionally disabled on Windows. diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index ed5736eff7..ce613974a4 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -126,6 +126,8 @@ pub enum ComputeDriverKind { Vm, Docker, Podman, + /// Microsoft MXC isolation session (Windows only). + Mxc, } impl ComputeDriverKind { @@ -136,6 +138,7 @@ impl ComputeDriverKind { Self::Vm => "vm", Self::Docker => "docker", Self::Podman => "podman", + Self::Mxc => "mxc", } } } @@ -176,8 +179,9 @@ impl FromStr for ComputeDriverKind { "vm" => Ok(Self::Vm), "docker" => Ok(Self::Docker), "podman" => Ok(Self::Podman), + "mxc" => Ok(Self::Mxc), other => Err(format!( - "unsupported compute driver '{other}'. expected one of: kubernetes, vm, docker, podman" + "unsupported compute driver '{other}'. expected one of: kubernetes, vm, docker, podman, mxc" )), } } diff --git a/crates/openshell-core/src/telemetry.rs b/crates/openshell-core/src/telemetry.rs index b2c9b79152..ad15989944 100644 --- a/crates/openshell-core/src/telemetry.rs +++ b/crates/openshell-core/src/telemetry.rs @@ -165,6 +165,7 @@ pub enum TelemetryComputeDriver { Kubernetes, Podman, Vm, + Mxc, Unknown, } @@ -176,6 +177,7 @@ impl TelemetryComputeDriver { Self::Kubernetes => "kubernetes", Self::Podman => "podman", Self::Vm => "vm", + Self::Mxc => "mxc", Self::Unknown => "unknown", } } @@ -187,6 +189,7 @@ impl TelemetryComputeDriver { "k8s" | "kubernetes" => Self::Kubernetes, "podman" => Self::Podman, "vm" => Self::Vm, + "mxc" => Self::Mxc, _ => Self::Unknown, } } @@ -198,6 +201,7 @@ impl TelemetryComputeDriver { Some(crate::ComputeDriverKind::Kubernetes) => Self::Kubernetes, Some(crate::ComputeDriverKind::Podman) => Self::Podman, Some(crate::ComputeDriverKind::Vm) => Self::Vm, + Some(crate::ComputeDriverKind::Mxc) => Self::Mxc, None => Self::Unknown, } } diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml new file mode 100644 index 0000000000..2f82e50e3b --- /dev/null +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-driver-mxc" +description = "MXC (Windows isolation session) compute driver for OpenShell" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +name = "openshell_driver_mxc" + +[dependencies] +openshell-core = { path = "../openshell-core" } +tokio = { workspace = true } +tonic = { workspace = true } +futures = { workspace = true } +tokio-stream = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +base64 = { workspace = true } +tracing = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } +# tempfile is not a workspace dependency; 3.27 is already resolved in Cargo.lock. +tempfile = "3" +# Used by Windows-only integration tests to parse policy YAML into the typed +# proto. Inert on non-Windows. +openshell-policy = { path = "../openshell-policy" } +# Needed by the real-wxc integration test (wxc_exec_real.rs) which builds +# --config-base64 payloads without going through the async WxcExecInvoker. +# base64 and serde_json are already [dependencies] but dev-dependency resolution +# is independent; explicit entries make them visible to integration tests. +base64 = { workspace = true } +serde_json = { workspace = true } +# Used by the drift guard test (handled_fields_inventory) to parse YAML into a +# generic serde_json::Value for key enumeration. +serde_yml = { workspace = true } + +[lints] +workspace = true diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md new file mode 100644 index 0000000000..6496dfe286 --- /dev/null +++ b/crates/openshell-driver-mxc/README.md @@ -0,0 +1,118 @@ +# openshell-driver-mxc + +OpenShell compute driver backed by **Microsoft MXC** (`wxc-exec`) on Windows. + +## Design + +This driver implements the gateway's `ComputeDriver` contract as an in-process +library linked into `openshell-gateway`. `process_container` launches a one-shot +AppContainer and is the default. The opt-in `isolation_session` backend uses the +state-aware `provision` → `start` → `exec` → `stop` → `deprovision` lifecycle. +The driver launches and monitors the configured workload itself and self-reports +readiness; there is no in-sandbox supervisor or `ConnectSupervisor` relay. + +## Capability Matrix + +| Capability | MXC driver | +|---|---| +| Filesystem policy | Read-only/read-write grants come only from `SandboxPolicy`. `process_container` enforces default-deny; `isolation_session` is an explicit grant-only compatibility mode. | +| Network policy | Rejected synchronously during sandbox creation until an enforcing egress path is bound. | +| Process policy | Unsupported; MXC supplies OS isolation only. | +| Interactive exec/connect/forward | Unsupported; the configured workload runs in-driver. | +| Restart durability | Unsupported; the in-memory registry cannot recover live sessions. | + +The filesystem enforcement proof has two paths: + +- A write to a path granted by the sandbox policy succeeds. +- A `process_container` write outside the sandbox policy fails with Windows access denied, and the driver reports the failed workload. + +## Configuration (`[openshell.drivers.mxc]`) + +Gateway configuration contains only host runtime settings: + +```toml +[openshell.drivers.mxc] +wxc_exec_path = "C:\\path\\to\\wxc-exec.exe" +# Default: process_container. isolation_session is grant-only and opt-in. +backend = "process_container" +default_configuration_id = "composable" +pc_least_privilege = false +pc_capabilities = [] +debug = false +``` + +Supply workload settings for each sandbox. The public config is keyed by driver name; the gateway forwards only the inner `mxc` object to the driver: + +```powershell +$config = '{"mxc":{"command":["cmd","/c","echo hello > C:\\\\work\\\\demo\\\\hello.txt"],"cwd":"C:\\\\work\\\\demo"}}' +openshell sandbox create --name mxc-demo --policy demo.yaml ` + --driver-config-json $config --env MODE=demo --no-tty +``` + +The `command` array is required and preserves Windows argument boundaries. `cwd` is optional. Environment variables come from the standard sandbox and template environment maps; the driver never copies values from the gateway host environment. + +Network policy and live policy replacement or merge updates are rejected while the gateway uses MXC. Delete and recreate the sandbox to apply a different filesystem policy. + +## Prerequisites (live runs) + +- Windows 11 Insider build ≥ 26300.8553 +- `IsoSessionApp.dll` present and registered +- `wxc-exec.exe` built with `--features isolation_session` + +For off-box smoke tests against the in-process mock shim (no `wxc-exec`, +no isolation session needed), set `OPENSHELL_MXC_MOCK_WXC=1`. + +## Policy mapping + +The production driver maps the typed `SandboxPolicy` to MXC configuration before it inserts a registry entry or invokes `wxc-exec`. Mapping failure therefore returns from `CreateSandbox` without leaving a partial sandbox. + +`EmbeddedPolicyMapper` calls the embedded [`policy_map`](src/policy_map/) module directly and normalizes filesystem paths to Windows form. It does not add gateway-configured host paths. The policy supplied for the sandbox is the only source of filesystem grants. + +The mapper retains an internal policy-splitting seam for future development, but the runtime exposes no governed-egress switch. Any network rule fails closed until an enforcing proxy is implemented and bound to the sandbox lifecycle. + +Parity and matrix tests under [`tests/`](tests/) cover the mapper on the Windows MSVC lane. The driver performs this mapping automatically; there is no separate policy-export command or example. + +## Packaging the demo for the demo box + +Use [`examples/package-demo.ps1`](examples/package-demo.ps1) to assemble +the gateway EXE, CLI EXE, runtime DLLs (`libz3.dll`), `demo.yaml`, the +gateway config, and the runbook into one folder, then copy that folder to +the demo Windows host and follow `mxc-demo-runbook.md` inside it. The +script prints a SHA256 manifest so the operator can sanity-check what +landed before moving it. + +## Real-MXC test lane + +Three tasks drive real `wxc-exec.exe` hardware; all are **skip-safe** — any test +or scenario that requires an absent binary or backend prints a SKIP reason and +exits 0 rather than failing. + +| Task | What it runs | When to use | +|---|---|---| +| `windows:test:mxc-real:x64` | `tests/wxc_exec_real.rs` — Tier-2 invoker tests with `--ignored --test-threads=1` | Pre-merge on any Windows host that has `wxc-exec`; dry-run tests always pass; enforcement tests probe-gate themselves | +| `windows:e2e:mxc` | `examples/run-mxc-e2e.ps1` — Tier-3 scenario runner, real binary, probe-gated | Demo box / nightly; needs the gateway + CLI binaries in the script directory | +| `windows:e2e:mxc:mock` | Same runner with `-Mock` — wiring-only, no real `wxc-exec` needed | Any Windows host (CI, dev machine); validates wiring and the network-reject scenario | + +**Probe script:** `examples/probe-mxc-host.ps1` is an operator/CI preflight that emits a JSON capability report +(OS build, wxc-exec path/version, dry-run exit code, per-backend trial result, +and a `verdicts` object). Run it before the real-MXC lane to understand what +will PASS vs SKIP on a given host: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass ` + -File crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 +``` + +**Skip semantics:** tests in `wxc_exec_real.rs` are marked +`#[ignore = "requires real wxc-exec"]` — the standard `windows:test:x64` suite +never runs them. `OPENSHELL_WXC_EXEC_PATH` overrides the default +`C:\mxc\wxc-exec.exe` lookup. See `docs4gtb/mxc-box-capabilities.md` for the +empirical capability snapshot of the development box (build 26200, processcontainer +velocity keys not enabled, isolation_session absent). + +## Deferred work + +- **Interactive exec/connect/forward** → `adapt-openshell-gateway-windows` +- **Governed egress** remains fail-closed until an enforcing proxy is implemented and bound to sandbox lifecycle. +- **Restart durability** (deprovision orphaned sessions on startup) → follow-on +- **GPU passthrough** → not pursued in host-side-governance design diff --git a/crates/openshell-driver-mxc/examples/demo.yaml b/crates/openshell-driver-mxc/examples/demo.yaml new file mode 100644 index 0000000000..1dcc23144f --- /dev/null +++ b/crates/openshell-driver-mxc/examples/demo.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# demo.yaml — June 15 MXC filesystem-policy proof. +# +# Minimal filesystem policy granting the workload folder read-write. The granted +# path must cover the per-sandbox command executable, working directory, and any +# files that command reads or writes. ProcessContainer denies all ungranted paths. +# +# NOTE: the canonical OpenShell policy YAML key is `filesystem_policy` +# (parsed by the `openshell-policy` crate into SandboxPolicy.filesystem), NOT +# `filesystem`. The MXC driver's policy bridge then re-emits this under the +# `filesystem_policy` key the embedded mapper expects. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "C:/work/openshell-mxc-demo" # = OPENSHELL_MXC_SHARE_DIR (host-visible share) + +# No landlock, process, or network policy is present. MXC rejects every network +# policy at create time until an enforcing egress path is available; it never +# silently drops network rules. diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/fs-empty.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/fs-empty.yaml new file mode 100644 index 0000000000..8be1dec9cf --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/fs-empty.yaml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# fs-empty.yaml — Empty filesystem policy (default-deny) scenario. +# +# No paths are granted. With processcontainer (AppContainer), every write is +# denied by the OS without any host ACL configuration — genuine default-deny. +# This scenario is processcontainer-only (isolation_session has no deny primitive). +# Used by the fs-default-deny-empty scenario in run-mxc-e2e.ps1. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: [] diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/fs-readonly.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/fs-readonly.yaml new file mode 100644 index 0000000000..7fd2d0864b --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/fs-readonly.yaml @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# fs-readonly.yaml — Read-only grant + read-write share scenario. +# +# The read_only path is a prepared directory whose content the agent can read +# but not write; the read_write path is DemoDir (host-visible share). +# Used by the fs-readonly scenario in run-mxc-e2e.ps1. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: + - "C:/work/openshell-mxc-e2e-ro-src" + read_write: + - "C:/work/openshell-mxc-e2e" diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/fs-rw.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/fs-rw.yaml new file mode 100644 index 0000000000..38dafa5ed5 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/fs-rw.yaml @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# fs-rw.yaml — Filesystem read-write grant scenario. +# +# Grants read-write access to the DemoDir (substituted at runtime). +# Used by the fs-rw-positive-negative scenario in run-mxc-e2e.ps1. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "C:/work/openshell-mxc-e2e" diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml new file mode 100644 index 0000000000..e5529eaeb6 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# network-reject.yaml — Network policy rejection scenario. +# +# A filesystem grant plus a network_policies rule. The driver rejects sandbox +# create at map time with invalid_argument naming the network rule on every +# backend until MXC has a bound, enforcing egress path. +# +# This scenario requires NO live backend — it passes even on this box and in +# mock mode because the rejection happens in the policy mapper before wxc-exec +# is invoked. It is the only scenario that never SKIPs. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "C:/work/openshell-mxc-e2e" + +network_policies: + test_rule: + name: test-network-reject + endpoints: + - host: api.example.com + port: 443 + protocol: rest + enforcement: enforce + access: read-only diff --git a/crates/openshell-driver-mxc/examples/mxc-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-gateway.toml new file mode 100644 index 0000000000..4fbd2c41f5 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-gateway.toml @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# MXC gateway runtime configuration. +# +# Commands, working directories, and environment variables are sandbox-scoped. +# Supply command and cwd through --driver-config-json when creating a sandbox, +# and environment variables through the standard sandbox --env option. + +[openshell.drivers.mxc] +# Path to wxc-exec.exe. Required for live runs. Leave the default for mock-mode +# smoke tests (set OPENSHELL_MXC_MOCK_WXC=1 instead). +wxc_exec_path = "C:\\mxc\\wxc-exec.exe" + +# process_container is the default because AppContainer enforces default-deny +# filesystem access. isolation_session is an explicit grant-only compatibility +# mode and does not deny access to paths omitted from the sandbox policy. +backend = "process_container" + +# process_container only: request a Less-Privileged AppContainer. +# pc_least_privilege = false +# process_container only: AppContainer capabilities to grant. +# pc_capabilities = [] + +# isolation_session only. Never use "small" (known OS bug). +default_configuration_id = "composable" + +# Enable --debug on wxc-exec invocations. +debug = false diff --git a/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 b/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 new file mode 100644 index 0000000000..4a20e7b86c --- /dev/null +++ b/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 @@ -0,0 +1,341 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# probe-mxc-host.ps1 - Operator/CI preflight for a prospective MXC host. +# This diagnostic is not invoked by the driver and does not mutate host state. +# It reports OS, wxc-exec, and backend availability so real tests can skip +# unsupported scenarios with an explicit reason. +# +# PowerShell 5.1-compatible (no && / || / ternary operators). +# +# Usage: +# powershell -NoProfile -ExecutionPolicy Bypass -File .\probe-mxc-host.ps1 +# powershell -NoProfile -ExecutionPolicy Bypass -File .\probe-mxc-host.ps1 -OutFile caps.json +# +# The script is read-only except for a per-run temp directory. It never enables +# OS features or modifies system state. +# +# Exit codes: +# 0 - report emitted (even if backends are unavailable) +# 1 - unexpected error (should not happen on a healthy box) + +[CmdletBinding()] +param( + [string] $WxcExecPath = "C:\mxc\wxc-exec.exe", + [string] $OutFile, + # Emit the complete JSON report to stdout. Default output is a short + # human-readable summary (the full report still goes to -OutFile if set). + [switch] $Full +) + +$ErrorActionPreference = "Stop" +# Prevent PS 7+ from turning native non-zero exits into terminating errors. +$PSNativeCommandUseErrorActionPreference = $false + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +function Invoke-Native([string[]] $ArgList) { + # Run a native exe and capture all output regardless of exit code. + # In PowerShell 5.1, a non-zero exit from a native exe can emit + # ErrorRecord objects into the output stream when $ErrorActionPreference + # is Stop (via NativeCommandError). We temporarily relax the preference + # and collect both String and ErrorRecord outputs into a single string. + $saved = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $raw = & $ArgList[0] $ArgList[1..($ArgList.Length - 1)] 2>&1 + $code = $LASTEXITCODE + $text = ($raw | ForEach-Object { + if ($_ -is [System.Management.Automation.ErrorRecord]) { + $_.ToString() + } else { + $_ + } + }) -join "`n" + return @{ ExitCode = $code; Output = $text } + } finally { + $ErrorActionPreference = $saved + } +} + +function Invoke-WxcDryRun([string] $wxc, [hashtable] $config) { + $json = $config | ConvertTo-Json -Depth 20 -Compress + $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) + $b64 = [Convert]::ToBase64String($bytes) + return Invoke-Native @($wxc, "--config-base64", $b64, "--dry-run") +} + +function Invoke-WxcPhase([string] $wxc, [hashtable] $config, [switch] $Experimental) { + $json = $config | ConvertTo-Json -Depth 20 -Compress + $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) + $b64 = [Convert]::ToBase64String($bytes) + if ($Experimental) { + return Invoke-Native @($wxc, "--config-base64", $b64, "--experimental") + } else { + return Invoke-Native @($wxc, "--config-base64", $b64) + } +} + +function Invoke-WxcProbe([string] $wxc) { + return Invoke-Native @($wxc, "--probe") +} + +# ── OS info ─────────────────────────────────────────────────────────────────── + +$osVersion = [System.Environment]::OSVersion.Version +$osBuild = $osVersion.Build +$osRevision = 0 +try { + $ubr = (Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion" -ErrorAction SilentlyContinue).UBR + if ($null -ne $ubr) { $osRevision = $ubr } +} catch {} +$osBuildFull = "$osBuild.$osRevision" +$isoSessionMinBuild = 26300 +$isoSessionMinRevision = 8553 + +# ── wxc-exec info ───────────────────────────────────────────────────────────── + +$wxcInfo = @{ + path = $WxcExecPath + exists = $false + size = $null + mtime = $null +} + +if (Test-Path $WxcExecPath) { + $item = Get-Item $WxcExecPath + $wxcInfo.exists = $true + $wxcInfo.size = $item.Length + $wxcInfo.mtime = $item.LastWriteTime.ToString("o") +} + +# ── Probe section ───────────────────────────────────────────────────────────── + +$probeOutput = $null +$dryRunExitCode = $null +$dryRunOutput = $null +$pcTrialResult = "absent" +$pcTrialMessage = "wxc-exec not found" +$isoTrialResult = "absent" +$isoTrialMessage = "wxc-exec not found" + +if ($wxcInfo.exists) { + # --probe + $probeResult = Invoke-WxcProbe -wxc $WxcExecPath + $probeOutput = $probeResult.Output + + # dry-run trial (minimal processcontainer config) + $dryConfig = @{ + version = "0.6.0-alpha" + containerId = "probe-dryrun" + containment = "processcontainer" + process = @{ + commandLine = "cmd /c exit 0" + cwd = "%TEMP%" + timeout = 0 + } + filesystem = @{ + readwritePaths = @("%TEMP%") + } + } + $dryResult = Invoke-WxcDryRun -wxc $WxcExecPath -config $dryConfig + $dryRunExitCode = $dryResult.ExitCode + $dryRunOutput = $dryResult.Output + + # processcontainer one-shot trial + $pcConfig = @{ + version = "0.6.0-alpha" + containerId = "probe-pc-oneshot" + containment = "processcontainer" + process = @{ + commandLine = "cmd /c exit 0" + cwd = "%TEMP%" + timeout = 10 + } + filesystem = @{ + readwritePaths = @("%TEMP%") + } + processContainer = @{ + leastPrivilege = $false + } + } + $pcResult = Invoke-WxcPhase -wxc $WxcExecPath -config $pcConfig + $pcOutput = $pcResult.Output + $pcOutputLower = $pcOutput.ToLower() + + if ($pcResult.ExitCode -eq 0) { + $pcTrialResult = "works" + $pcTrialMessage = "processcontainer one-shot exited 0" + } elseif ($pcOutputLower -match "backend_error" -or $pcOutputLower -match "e_notimpl" -or $pcOutputLower -match "velocity") { + $pcTrialResult = "backend_error" + # Try to extract the message from the JSON envelope. + $pcTrialMessage = "backend_error: velocity keys not enabled (E_NOTIMPL)" + try { + $envelope = $pcOutput | ConvertFrom-Json + if ($null -ne $envelope.error) { + $pcTrialMessage = "backend_error: $($envelope.error.message)" + } + } catch {} + } else { + $pcTrialResult = "error" + if ([string]::IsNullOrWhiteSpace($pcOutput)) { + $pcTrialMessage = "exit $($pcResult.ExitCode) with no output captured" + } else { + $pcTrialMessage = "exit $($pcResult.ExitCode): $pcOutput" + } + } + + # isolation_session provision trial + $isoConfig = @{ + version = "0.6.0-alpha" + phase = "provision" + containment = "isolation_session" + filesystem = @{ + readwritePaths = @() + readonlyPaths = @() + } + experimental = @{ + isolation_session = @{ + configurationId = "composable" + provision = @{} + } + } + } + $isoResult = Invoke-WxcPhase -wxc $WxcExecPath -config $isoConfig -Experimental + $isoOutput = $isoResult.Output + $isoOutputLower = $isoOutput.ToLower() + + if ($isoOutputLower -match "backend_unavailable" -or $isoOutputLower -match "0x80040154") { + $isoTrialResult = "unavailable" + $isoTrialMessage = "backend_unavailable: IsoSessionApp.dll absent or OS build < 26300.8553" + } elseif ($isoResult.ExitCode -eq 0) { + # Provision succeeded — deprovision immediately to avoid orphaning. + $isoTrialResult = "live" + $isoTrialMessage = "isolation_session provision succeeded" + $sandboxId = $null + try { + $envelope = $isoOutput | ConvertFrom-Json + if ($null -ne $envelope.result) { + $sandboxId = $envelope.result.sandboxId + } + } catch {} + + if ($null -ne $sandboxId) { + # Stop first (a provisioned-but-unstarted session may still accept it; + # ignore failures), then deprovision. Surface the deprovision error + # text — an orphaned session blocks the single-session backend. + $stopConfig = @{ + version = "0.6.0-alpha" + phase = "stop" + sandboxId = $sandboxId + experimental = @{ + isolation_session = @{ + # Unit variant: serialize as null, not {} (malformed_request otherwise). + stop = $null + } + } + } + Invoke-WxcPhase -wxc $WxcExecPath -config $stopConfig -Experimental | Out-Null + $deprovConfig = @{ + version = "0.6.0-alpha" + phase = "deprovision" + sandboxId = $sandboxId + experimental = @{ + isolation_session = @{ + deprovision = $null + } + } + } + $deprovResult = Invoke-WxcPhase -wxc $WxcExecPath -config $deprovConfig -Experimental + if ($deprovResult.ExitCode -eq 0) { + $isoTrialMessage = "isolation_session live (provisioned $sandboxId, deprovisioned cleanly)" + } else { + $snippet = $deprovResult.Output + if ($snippet.Length -gt 200) { $snippet = $snippet.Substring(0, 200) } + $isoTrialMessage = "isolation_session live (provisioned $sandboxId; deprovision FAILED exit $($deprovResult.ExitCode): $snippet -- clean up manually before running lifecycle tests)" + } + } + } else { + $isoTrialResult = "error" + $isoTrialMessage = "exit $($isoResult.ExitCode): $isoOutput" + } +} + +# ── Verdicts ────────────────────────────────────────────────────────────────── + +$pcVerdict = $null +if ($pcTrialResult -eq "works") { + $pcVerdict = "live" +} else { + $pcVerdict = "unavailable: $pcTrialMessage" +} + +$isoVerdict = $null +if ($isoTrialResult -eq "live") { + $isoVerdict = "live" +} else { + $isoVerdict = "unavailable: $isoTrialMessage" +} + +$dryRunVerdict = $null +if ($null -eq $dryRunExitCode) { + $dryRunVerdict = "unavailable: wxc-exec not found" +} elseif ($dryRunExitCode -eq 0) { + $dryRunVerdict = "ok" +} else { + $dryRunVerdict = "failed: exit $dryRunExitCode" +} + +# ── Assemble report ─────────────────────────────────────────────────────────── + +$report = [ordered]@{ + generatedAt = (Get-Date).ToString("o") + host = [ordered]@{ + osBuild = $osBuildFull + osBuildNumber = $osBuild + osRevision = $osRevision + isoSessionBuildRequirement = "${isoSessionMinBuild}.${isoSessionMinRevision}" + meetsIsoBuildReq = ($osBuild -gt $isoSessionMinBuild) -or + ($osBuild -eq $isoSessionMinBuild -and $osRevision -ge $isoSessionMinRevision) + } + wxcExec = $wxcInfo + probeOutput = $probeOutput + dryRun = [ordered]@{ + exitCode = $dryRunExitCode + output = $dryRunOutput + } + processcontainerTrial = [ordered]@{ + result = $pcTrialResult + message = $pcTrialMessage + } + isolationSessionTrial = [ordered]@{ + result = $isoTrialResult + message = $isoTrialMessage + } + verdicts = [ordered]@{ + processcontainer = $pcVerdict + isolation_session = $isoVerdict + dryRun = $dryRunVerdict + } +} + +$json = $report | ConvertTo-Json -Depth 10 + +if ($Full) { + Write-Output $json +} else { + $met = "not met" + if ($report.host.meetsIsoBuildReq) { $met = "met" } + Write-Host "MXC host probe - OS build $osBuildFull (isolation_session requires $($report.host.isoSessionBuildRequirement): $met)" + Write-Host "wxc-exec: $WxcExecPath (exists=$($wxcInfo.exists))" + Write-Host "verdicts:" + Write-Host " processcontainer : $pcVerdict" + Write-Host " isolation_session : $isoVerdict" + Write-Host " dry-run : $dryRunVerdict" + Write-Host "(re-run with -Full for the complete JSON report, or -OutFile caps.json to save it)" +} + +if ($OutFile) { + $json | Out-File -FilePath $OutFile -Encoding utf8 + Write-Host "Report written to $OutFile" -ForegroundColor Cyan +} diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 new file mode 100644 index 0000000000..9f8baa3446 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -0,0 +1,484 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# run-mxc-e2e.ps1 - MXC e2e scenario runner. +# +# Starts the gateway ONCE, runs a table of policy scenarios, emits per-scenario +# PASS/FAIL/SKIP(reason), prints a summary table, and exits non-zero only on +# FAIL. Reuses the gateway-start / CLI-register / teardown pattern from +# run-demo.ps1. +# +# PowerShell 5.1-compatible (no && / || / ternary operators). +# +# Usage examples: +# +# # Real mode (probe-gated — backends that are absent are SKIPped): +# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-mxc-e2e.ps1 +# +# # Mock mode (wiring-only; no real wxc-exec or enforcement): +# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-mxc-e2e.ps1 -Mock +# +# # Choose backend / filter scenarios: +# .\run-mxc-e2e.ps1 -Backend process_container -Scenario fs-rw +# +# Scenarios & expected verdicts: +# fs-rw - rw grant on DemoDir; in-policy write succeeds. +# Both backends; skipped when backend not live (non-mock). +# fs-readonly - ro grant on a source dir + rw on DemoDir; +# write to ro dir should be denied. +# Both backends; skipped when backend not live. +# fs-default-deny - empty filesystem policy; every write denied. +# processcontainer only (isolation_session has no deny +# primitive); skipped on isolation_session. +# network-reject - rw grant + network_policies rule; +# sandbox create must FAIL (invalid_argument). +# Runs on ANY backend including mock — never skips. + +[CmdletBinding()] +param( + [string] $DemoDir = "C:\work\openshell-mxc-e2e", + [string] $WxcExecPath = "C:\mxc\wxc-exec.exe", + [ValidateSet("isolation_session", "process_container")] + [string] $Backend = "process_container", + [string] $Scenario, + [int] $Port = 17670, + [string] $GatewayName = "openshell-mxc-e2e", + [switch] $Mock, + [switch] $KeepRunning +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false + +$here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } + +function Step([string]$m) { Write-Host "`n=== $m ===" -ForegroundColor Cyan } +function Info([string]$m) { Write-Host " $m" } +function Ok([string]$m) { Write-Host "[OK] $m" -ForegroundColor Green } +function Bad([string]$m) { Write-Host "[FAIL] $m" -ForegroundColor Red } +function Skip([string]$m) { Write-Host "[SKIP] $m" -ForegroundColor Yellow } +function Warn([string]$m) { Write-Host "[WARN] $m" -ForegroundColor Yellow } + +# ── Pre-flight ──────────────────────────────────────────────────────────────── + +# In real mode, assert OPENSHELL_MXC_MOCK_WXC is NOT set. +# A stale mock env var would silently re-mock a run that should be real. +if (-not $Mock) { + if ($env:OPENSHELL_MXC_MOCK_WXC -eq "1") { + throw "OPENSHELL_MXC_MOCK_WXC=1 is set but -Mock was not passed. " + + "A stale mock env var would silently re-mock a real run. " + + "Unset OPENSHELL_MXC_MOCK_WXC or pass -Mock." + } +} + +$gateway = Join-Path $here "openshell-gateway.exe" +$cli = Join-Path $here "openshell.exe" +$toml = Join-Path $here "mxc-gateway.toml" +$policyDir = Join-Path $here "e2e-policies" + +foreach ($f in @($gateway, $cli, $toml)) { + if (-not (Test-Path $f)) { + throw "Missing artifact: $f`nBuild first or run from a demo-package folder." + } +} +if (-not (Test-Path $policyDir)) { + throw "e2e-policies/ directory not found at $policyDir" +} + +# ── Backend probe ───────────────────────────────────────────────────────────── + +# Returns a verdict hash for a given backend: {Live: bool, Reason: string} +function Probe-Backend([string] $backendName, [string] $wxc) { + if ($Mock) { + # In mock mode all backends are "live" — enforcement is simulated. + return @{ Live = $true; Reason = "mock mode" } + } + if (-not (Test-Path $wxc)) { + return @{ Live = $false; Reason = "wxc-exec not found at $wxc" } + } + + if ($backendName -eq "process_container") { + $config = @{ + version = "0.6.0-alpha" + containerId = "e2e-probe-pc" + containment = "processcontainer" + process = @{ + commandLine = "cmd /c exit 0" + cwd = "%TEMP%" + timeout = 10 + } + filesystem = @{ readwritePaths = @("%TEMP%") } + processContainer = @{ leastPrivilege = $false } + } + $json = $config | ConvertTo-Json -Depth 20 -Compress + $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) + $b64 = [Convert]::ToBase64String($bytes) + $outObj = & $wxc --config-base64 $b64 2>&1 + $exitCode = $LASTEXITCODE + $output = ($outObj -join "`n").ToLower() + if ($exitCode -eq 0) { + return @{ Live = $true; Reason = "process_container probe exit 0" } + } + $reason = "process_container unavailable: exit $exitCode" + if ($output -match "backend_error" -or $output -match "e_notimpl" -or $output -match "velocity") { + $reason = "process_container backend_error (velocity keys not enabled)" + } + return @{ Live = $false; Reason = $reason } + } + + if ($backendName -eq "isolation_session") { + $config = @{ + version = "0.6.0-alpha" + phase = "provision" + containment = "isolation_session" + filesystem = @{ readwritePaths = @(); readonlyPaths = @() } + experimental = @{ + isolation_session = @{ + configurationId = "composable" + provision = @{} + } + } + } + $json = $config | ConvertTo-Json -Depth 20 -Compress + $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) + $b64 = [Convert]::ToBase64String($bytes) + $outObj = & $wxc --config-base64 $b64 --experimental 2>&1 + $exitCode = $LASTEXITCODE + $output = ($outObj -join "`n").ToLower() + if ($output -match "backend_unavailable" -or $output -match "0x80040154") { + return @{ Live = $false; Reason = "isolation_session backend_unavailable (IsoSessionApp.dll absent)" } + } + if ($exitCode -ne 0) { + return @{ Live = $false; Reason = "isolation_session probe failed: exit $exitCode" } + } + # Provision succeeded — deprovision immediately. + $sandboxId = $null + try { + $rawOut = ($outObj -join "`n") + $parsed = $rawOut | ConvertFrom-Json + $sandboxId = $parsed.result.sandboxId + } catch {} + if ($null -ne $sandboxId) { + $deprovConfig = @{ + version = "0.6.0-alpha" + phase = "deprovision" + sandboxId = $sandboxId + experimental = @{ + # Unit variant: null, not @{} (malformed_request otherwise). + isolation_session = @{ deprovision = $null } + } + } + $deprovJson = $deprovConfig | ConvertTo-Json -Depth 20 -Compress + $deprovBytes = [System.Text.Encoding]::UTF8.GetBytes($deprovJson) + $deprovB64 = [Convert]::ToBase64String($deprovBytes) + & $wxc --config-base64 $deprovB64 --experimental 2>&1 | Out-Null + } + return @{ Live = $true; Reason = "isolation_session probe: provisioned and deprovisioned" } + } + + return @{ Live = $false; Reason = "unknown backend: $backendName" } +} + +# ── Mode setup ──────────────────────────────────────────────────────────────── + +$mode = if ($Mock) { "MOCK" } else { "REAL" } +Step "Pre-flight (mode=$mode, backend=$Backend)" + +if ($Mock) { + $env:OPENSHELL_MXC_MOCK_WXC = "1" + Info "OPENSHELL_MXC_MOCK_WXC=1 — mock mode: enforcement simulated" +} else { + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + if (-not (Test-Path $WxcExecPath)) { + throw "wxc-exec not found at '$WxcExecPath'. Pass -WxcExecPath or use -Mock." + } + $env:OPENSHELL_WXC_EXEC_PATH = $WxcExecPath + Info "wxc-exec: $WxcExecPath" +} + +# Patch the TOML copy for backend + wxc_exec_path (mirrors run-demo.ps1). +$tomlText = Get-Content $toml -Raw +$backendLine = "backend = `"$Backend`"" +if ($tomlText -match '(?m)^\s*#?\s*backend\s*=') { + $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*backend\s*=.*$', $backendLine) +} else { + $tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`n$backendLine") +} +if (-not $Mock) { + $escaped = $WxcExecPath.Replace('\', '\\') + $wxcLine = "wxc_exec_path = `"$escaped`"" + if ($tomlText -match '(?m)^\s*#?\s*wxc_exec_path\s*=') { + $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*wxc_exec_path\s*=.*$', $wxcLine) + } else { + $tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`n$wxcLine") + } +} +Set-Content $toml -Value $tomlText -Encoding UTF8 +Info "patched $(Split-Path $toml -Leaf): backend=$Backend" + +# Probe backend liveness now (used by scenario gate below). +$backendProbe = Probe-Backend -backendName $Backend -wxc $WxcExecPath +if ($backendProbe.Live) { + Ok "Backend '$Backend' is live: $($backendProbe.Reason)" +} else { + Warn "Backend '$Backend' is not live: $($backendProbe.Reason)" + Warn "Enforcement scenarios will SKIP; network-reject scenario will still run." +} + +# ── Port check ──────────────────────────────────────────────────────────────── + +Step "Check gateway port $Port" +$busy = Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue +if ($busy) { + throw "port $Port in use (pid $($busy.OwningProcess)). Stop stale gateway first." +} +Ok "port $Port free" + +# ── Prepare DemoDir ─────────────────────────────────────────────────────────── + +Step "Prepare DemoDir $DemoDir" +New-Item -ItemType Directory -Force $DemoDir | Out-Null +Ok "DemoDir ready" + +$env:OPENSHELL_DRIVERS = "mxc" +$env:OPENSHELL_MXC_SHARE_DIR = $DemoDir + +# ── Start gateway ───────────────────────────────────────────────────────────── + +Step "Start gateway" +$gwLog = Join-Path $here "gateway.e2e.log" +$gwErrLog = "$gwLog.err" +Remove-Item $gwLog, $gwErrLog -Force -ErrorAction SilentlyContinue + +$gw = Start-Process -FilePath $gateway ` + -ArgumentList @("--disable-tls", "--config", $toml, "--log-level", "info") ` + -WorkingDirectory $here -PassThru -NoNewWindow ` + -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + +Info "gateway pid $($gw.Id); logs: $gwLog" + +$results = @() + +try { + # Wait for listening + $deadline = (Get-Date).AddSeconds(30) + $ready = $false + while ((Get-Date) -lt $deadline) { + if ($gw.HasExited) { + Get-Content $gwLog, $gwErrLog -ErrorAction SilentlyContinue | ForEach-Object { Info $_ } + throw "gateway exited early (code $($gw.ExitCode)). See log." + } + if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) { + $ready = $true + break + } + Start-Sleep -Milliseconds 500 + } + if (-not $ready) { throw "gateway did not start within 30 s." } + Ok "gateway listening on $Port" + + # Register CLI + Step "Register CLI" + $env:OPENSHELL_GATEWAY = "" + try { & $cli gateway add "http://127.0.0.1:$Port" --local --name $GatewayName 2>&1 | ForEach-Object { Info $_ } } + catch { Info "gateway add: $($_.Exception.Message) (continuing)" } + try { & $cli gateway select $GatewayName 2>&1 | ForEach-Object { Info $_ } } + catch { Info "gateway select: $($_.Exception.Message) (continuing)" } + Ok "CLI registered" + + # ── Scenario definitions ────────────────────────────────────────────────── + # + # Each scenario is a hashtable: + # Name - unique identifier + # PolicyFile - path to the policy YAML fixture + # Backends - list: "both" / "process_container" / "isolation_session" + # ExpectFail - $true means `sandbox create` itself must fail (invalid_argument) + # ExpectArtifact - whether the workload should create its target file + # Description - human-readable label + + $allScenarios = @( + @{ + Name = "fs-rw" + PolicyFile = Join-Path $policyDir "fs-rw.yaml" + Backends = "both" + ExpectFail = $false + ExpectArtifact = $true + Description = "rw grant on DemoDir; in-policy write should succeed" + }, + @{ + Name = "fs-readonly" + PolicyFile = Join-Path $policyDir "fs-readonly.yaml" + Backends = "both" + ExpectFail = $false + ExpectArtifact = $true + Description = "ro grant + rw share; write to ro dir should be denied" + }, + @{ + Name = "fs-default-deny" + PolicyFile = Join-Path $policyDir "fs-empty.yaml" + Backends = "process_container" + ExpectFail = $false + ExpectArtifact = $false + Description = "empty filesystem policy; all writes denied (process_container only)" + }, + @{ + Name = "network-reject" + PolicyFile = Join-Path $policyDir "network-reject.yaml" + Backends = "both" + ExpectFail = $true + Description = "network_policies rule causes sandbox create to fail (no live backend needed)" + } + ) + + # Apply optional scenario filter. + if ($Scenario) { + $filtered = $allScenarios | Where-Object { $_.Name -eq $Scenario } + if ($filtered.Count -eq 0) { + throw "Scenario '$Scenario' not found. Available: $(($allScenarios | ForEach-Object { $_.Name }) -join ', ')" + } + $allScenarios = $filtered + } + + # ── Run scenarios ───────────────────────────────────────────────────────── + + foreach ($sc in $allScenarios) { + Step "Scenario: $($sc.Name)" + Info $sc.Description + + # Backend gate: skip enforcement scenarios when backend not live (and not mock and not ExpectFail). + $skipReason = $null + if (-not $sc.ExpectFail) { + $backendMatches = ($sc.Backends -eq "both") -or ($sc.Backends -eq $Backend) + if (-not $backendMatches) { + $skipReason = "scenario requires backend=$($sc.Backends); current backend=$Backend" + } elseif (-not $backendProbe.Live -and -not $Mock) { + $skipReason = "backend not live: $($backendProbe.Reason)" + } + } + + if ($null -ne $skipReason) { + Skip "$($sc.Name): $skipReason" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "SKIP"; Reason = $skipReason } + continue + } + + # Policy file must exist. + if (-not (Test-Path $sc.PolicyFile)) { + Bad "$($sc.Name): policy fixture not found at $($sc.PolicyFile)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "policy fixture missing" } + continue + } + + # Build per-sandbox MXC workload config. Commands and working directories + # are create-time inputs, not gateway-wide settings. + $target = Join-Path $DemoDir "$($sc.Name)-result.txt" + Remove-Item $target -Force -ErrorAction SilentlyContinue + $targetFwd = $target.Replace('\', '/') + $demoDirFwd = $DemoDir.Replace('\', '/') + $driverConfig = @{ + mxc = @{ + command = @("cmd", "/c", "echo.ok>$targetFwd") + cwd = $demoDirFwd + } + } | ConvertTo-Json -Compress -Depth 4 + # Windows PowerShell 5.1 removes embedded quotes when it builds the + # native command line. Escape them so the CLI receives valid JSON. + $driverConfigArg = if ($PSVersionTable.PSVersion.Major -lt 7) { + $driverConfig.Replace('"', '\"') + } else { + $driverConfig + } + # Run sandbox create. + $createOut = $null + $createExitCode = 0 + try { + $createOut = & $cli sandbox create --name $sc.Name --policy $sc.PolicyFile --driver-config-json $driverConfigArg --no-tty 2>&1 + $createExitCode = $LASTEXITCODE + } catch { + $createOut = $_.Exception.Message + $createExitCode = 1 + } + $createOutStr = ($createOut -join "`n") + Info "create exit: $createExitCode" + + # Delete sandbox (best-effort; no-op if create failed). + try { & $cli sandbox delete $sc.Name 2>&1 | Out-Null } catch {} + + # Evaluate. + if ($sc.ExpectFail) { + # network-reject: create must fail. + if ($createExitCode -ne 0) { + Ok "$($sc.Name): create correctly failed (exit $createExitCode)" + Info "output: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "create failed as expected" } + } else { + Bad "$($sc.Name): create succeeded but should have failed" + Info "output: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "create succeeded unexpectedly" } + } + } else { + # Wiring check in mock mode: the artifact must match the policy's + # expected outcome. In particular, default-deny passes only when + # the workload cannot create its target file. + if ($Mock) { + if ($sc.ExpectArtifact) { + $deadline = (Get-Date).AddSeconds(10) + while ((Get-Date) -lt $deadline -and -not (Test-Path $target)) { + Start-Sleep -Milliseconds 300 + } + } + $artifactExists = Test-Path $target + if ($artifactExists -eq $sc.ExpectArtifact) { + $outcome = if ($artifactExists) { "present" } else { "absent" } + Ok "$($sc.Name): artifact $outcome as expected (mock wiring OK)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "mock wiring: artifact $outcome as expected" } + } else { + Bad "$($sc.Name): artifact outcome did not match policy (present=$artifactExists, expected=$($sc.ExpectArtifact))" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "mock wiring: artifact present=$artifactExists, expected=$($sc.ExpectArtifact)" } + } + } else { + # Real mode: artifact presence == enforcement worked. + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline -and -not (Test-Path $target)) { + Start-Sleep -Milliseconds 500 + } + if ($createExitCode -eq 0 -and (Test-Path $target)) { + Ok "$($sc.Name): in-policy write succeeded" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "in-policy write produced artifact" } + } else { + Bad "$($sc.Name): FAIL (create=$createExitCode, artifact=$(Test-Path $target))" + Info "createOut: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "create=$createExitCode, artifact=$(Test-Path $target)" } + } + } + } + } + +} finally { + if ($KeepRunning) { + Info "leaving gateway pid $($gw.Id) running (-KeepRunning)" + } elseif ($gw -and -not $gw.HasExited) { + Step "Cleanup" + Stop-Process -Id $gw.Id -Force -ErrorAction SilentlyContinue + Info "stopped gateway pid $($gw.Id)" + } +} + +# ── Summary table ───────────────────────────────────────────────────────────── + +Step "Summary" +$results | Format-Table -AutoSize + +$failCount = ($results | Where-Object { $_.Result -eq "FAIL" }).Count +$passCount = ($results | Where-Object { $_.Result -eq "PASS" }).Count +$skipCount = ($results | Where-Object { $_.Result -eq "SKIP" }).Count + +Write-Host "PASS=$passCount FAIL=$failCount SKIP=$skipCount" + +if ($failCount -gt 0) { + Write-Host "`nSOME SCENARIOS FAILED" -ForegroundColor Red + exit 1 +} else { + Write-Host "`nALL SCENARIOS PASSED (or SKIPPED)" -ForegroundColor Green + exit 0 +} diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs new file mode 100644 index 0000000000..d8e981a46d --- /dev/null +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -0,0 +1,1260 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! MXC compute backend: lifecycle logic, in-memory registry, exec-in-driver, +//! and self-reported readiness. + +use crate::mxc::{MxcFilesystem, MxcProcess, MxcProcessContainer, WxcExecInvoker}; +use crate::policy::{EmbeddedPolicyMapper, MapCtx, MappedConfig, PolicyMapper}; +use futures::Stream; +use openshell_core::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; +use openshell_core::proto::SandboxPolicy; +use openshell_core::proto::compute::v1::{ + DriverCondition, DriverPlatformEvent, DriverSandbox, DriverSandboxStatus, + GetCapabilitiesResponse, WatchSandboxesDeletedEvent, WatchSandboxesEvent, + WatchSandboxesPlatformEvent, WatchSandboxesSandboxEvent, watch_sandboxes_event, +}; +use openshell_core::proto_struct::struct_to_json_value; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::Arc; +use tokio::sync::{Mutex, broadcast, mpsc, watch}; +use tokio::task::JoinHandle; +use tokio_stream::wrappers::ReceiverStream; +use tracing::{info, warn}; + +const DRIVER_NAME: &str = "mxc"; +const DRIVER_VERSION: &str = env!("CARGO_PKG_VERSION"); +/// Sentinel image name — MXC has no OCI image; this string must be non-empty +/// so the gateway's `default_image` cache is satisfied, but it is not pullable. +const DEFAULT_IMAGE_SENTINEL: &str = "mxc:process-container"; + +// ── Config ──────────────────────────────────────────────────────────────────── + +/// Which MXC backend the driver targets. +/// +/// - `IsolationSession`: persistent, attachable session +/// (provision → start → exec → stop → deprovision). Grant-only filesystem +/// policy — it has no deny primitive and is NOT default-deny. +/// - `ProcessContainer` (default): one-shot `AppContainer`. Genuinely default-deny: a +/// write to any ungranted path is denied by the OS. No persistent session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum MxcBackend { + IsolationSession, + #[default] + ProcessContainer, +} + +/// Configuration for the MXC compute driver. +/// +/// Loaded from `[openshell.drivers.mxc]` in the gateway TOML file, or from +/// environment variables / CLI flags via the standard gateway precedence chain. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct MxcComputeConfig { + /// Path to `wxc-exec.exe`. Required for live runs. + pub wxc_exec_path: String, + /// Backend to target. Default: `process_container`. + pub backend: MxcBackend, + /// `processContainer` only: request a Less-Privileged `AppContainer`. + pub pc_least_privilege: bool, + /// `processContainer` only: `AppContainer` capabilities to grant. + pub pc_capabilities: Vec, + /// MXC `configurationId` for isolation session. Default: `"composable"`. + /// Never use `"small"` (known OS bug). + pub default_configuration_id: String, + + /// Enable `--debug` flag on `wxc-exec` invocations. + pub debug: bool, +} + +impl Default for MxcComputeConfig { + fn default() -> Self { + Self { + wxc_exec_path: "wxc-exec.exe".into(), + backend: MxcBackend::default(), + pc_least_privilege: false, + pc_capabilities: Vec::new(), + default_configuration_id: crate::mxc::DEFAULT_CONFIGURATION_ID.into(), + + debug: false, + } + } +} + +/// Per-sandbox MXC workload settings supplied through +/// `template.driver_config.mxc` / `--driver-config-json`. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct MxcSandboxConfig { + command: Vec, + #[serde(default)] + cwd: String, +} + +// ── Registry entry ──────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PhaseState { + Starting, + Running, + Stopped, + Failed(String), +} + +struct SandboxEntry { + sandbox: DriverSandbox, + iso_sandbox_id: Option, + isolation_stopped: bool, + phase_state: PhaseState, + /// Serializes stop/delete with provisioning and process launch. + lifecycle_gate: Arc>, + monitor_cancel: Option>, + monitor_task: Option>, +} + +impl std::fmt::Debug for SandboxEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SandboxEntry") + .field("sandbox_id", &self.sandbox.id) + .field("iso_sandbox_id", &self.iso_sandbox_id) + .field("isolation_stopped", &self.isolation_stopped) + .field("phase_state", &self.phase_state) + .finish_non_exhaustive() + } +} + +// ── Watch stream helpers ────────────────────────────────────────────────────── + +pub type WatchStream = Pin< + Box> + Send>, +>; + +fn sandbox_event(sandbox: DriverSandbox) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Sandbox( + WatchSandboxesSandboxEvent { + sandbox: Some(sandbox), + }, + )), + } +} + +fn deleted_event(sandbox_id: String) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Deleted( + WatchSandboxesDeletedEvent { sandbox_id }, + )), + } +} + +fn platform_event(sandbox_id: String, reason: &str, message: String) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::PlatformEvent( + WatchSandboxesPlatformEvent { + sandbox_id, + event: Some(DriverPlatformEvent { + timestamp_ms: 0, + source: "mxc-driver".into(), + r#type: "Warning".into(), + reason: reason.to_string(), + message, + metadata: HashMap::new(), + }), + }, + )), + } +} + +// ── Driver ──────────────────────────────────────────────────────────────────── + +/// In-process MXC compute driver. +pub struct MxcComputeBackend { + config: MxcComputeConfig, + invoker: WxcExecInvoker, + registry: Arc>>, + watch_tx: Arc>, + policy_mapper: Arc, + /// Out-of-band side channel for the `SandboxPolicy` (A1). The proto driver + /// contract has no `policy` field and there is no driver-side + /// `GetSandboxConfig`, so `ComputeRuntime::create_sandbox` stages the policy + /// here keyed by sandbox id (mirroring the `sandbox_token` injection), + /// immediately before dispatching to this backend's `create_sandbox`, which + /// removes/consumes it. + pending_policies: Arc>>, +} + +impl std::fmt::Debug for MxcComputeBackend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MxcComputeBackend") + .field("wxc_exec_path", &self.config.wxc_exec_path) + .finish_non_exhaustive() + } +} + +fn sandbox_config(sandbox: &DriverSandbox) -> Result { + let config = sandbox + .spec + .as_ref() + .and_then(|spec| spec.template.as_ref()) + .and_then(|template| template.driver_config.as_ref()) + .ok_or_else(|| { + tonic::Status::invalid_argument( + "mxc requires template.driver_config.mxc with a non-empty command array", + ) + })?; + let config: MxcSandboxConfig = + serde_json::from_value(struct_to_json_value(config)).map_err(|error| { + tonic::Status::invalid_argument(format!("invalid mxc driver_config: {error}")) + })?; + if config.command.is_empty() || config.command[0].is_empty() { + return Err(tonic::Status::invalid_argument( + "mxc driver_config.command must contain a non-empty executable", + )); + } + Ok(config) +} + +fn sandbox_environment(sandbox: &DriverSandbox) -> Vec { + let Some(spec) = sandbox.spec.as_ref() else { + return Vec::new(); + }; + let mut environment = spec + .template + .as_ref() + .map_or_else(HashMap::new, |template| template.environment.clone()); + environment.extend(spec.environment.clone()); + let mut environment = environment + .into_iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>(); + environment.sort_unstable(); + environment +} + +fn encode_windows_command_line(args: &[String]) -> String { + args.iter() + .map(|arg| quote_windows_argument(arg)) + .collect::>() + .join(" ") +} + +fn quote_windows_argument(arg: &str) -> String { + if !arg.is_empty() && !arg.chars().any(|ch| ch.is_whitespace() || ch == '"') { + return arg.to_string(); + } + + let mut quoted = String::from("\""); + let mut backslashes = 0; + for ch in arg.chars() { + match ch { + '\\' => backslashes += 1, + '"' => { + quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); + quoted.push('"'); + backslashes = 0; + } + _ => { + quoted.push_str(&"\\".repeat(backslashes)); + backslashes = 0; + quoted.push(ch); + } + } + } + quoted.push_str(&"\\".repeat(backslashes * 2)); + quoted.push('"'); + quoted +} +impl MxcComputeBackend { + pub fn new(config: MxcComputeConfig) -> Self { + let invoker = WxcExecInvoker::new(&config.wxc_exec_path, config.debug); + let (watch_tx, _) = broadcast::channel(256); + Self { + invoker, + config, + registry: Arc::new(Mutex::new(HashMap::new())), + watch_tx: Arc::new(watch_tx), + // Production policy translation is always handled by the embedded + // mapper before any MXC lifecycle side effects begin. + policy_mapper: Arc::new(EmbeddedPolicyMapper), + pending_policies: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// Returns a clone of the `pending_policies` side channel so the gateway's + /// `ComputeRuntime` can stage the typed `SandboxPolicy` by sandbox id right + /// before dispatching `create_sandbox` (A1 wiring). + pub fn policy_sink(&self) -> Arc>> { + self.pending_policies.clone() + } + + /// Test-only constructor wiring the in-process mock `wxc-exec` shim. + #[cfg(test)] + pub(crate) fn new_mocked(config: MxcComputeConfig) -> Self { + let mut backend = Self::new(config); + backend.invoker = WxcExecInvoker::mocked(&backend.config.wxc_exec_path); + backend + } + + pub fn capabilities(&self) -> GetCapabilitiesResponse { + GetCapabilitiesResponse { + driver_name: DRIVER_NAME.to_string(), + driver_version: DRIVER_VERSION.to_string(), + default_image: DEFAULT_IMAGE_SENTINEL.to_string(), + gateway_manages_lifecycle: false, + } + } + + pub fn validate_sandbox_create(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { + if let Some(spec) = &sandbox.spec { + if effective_driver_gpu_count(driver_gpu_requirements( + spec.resource_requirements.as_ref(), + )) + .map_err(tonic::Status::invalid_argument)? + .is_some() + { + return Err(tonic::Status::invalid_argument( + "mxc driver does not support GPU sandboxes", + )); + } + if let Some(tmpl) = &spec.template + && !tmpl.agent_socket_path.is_empty() + { + return Err(tonic::Status::invalid_argument( + "mxc driver does not support agent_socket_path (no in-sandbox supervisor)", + )); + } + } + sandbox_config(sandbox)?; + Ok(()) + } + pub async fn get_sandbox(&self, sandbox_name: &str) -> Option { + let registry = self.registry.lock().await; + registry + .values() + .find(|e| e.sandbox.name == sandbox_name) + .map(|e| e.sandbox.clone()) + } + + pub async fn list_sandboxes(&self) -> Vec { + let registry = self.registry.lock().await; + registry.values().map(|e| e.sandbox.clone()).collect() + } + + pub async fn create_sandbox(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { + let sandbox_id = sandbox.id.clone(); + + // Consume the out-of-band policy staged by `ComputeRuntime::create_sandbox` + // (A1). Always remove so rejected creates cannot leak policy state. + let policy = self.pending_policies.lock().await.remove(&sandbox_id); + self.validate_sandbox_create(sandbox)?; + let sandbox_config = sandbox_config(sandbox)?; + + // Policy translation is deterministic and side-effect free. Do it before + // inserting the registry entry or launching MXC so invalid requests fail + // synchronously at the CreateSandbox boundary. + let mapped = self + .policy_mapper + .map( + policy.as_ref(), + &MapCtx { + sandbox_id: sandbox_id.clone(), + egress: None, + }, + ) + .map_err(|error| tonic::Status::invalid_argument(error.to_string()))?; + + if sandbox + .spec + .as_ref() + .is_none_or(|spec| spec.sandbox_token.is_empty()) + { + tracing::debug!( + sandbox = %sandbox.name, + "no sandbox_token minted (no supervisor consumer on MXC)" + ); + } + + let sandbox_name = sandbox.name.clone(); + let lifecycle_gate = Arc::new(Mutex::new(())); + // Take the gate before publishing the entry. stop/delete can discover the + // sandbox immediately, but cannot pass this guard until startup has either + // installed a cancellable child monitor or failed. + let startup_guard = lifecycle_gate.clone().lock_owned().await; + { + let mut registry = self.registry.lock().await; + if registry.contains_key(&sandbox_id) { + return Err(tonic::Status::already_exists(format!( + "sandbox {sandbox_name} already exists" + ))); + } + let initial = make_sandbox_with_condition( + sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "False".into(), + reason: "Starting".into(), + message: "MXC lifecycle starting".into(), + last_transition_time: String::new(), + }, + false, + ); + let _ = self.watch_tx.send(sandbox_event(initial.clone())); + registry.insert( + sandbox_id.clone(), + SandboxEntry { + sandbox: initial, + iso_sandbox_id: None, + isolation_stopped: false, + phase_state: PhaseState::Starting, + lifecycle_gate, + monitor_cancel: None, + monitor_task: None, + }, + ); + } + + let invoker = self.invoker.clone(); + let config = self.config.clone(); + let registry = self.registry.clone(); + let watch_tx = self.watch_tx.clone(); + let sandbox = sandbox.clone(); + tokio::spawn(async move { + run_lifecycle( + invoker, + config, + registry, + watch_tx, + sandbox, + sandbox_config, + mapped, + startup_guard, + ) + .await; + }); + + Ok(()) + } + pub async fn stop_sandbox(&self, sandbox_name: &str) -> Result<(), tonic::Status> { + let (sandbox_id, lifecycle_gate) = { + let registry = self.registry.lock().await; + let entry = registry + .values() + .find(|entry| entry.sandbox.name == sandbox_name) + .ok_or_else(|| { + tonic::Status::not_found(format!("sandbox {sandbox_name} not found")) + })?; + (entry.sandbox.id.clone(), entry.lifecycle_gate.clone()) + }; + + let _lifecycle_guard = lifecycle_gate.lock().await; + let (iso_id, mut isolation_stopped, cancel, monitor_task) = { + let mut registry = self.registry.lock().await; + let entry = registry.get_mut(&sandbox_id).ok_or_else(|| { + tonic::Status::not_found(format!("sandbox {sandbox_name} not found")) + })?; + ( + entry.iso_sandbox_id.clone(), + entry.isolation_stopped, + entry.monitor_cancel.take(), + entry.monitor_task.take(), + ) + }; + if let Some(cancel) = cancel { + let _ = cancel.send(true); + } + if let Some(task) = monitor_task { + task.await.map_err(|error| { + tonic::Status::internal(format!("mxc process monitor failed: {error}")) + })?; + } + if let Some(ref iso_id) = iso_id + && !isolation_stopped + { + self.invoker.stop(iso_id).await.map_err(|error| { + tonic::Status::internal(format!("wxc-exec stop failed: {error}")) + })?; + isolation_stopped = true; + } + + let mut registry = self.registry.lock().await; + if let Some(entry) = registry.get_mut(&sandbox_id) { + entry.isolation_stopped = isolation_stopped; + entry.phase_state = PhaseState::Stopped; + entry.sandbox = make_sandbox_with_condition( + &entry.sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "False".into(), + reason: "Stopped".into(), + message: "MXC sandbox stopped".into(), + last_transition_time: String::new(), + }, + false, + ); + let snapshot = entry.sandbox.clone(); + drop(registry); + let _ = self.watch_tx.send(sandbox_event(snapshot)); + } + Ok(()) + } + pub async fn delete_sandbox( + &self, + sandbox_id: &str, + sandbox_name: &str, + ) -> Result { + let lifecycle_gate = { + let registry = self.registry.lock().await; + let Some(entry) = registry.get(sandbox_id) else { + return Ok(false); + }; + if entry.sandbox.name != sandbox_name { + return Err(tonic::Status::failed_precondition( + "sandbox_id did not match sandbox_name", + )); + } + entry.lifecycle_gate.clone() + }; + + let _lifecycle_guard = lifecycle_gate.lock().await; + let (iso_id, isolation_stopped, cancel, monitor_task) = { + let mut registry = self.registry.lock().await; + let Some(entry) = registry.get_mut(sandbox_id) else { + return Ok(false); + }; + ( + entry.iso_sandbox_id.clone(), + entry.isolation_stopped, + entry.monitor_cancel.take(), + entry.monitor_task.take(), + ) + }; + if let Some(cancel) = cancel { + let _ = cancel.send(true); + } + if let Some(task) = monitor_task { + task.await.map_err(|error| { + tonic::Status::internal(format!("mxc process monitor failed: {error}")) + })?; + } + if let Some(ref iso_id) = iso_id { + if !isolation_stopped { + self.invoker.stop(iso_id).await.map_err(|error| { + tonic::Status::internal(format!("wxc-exec stop failed: {error}")) + })?; + // Persist phase progress before deprovision. If deprovision + // fails, a retry resumes here instead of stopping twice. + let mut registry = self.registry.lock().await; + if let Some(entry) = registry.get_mut(sandbox_id) { + entry.isolation_stopped = true; + } + } + self.invoker.deprovision(iso_id).await.map_err(|error| { + tonic::Status::internal(format!("wxc-exec deprovision failed: {error}")) + })?; + } + + let mut registry = self.registry.lock().await; + if registry.remove(sandbox_id).is_some() { + let _ = self.watch_tx.send(deleted_event(sandbox_id.to_string())); + return Ok(true); + } + Ok(false) + } + /// Returns a stream of watch events. + /// + /// First emits a snapshot of all current sandboxes, then forwards live + /// events from the broadcast channel. + pub async fn watch_sandboxes(&self) -> WatchStream { + let (tx, rx) = + mpsc::channel::>(256); + + // Subscribe while holding the registry lock. Every transition is then + // represented by either this snapshot or the live receiver. + let (snapshots, mut broadcast_rx): (Vec, _) = { + let registry = self.registry.lock().await; + let broadcast_rx = self.watch_tx.subscribe(); + let snapshots = registry + .values() + .map(|entry| entry.sandbox.clone()) + .collect(); + (snapshots, broadcast_rx) + }; + + let tx_clone = tx.clone(); + tokio::spawn(async move { + // Deliver initial snapshots. + for sb in snapshots { + if tx_clone.send(Ok(sandbox_event(sb))).await.is_err() { + return; + } + } + // Forward live events. + loop { + match broadcast_rx.recv().await { + Ok(event) => { + if tx_clone.send(Ok(event)).await.is_err() { + break; + } + } + Err(broadcast::error::RecvError::Lagged(_)) => { + // Drop lagged events — the gateway re-syncs via Get/List. + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + }); + + Box::pin(ReceiverStream::new(rx)) + } +} + +// ── Lifecycle task ──────────────────────────────────────────────────────────── + +#[allow(clippy::too_many_arguments)] +async fn run_lifecycle( + invoker: WxcExecInvoker, + config: MxcComputeConfig, + registry: Arc>>, + watch_tx: Arc>, + sandbox: DriverSandbox, + sandbox_config: MxcSandboxConfig, + mapped: MappedConfig, + _startup_guard: tokio::sync::OwnedMutexGuard<()>, +) { + let sandbox_id = sandbox.id.clone(); + let sandbox_name = sandbox.name.clone(); + let filesystem = MxcFilesystem { + readwrite_paths: mapped.readwrite_paths, + readonly_paths: mapped.readonly_paths, + // OpenShell's policy model has no explicit deny field; default-deny is + // implicit and enforced by processContainer at the OS boundary. + denied_paths: Vec::new(), + }; + let command_line = encode_windows_command_line(&sandbox_config.command); + let process = MxcProcess { + command_line: command_line.clone(), + cwd: sandbox_config.cwd, + env: sandbox_environment(&sandbox), + timeout: 0, + }; + + let child = match config.backend { + MxcBackend::IsolationSession => { + let iso_sandbox_id = match invoker + .provision(&config.default_configuration_id, filesystem, None) + .await + { + Ok(id) => id, + Err(error) => { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &error.to_string(), + ) + .await; + return; + } + }; + info!(sandbox = %sandbox_name, iso_id = %iso_sandbox_id, "MXC provisioned"); + { + let mut registry = registry.lock().await; + if let Some(entry) = registry.get_mut(&sandbox_id) { + // Publish cleanup identity before any later lifecycle await. + entry.iso_sandbox_id = Some(iso_sandbox_id.clone()); + entry.isolation_stopped = false; + } + } + if let Err(error) = invoker.start(&iso_sandbox_id).await { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &error.to_string(), + ) + .await; + return; + } + info!(sandbox = %sandbox_name, "MXC started"); + match invoker.spawn_exec(&iso_sandbox_id, process).await { + Ok(child) => child, + Err(error) => { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &error.to_string(), + ) + .await; + return; + } + } + } + MxcBackend::ProcessContainer => { + let process_container = MxcProcessContainer { + least_privilege: config.pc_least_privilege, + capabilities: config.pc_capabilities.clone(), + }; + match invoker + .run_oneshot(&sandbox_id, filesystem, process_container, process, None) + .await + { + Ok(child) => child, + Err(error) => { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &error.to_string(), + ) + .await; + return; + } + } + } + }; + info!(sandbox = %sandbox_name, command = %command_line, backend = ?config.backend, "MXC agent launched"); + + let ready_sandbox = make_sandbox_with_condition( + &sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "True".into(), + reason: "AgentRunning".into(), + message: format!("Agent exec launched: {command_line}"), + last_transition_time: String::new(), + }, + false, + ); + let (cancel_tx, cancel_rx) = watch::channel(false); + { + // Publish cancellation state before the monitor can observe a fast + // process exit. Holding the registry lock while spawning prevents a + // completed child from being overwritten with AgentRunning. + let mut registry_guard = registry.lock().await; + if let Some(entry) = registry_guard.get_mut(&sandbox_id) { + entry.sandbox = ready_sandbox.clone(); + entry.phase_state = PhaseState::Running; + entry.monitor_cancel = Some(cancel_tx); + entry.monitor_task = Some(tokio::spawn(monitor_exec( + registry.clone(), + watch_tx.clone(), + sandbox.clone(), + sandbox_id.clone(), + cancel_rx, + child, + ))); + } + } + let _ = watch_tx.send(sandbox_event(ready_sandbox)); +} + +async fn monitor_exec( + registry: Arc>>, + watch_tx: Arc>, + sandbox: DriverSandbox, + sandbox_id: String, + mut cancel_rx: watch::Receiver, + mut child: tokio::process::Child, +) { + let status = tokio::select! { + status = child.wait() => status, + changed = cancel_rx.changed() => { + let should_kill = changed.is_ok() && *cancel_rx.borrow_and_update(); + if should_kill { + if let Err(error) = child.kill().await { + warn!(sandbox = %sandbox.name, error = %error, "failed to terminate MXC agent process"); + } + // `kill` waits on current Tokio releases, but an explicit wait is + // harmless and guarantees the OS process handle is reaped. + let _ = child.wait().await; + } + return; + } + }; + + match status { + Ok(status) if status.success() => { + info!(sandbox = %sandbox.name, "MXC agent exec completed successfully"); + let done = make_sandbox_with_condition( + &sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "True".into(), + reason: "AgentCompleted".into(), + message: "Agent exec finished successfully (exit code 0)".into(), + last_transition_time: String::new(), + }, + false, + ); + let mut registry = registry.lock().await; + if let Some(entry) = registry.get_mut(&sandbox_id) { + entry.sandbox = done.clone(); + entry.phase_state = PhaseState::Running; + } + drop(registry); + let _ = watch_tx.send(sandbox_event(done)); + } + Ok(status) => { + let code = status.code().unwrap_or(-1); + warn!(sandbox = %sandbox.name, exit_code = code, "MXC agent exec exited non-zero"); + let _ = watch_tx.send(platform_event( + sandbox_id.clone(), + "AgentExecFailed", + format!("agent exited with code {code}; possible out-of-policy write"), + )); + let failed = make_sandbox_with_condition( + &sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "False".into(), + reason: "ExecFailed".into(), + message: format!("Agent exec exited {code}"), + last_transition_time: String::new(), + }, + false, + ); + let mut registry = registry.lock().await; + if let Some(entry) = registry.get_mut(&sandbox_id) { + entry.sandbox = failed.clone(); + entry.phase_state = PhaseState::Failed(format!("exit code {code}")); + } + drop(registry); + let _ = watch_tx.send(sandbox_event(failed)); + } + Err(error) => { + warn!(sandbox = %sandbox.name, error = %error, "MXC agent exec wait error"); + } + } +} +async fn set_failed( + registry: &Arc>>, + watch_tx: &Arc>, + sandbox: &DriverSandbox, + sandbox_id: &str, + message: &str, +) { + warn!(sandbox = %sandbox.name, error = %message, "MXC lifecycle failed"); + let failed = make_sandbox_with_condition( + sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "False".into(), + reason: "ProvisionFailed".into(), + message: message.to_string(), + last_transition_time: String::new(), + }, + false, + ); + let mut reg = registry.lock().await; + if let Some(entry) = reg.get_mut(sandbox_id) { + entry.sandbox = failed.clone(); + entry.phase_state = PhaseState::Failed(message.to_string()); + } + drop(reg); + let _ = watch_tx.send(sandbox_event(failed)); +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn make_sandbox_with_condition( + base: &DriverSandbox, + condition: &DriverCondition, + deleting: bool, +) -> DriverSandbox { + DriverSandbox { + id: base.id.clone(), + name: base.name.clone(), + namespace: base.namespace.clone(), + workspace: base.workspace.clone(), + spec: base.spec.clone(), + status: Some(DriverSandboxStatus { + sandbox_name: base.name.clone(), + instance_id: String::new(), + agent_fd: String::new(), + sandbox_fd: String::new(), + conditions: vec![condition.clone()], + deleting, + }), + } +} + +// ── Lifecycle + policy-proof tests (mock wxc-exec) ───────────────────────────── +// +// These drive the full create → provision → start → exec → self-report Ready +// flow against the in-process mock shim, proving the positive (in-policy write +// succeeds, Ready reached) and negative (out-of-policy write denied + denial +// event) paths WITHOUT the demo box. Windows-only (the crate is Windows-gated), +// run by the `windows:test:x64` mise lane. +#[cfg(test)] +mod lifecycle_tests { + use super::*; + use futures::StreamExt; + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_core::proto::{FilesystemPolicy, SandboxPolicy}; + use std::time::Duration; + + fn driver_sandbox(id: &str) -> DriverSandbox { + driver_sandbox_with_command(id, "", vec!["cmd".into(), "/c".into(), "exit 0".into()]) + } + + fn driver_sandbox_with_command(id: &str, cwd: &str, command: Vec) -> DriverSandbox { + let serde_json::Value::Object(driver_config) = serde_json::json!({ + "command": command, + "cwd": cwd, + }) else { + unreachable!(); + }; + DriverSandbox { + id: id.to_string(), + name: id.to_string(), + namespace: String::new(), + workspace: String::new(), + spec: Some(DriverSandboxSpec { + sandbox_token: "test-token".into(), + template: Some(DriverSandboxTemplate { + driver_config: Some( + openshell_core::proto_struct::json_object_to_struct(driver_config).unwrap(), + ), + ..Default::default() + }), + ..Default::default() + }), + status: None, + } + } + fn fs_policy(read_write: &[&str]) -> SandboxPolicy { + SandboxPolicy { + filesystem: Some(FilesystemPolicy { + include_workdir: false, + read_only: Vec::new(), + read_write: read_write.iter().map(ToString::to_string).collect(), + }), + ..Default::default() + } + } + + fn ready_condition(sb: &DriverSandbox) -> Option { + sb.status + .as_ref()? + .conditions + .iter() + .find(|c| c.r#type == "Ready") + .cloned() + } + + /// Poll the backend registry until the predicate matches or the deadline hits. + async fn wait_for( + backend: &MxcComputeBackend, + name: &str, + mut pred: F, + ) -> Option + where + F: FnMut(&DriverSandbox) -> bool, + { + for _ in 0..100 { + if let Some(sandbox) = backend.get_sandbox(name).await + && pred(&sandbox) + { + return Some(sandbox); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + None + } + + #[test] + fn mxc_config_defaults_to_default_deny_process_container() { + assert_eq!( + MxcComputeConfig::default().backend, + MxcBackend::ProcessContainer + ); + } + + #[test] + fn sandbox_environment_uses_sandbox_scope_with_spec_precedence() { + let mut sandbox = driver_sandbox("sb-env"); + let spec = sandbox.spec.as_mut().unwrap(); + spec.template + .as_mut() + .unwrap() + .environment + .insert("SHARED".into(), "template".into()); + spec.environment.insert("SHARED".into(), "spec".into()); + spec.environment.insert("TOKEN".into(), "value".into()); + assert_eq!( + sandbox_environment(&sandbox), + vec!["SHARED=spec".to_string(), "TOKEN=value".to_string()] + ); + } + + #[test] + fn windows_command_line_preserves_argument_boundaries() { + assert_eq!( + encode_windows_command_line(&[ + r"C:\Program Files\Agent\agent.exe".into(), + "hello world".into(), + String::new(), + ]), + r#""C:\Program Files\Agent\agent.exe" "hello world" """# + ); + assert_eq!( + quote_windows_argument(r#"say "hello""#), + r#""say \"hello\"""# + ); + assert_eq!( + quote_windows_argument("trailing slash\\ "), + r#""trailing slash\ ""# + ); + } + #[tokio::test] + async fn positive_in_policy_write_reaches_ready_and_materializes_file() { + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let hello = format!("{share}/hello.txt"); + let cmd = vec![ + "powershell".into(), + "-NoProfile".into(), + "-Command".into(), + format!("Set-Content -LiteralPath {hello} -Value hi"), + ]; + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + + // Stage the policy via the A1 side channel (as ComputeRuntime would). + let sink = backend.policy_sink(); + sink.lock() + .await + .insert("sb-pos".into(), fs_policy(&[&share])); + + let sb = driver_sandbox_with_command("sb-pos", &share, cmd); + backend.create_sandbox(&sb).await.expect("create accepted"); + + // Self-reported Ready=True (no supervisor) once the agent exec launches. + let ready = wait_for(&backend, "sb-pos", |s| { + ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") + }) + .await; + assert!(ready.is_some(), "sandbox should self-report Ready=True"); + + // Positive proof: the in-policy write materializes the host artifact. + let host_path = std::path::Path::new(tmp.path()).join("hello.txt"); + let mut found = false; + for _ in 0..100 { + if host_path.exists() { + found = true; + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!(found, "hello.txt should appear in the granted share folder"); + + // A successful one-shot agent (exit 0) must STAY Ready, not demote to + // Error. Assert the terminal condition is Ready=True/AgentCompleted so the + // positive demo shows a green Ready phase, not a red Error. + let completed = wait_for(&backend, "sb-pos", |s| { + ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentCompleted") + }) + .await; + assert!( + completed.is_some(), + "sandbox should remain Ready=True (AgentCompleted) after a successful exec, never demote to Error" + ); + } + + #[tokio::test] + async fn processcontainer_one_shot_in_policy_write_reaches_ready() { + // The processContainer backend skips provision/start and runs a single + // one-shot. The mock routes through `run_oneshot`, deriving grants from + // the filesystem (not a provision step), so the in-policy write should + // materialize and the sandbox should reach Ready=True. + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let hello = format!("{share}/hello.txt"); + let cmd = vec![ + "powershell".into(), + "-NoProfile".into(), + "-Command".into(), + format!("Set-Content -LiteralPath {hello} -Value hi"), + ]; + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + + let sink = backend.policy_sink(); + sink.lock() + .await + .insert("sb-pc".into(), fs_policy(&[&share])); + + let sb = driver_sandbox_with_command("sb-pc", &share, cmd); + backend.create_sandbox(&sb).await.expect("create accepted"); + + let ready = wait_for(&backend, "sb-pc", |s| { + ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") + }) + .await; + assert!( + ready.is_some(), + "processContainer sandbox should self-report Ready=True" + ); + let recorded = crate::mxc::mock_recorded_config("sb-pc").expect("mock recorded config"); + assert!( + recorded.get("network").is_none(), + "coarse path must not emit an MXC network block" + ); + + let host_path = std::path::Path::new(tmp.path()).join("hello.txt"); + let mut found = false; + for _ in 0..100 { + if host_path.exists() { + found = true; + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!( + found, + "in-policy write should materialize under processContainer" + ); + } + + #[tokio::test] + async fn negative_out_of_policy_write_is_denied_with_event() { + let share_tmp = tempfile::tempdir().unwrap(); + let out_tmp = tempfile::tempdir().unwrap(); + let share = share_tmp.path().to_string_lossy().replace('\\', "/"); + let out_path = format!( + "{}/hello.txt", + out_tmp.path().to_string_lossy().replace('\\', "/") + ); + let cmd = vec![ + "powershell".into(), + "-NoProfile".into(), + "-Command".into(), + format!("Set-Content -LiteralPath {out_path} -Value hi"), + ]; + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + + // Subscribe to the watch stream BEFORE create so we catch the denial event. + let mut stream = backend.watch_sandboxes().await; + + let sink = backend.policy_sink(); + sink.lock() + .await + .insert("sb-neg".into(), fs_policy(&[&share])); + backend + .create_sandbox(&driver_sandbox_with_command("sb-neg", &share, cmd)) + .await + .expect("create accepted"); + + // Collect events until we observe the AgentExecFailed platform event. + let mut saw_denial = false; + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + while tokio::time::Instant::now() < deadline { + match tokio::time::timeout(Duration::from_millis(500), stream.next()).await { + Ok(Some(Ok(ev))) => { + if let Some(watch_sandboxes_event::Payload::PlatformEvent(event)) = ev.payload + && event + .event + .as_ref() + .is_some_and(|event| event.reason == "AgentExecFailed") + { + saw_denial = true; + break; + } + } + Ok(_) => break, + Err(_) => {} + } + } + assert!( + saw_denial, + "expected an AgentExecFailed denial platform event" + ); + + // The out-of-policy artifact must NOT have been written by the mock. + let out_fs = std::path::Path::new(out_tmp.path()).join("hello.txt"); + assert!(!out_fs.exists(), "out-of-policy write must be denied"); + + // And the sandbox surfaces a terminal ExecFailed Ready=False condition. + let failed = wait_for(&backend, "sb-neg", |s| { + ready_condition(s).is_some_and(|c| c.status == "False" && c.reason == "ExecFailed") + }) + .await; + assert!(failed.is_some(), "sandbox should report ExecFailed"); + } + + #[tokio::test] + async fn stop_terminates_and_reaps_a_running_process_container() { + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let command = vec![ + "powershell".into(), + "-NoProfile".into(), + "-Command".into(), + format!("$null = '{share}'; Start-Sleep -Seconds 60"), + ]; + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + backend + .policy_sink() + .lock() + .await + .insert("sb-stop".into(), fs_policy(&[&share])); + backend + .create_sandbox(&driver_sandbox_with_command("sb-stop", "", command)) + .await + .expect("create accepted"); + wait_for(&backend, "sb-stop", |sandbox| { + ready_condition(sandbox).is_some_and(|condition| condition.reason == "AgentRunning") + }) + .await + .expect("long-running child should start"); + tokio::time::sleep(Duration::from_millis(250)).await; + let running = backend.get_sandbox("sb-stop").await.unwrap(); + assert_eq!(ready_condition(&running).unwrap().reason, "AgentRunning"); + + tokio::time::timeout(Duration::from_secs(5), backend.stop_sandbox("sb-stop")) + .await + .expect("stop should not wait for the child sleep") + .expect("stop should terminate and reap the child"); + let stopped = backend.get_sandbox("sb-stop").await.unwrap(); + assert_eq!(ready_condition(&stopped).unwrap().reason, "Stopped"); + } + + #[tokio::test] + async fn unmappable_network_policy_fails_create_lifecycle() { + use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + + let mut policy = fs_policy(&[&share]); + policy.network_policies.insert( + "api".into(), + NetworkPolicyRule { + name: "api".into(), + endpoints: vec![NetworkEndpoint { + host: "example.com".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + backend + .policy_sink() + .lock() + .await + .insert("sb-net".into(), policy); + let error = backend + .create_sandbox(&driver_sandbox("sb-net")) + .await + .expect_err("unmappable policy must fail CreateSandbox synchronously"); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(backend.get_sandbox("sb-net").await.is_none()); + } +} diff --git a/crates/openshell-driver-mxc/src/grpc.rs b/crates/openshell-driver-mxc/src/grpc.rs new file mode 100644 index 0000000000..9e7dada94f --- /dev/null +++ b/crates/openshell-driver-mxc/src/grpc.rs @@ -0,0 +1,209 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Thin tonic adapter: delegates to `MxcComputeBackend` and maps errors to +//! gRPC `Status`. + +#![allow(clippy::result_large_err)] + +use crate::driver::MxcComputeBackend; +use futures::{Stream, StreamExt}; +use openshell_core::proto::compute::v1::{ + CreateSandboxRequest, CreateSandboxResponse, DeleteSandboxRequest, DeleteSandboxResponse, + DeleteWorkspaceRequest, DeleteWorkspaceResponse, EnsureWorkspaceRequest, + EnsureWorkspaceResponse, GetCapabilitiesRequest, GetCapabilitiesResponse, + GetGatewayListenerRequirementsRequest, GetGatewayListenerRequirementsResponse, + GetSandboxRequest, GetSandboxResponse, ListSandboxesRequest, ListSandboxesResponse, + StartSandboxRequest, StartSandboxResponse, StopSandboxRequest, StopSandboxResponse, + ValidateSandboxCreateRequest, ValidateSandboxCreateResponse, WatchSandboxesEvent, + WatchSandboxesRequest, compute_driver_server::ComputeDriver, +}; +use std::pin::Pin; +use tonic::{Request, Response, Status}; + +#[derive(Debug)] +pub struct ComputeDriverService { + backend: MxcComputeBackend, +} + +impl ComputeDriverService { + pub fn new(backend: MxcComputeBackend) -> Self { + Self { backend } + } +} + +#[tonic::async_trait] +impl ComputeDriver for ComputeDriverService { + async fn get_capabilities( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(self.backend.capabilities())) + } + + async fn get_gateway_listener_requirements( + &self, + _request: Request, + ) -> Result, Status> { + // MXC is an in-process, single-host driver: it needs no extra gateway + // listeners (no relay/surrogate/remote endpoint), so it reports none. + Ok(Response::new(GetGatewayListenerRequirementsResponse { + requirements: Vec::new(), + })) + } + + async fn validate_sandbox_create( + &self, + request: Request, + ) -> Result, Status> { + let sandbox = request + .into_inner() + .sandbox + .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; + self.backend.validate_sandbox_create(&sandbox)?; + Ok(Response::new(ValidateSandboxCreateResponse {})) + } + + async fn get_sandbox( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + if req.sandbox_name.is_empty() { + return Err(Status::invalid_argument("sandbox_name is required")); + } + let sandbox = self + .backend + .get_sandbox(&req.sandbox_name) + .await + .ok_or_else(|| Status::not_found(format!("sandbox {} not found", req.sandbox_name)))?; + if !req.sandbox_id.is_empty() && req.sandbox_id != sandbox.id { + return Err(Status::failed_precondition( + "sandbox_id did not match the fetched sandbox", + )); + } + Ok(Response::new(GetSandboxResponse { + sandbox: Some(sandbox), + })) + } + + async fn list_sandboxes( + &self, + _request: Request, + ) -> Result, Status> { + let sandboxes = self.backend.list_sandboxes().await; + Ok(Response::new(ListSandboxesResponse { sandboxes })) + } + + async fn create_sandbox( + &self, + request: Request, + ) -> Result, Status> { + let sandbox = request + .into_inner() + .sandbox + .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; + self.backend.create_sandbox(&sandbox).await?; + Ok(Response::new(CreateSandboxResponse {})) + } + + async fn stop_sandbox( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + if req.sandbox_name.is_empty() { + return Err(Status::invalid_argument("sandbox_name is required")); + } + self.backend.stop_sandbox(&req.sandbox_name).await?; + Ok(Response::new(StopSandboxResponse {})) + } + + async fn start_sandbox( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "mxc driver does not support restarting stopped sandboxes", + )) + } + + async fn delete_sandbox( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + if req.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } + if req.sandbox_name.is_empty() { + return Err(Status::invalid_argument("sandbox_name is required")); + } + let deleted = self + .backend + .delete_sandbox(&req.sandbox_id, &req.sandbox_name) + .await?; + Ok(Response::new(DeleteSandboxResponse { deleted })) + } + + type WatchSandboxesStream = + Pin> + Send + 'static>>; + + async fn watch_sandboxes( + &self, + _request: Request, + ) -> Result, Status> { + let stream = self.backend.watch_sandboxes().await; + let mapped = stream.map(|item| item.map_err(|e| Status::internal(e.to_string()))); + Ok(Response::new(Box::pin(mapped))) + } + + async fn ensure_workspace( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(EnsureWorkspaceResponse {})) + } + + async fn delete_workspace( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(DeleteWorkspaceResponse {})) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::driver::MxcComputeConfig; + + #[tokio::test] + async fn start_sandbox_reports_one_shot_lifecycle() { + let service = + ComputeDriverService::new(MxcComputeBackend::new(MxcComputeConfig::default())); + + let error = service + .start_sandbox(Request::new(StartSandboxRequest::default())) + .await + .expect_err("MXC must not restart a stopped one-shot workload"); + + assert_eq!(error.code(), tonic::Code::Unimplemented); + assert!(error.message().contains("does not support restarting")); + } + + #[tokio::test] + async fn workspace_lifecycle_is_an_idempotent_no_op() { + let service = + ComputeDriverService::new(MxcComputeBackend::new(MxcComputeConfig::default())); + + service + .ensure_workspace(Request::new(EnsureWorkspaceRequest::default())) + .await + .expect("MXC has no driver-owned workspace resource to provision"); + service + .delete_workspace(Request::new(DeleteWorkspaceRequest::default())) + .await + .expect("MXC workspace deletion must remain idempotent"); + } +} diff --git a/crates/openshell-driver-mxc/src/lib.rs b/crates/openshell-driver-mxc/src/lib.rs new file mode 100644 index 0000000000..dc251649f8 --- /dev/null +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! `OpenShell` MXC compute driver. +//! +//! Implements the gateway's `ComputeDriver` gRPC contract backed by Microsoft +//! MXC (`wxc-exec`) on Windows. The driver is **in-process**, runs the agent +//! directly (exec-in-driver), and self-reports `Ready` — there is no +//! in-sandbox supervisor, no host-side surrogate, and no `ConnectSupervisor` +//! relay. +//! +//! This crate compiles to an **empty stub** on non-Windows targets so the +//! Linux build stays green. All implementation code is gated on +//! `#[cfg(target_os = "windows")]`. + +#![allow(clippy::result_large_err)] + +#[cfg(target_os = "windows")] +mod driver; +#[cfg(target_os = "windows")] +mod grpc; +#[cfg(target_os = "windows")] +mod mxc; +#[cfg(target_os = "windows")] +mod policy; +// Embedded mapper logic (source of truth; was the `openshell-policy-mapper` +// crate). Windows-only — MXC and the policy mapper are not built for Linux/WSL. +#[cfg(target_os = "windows")] +mod policy_map; + +#[cfg(target_os = "windows")] +pub use driver::{MxcBackend, MxcComputeBackend, MxcComputeConfig}; +#[cfg(target_os = "windows")] +pub use grpc::ComputeDriverService; +// Re-export the embedded mapper API so the windows-only example and integration +// test can reach it without making `policy_map` a public module. +#[cfg(target_os = "windows")] +pub use policy::{EmbeddedPolicyMapper, MapCtx, MapError, MappedConfig, PolicyMapper}; +#[cfg(target_os = "windows")] +pub use policy_map::{ + DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, LossItem, MxcMappingOptions, + MxcMappingResult, OPEN_SHELL_SUPERSET_GAPS, SplitPolicyResult, build_loss_report, map_to_mxc, + render_readme, split_policy, +}; diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs new file mode 100644 index 0000000000..32d7006d90 --- /dev/null +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -0,0 +1,867 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! `wxc-exec` invoker and MXC request/response types. +//! +//! Builds state-aware MXC config JSON, base64-encodes it, runs `wxc-exec`, +//! and parses the response envelope. The exec phase is special: its stdout is +//! live process output (not JSON) and its exit code is the agent exit code. + +use base64::Engine as _; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; +use thiserror::Error; +use tokio::process::Command; +use tracing::debug; + +/// MXC config schema version. +pub const MXC_SCHEMA_VERSION: &str = "0.6.0-alpha"; + +/// Default `configurationId` for isolation session. Never use `"small"` (known OS bug). +pub const DEFAULT_CONFIGURATION_ID: &str = "composable"; + +/// Environment flag selecting the in-process mock `wxc-exec` shim. When set to +/// `"1"`, the invoker does NOT spawn the real `wxc-exec.exe`; instead it emits +/// canned provision/start/stop/deprovision results and simulates `AppContainer` +/// filesystem-policy enforcement for the exec phase. This is what makes the +/// full create → Ready → policy-proof round trip runnable off the demo box. +pub const MOCK_ENV_VAR: &str = "OPENSHELL_MXC_MOCK_WXC"; + +fn mock_enabled() -> bool { + std::env::var(MOCK_ENV_VAR).is_ok_and(|value| value == "1") +} + +/// Normalize a path/command fragment to lowercase backslash form for the mock's +/// in-policy substring check. +fn mock_normalize(s: &str) -> String { + s.replace('/', "\\").to_lowercase() +} + +/// Per-process mock state: `iso:` sandbox id → granted read-write paths +/// (normalized). Populated by the mock provision, consumed by the mock exec to +/// decide whether the agent's write target is in-policy. +fn mock_grants() -> &'static Mutex>> { + static GRANTS: OnceLock>>> = OnceLock::new(); + GRANTS.get_or_init(|| Mutex::new(HashMap::new())) +} + +// ── Request types ───────────────────────────────────────────────────────────── + +/// Filesystem shares for the sandbox. +/// +/// `isolation_session` honors `readwrite`/`readonly` (grant-only — it has no +/// deny primitive). `processContainer` additionally honors `denied_paths` +/// because the `AppContainer` backend can stamp deny ACEs; it is also genuinely +/// default-deny, so anything not granted is already inaccessible. +#[derive(Debug, Default)] +#[allow(clippy::struct_field_names)] +pub struct MxcFilesystem { + pub readwrite_paths: Vec, + pub readonly_paths: Vec, + pub denied_paths: Vec, +} + +/// Network redirect fragment emitted when governed egress is enabled. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MxcNetwork { + pub default_policy: String, + pub proxy: Option, +} + +/// `processContainer`-specific knobs (one-shot `AppContainer` backend). +#[derive(Debug, Default, Clone)] +pub struct MxcProcessContainer { + /// Request a Less-Privileged `AppContainer` (stricter default-deny). + pub least_privilege: bool, + /// `AppContainer` capabilities to grant (e.g. `internetClient`). + pub capabilities: Vec, +} + +/// Process config for the exec phase. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MxcProcess { + pub command_line: String, + pub cwd: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub env: Vec, + /// 0 = no timeout (long-lived agent). + pub timeout: u64, +} + +fn network_json(network: &MxcNetwork) -> serde_json::Value { + // MXC 0.6.0-alpha schema accepts ONLY {"proxy": {"localhost": }}. + // {"host": ..., "port": ...} and every other shape is rejected — verified + // empirically against the real wxc-exec 0.6.0-alpha binary via --dry-run. + // See also docs/reference/mxc-compute-driver-design.mdx §network.proxy. + // The MxcNetwork.proxy field remains SocketAddr so callers keep full + // precision; only the port is serialized into the localhost key. + let mut value = serde_json::json!({ + "defaultPolicy": network.default_policy.as_str(), + "allowedHosts": [], + "blockedHosts": [], + }); + if let Some(proxy) = network.proxy { + value["proxy"] = serde_json::json!({ "localhost": proxy.port() }); + } + value +} + +fn provision_config_json( + configuration_id: &str, + filesystem: &MxcFilesystem, + network: Option<&MxcNetwork>, +) -> serde_json::Value { + let mut config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "provision", + "containment": "isolation_session", + "filesystem": { + "readwritePaths": &filesystem.readwrite_paths, + "readonlyPaths": &filesystem.readonly_paths, + }, + "experimental": { + "isolation_session": { + "configurationId": configuration_id, + "provision": {} + } + } + }); + if let Some(network) = network { + config["network"] = network_json(network); + } + config +} + +fn oneshot_config_json( + container_id: &str, + filesystem: &MxcFilesystem, + pc: &MxcProcessContainer, + process: &MxcProcess, + network: Option<&MxcNetwork>, +) -> serde_json::Value { + let mut filesystem_json = serde_json::Map::new(); + if !filesystem.readwrite_paths.is_empty() { + filesystem_json.insert( + "readwritePaths".into(), + filesystem.readwrite_paths.clone().into(), + ); + } + if !filesystem.readonly_paths.is_empty() { + filesystem_json.insert( + "readonlyPaths".into(), + filesystem.readonly_paths.clone().into(), + ); + } + if !filesystem.denied_paths.is_empty() { + filesystem_json.insert("deniedPaths".into(), filesystem.denied_paths.clone().into()); + } + + let mut pc_json = serde_json::Map::new(); + pc_json.insert("leastPrivilege".into(), pc.least_privilege.into()); + if !pc.capabilities.is_empty() { + pc_json.insert("capabilities".into(), pc.capabilities.clone().into()); + } + + let mut config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "containerId": container_id, + "containment": "processcontainer", + "process": { + "commandLine": process.command_line.as_str(), + "cwd": process.cwd.as_str(), + "env": &process.env, + "timeout": process.timeout, + }, + "processContainer": serde_json::Value::Object(pc_json), + "filesystem": serde_json::Value::Object(filesystem_json), + }); + if let Some(network) = network { + config["network"] = network_json(network); + } + config +} + +#[cfg(test)] +fn mock_configs() -> &'static Mutex> { + static CONFIGS: OnceLock>> = OnceLock::new(); + CONFIGS.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[cfg(test)] +pub fn mock_recorded_config(id: &str) -> Option { + mock_configs().lock().unwrap().get(id).cloned() +} + +// ── Response envelope ───────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct ProvisionResult { + #[serde(rename = "sandboxId")] + pub sandbox_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum MxcEnvelope { + Ok { + #[allow(dead_code)] + result: serde_json::Value, + }, + Err { + error: MxcErrorBody, + }, +} + +#[derive(Debug, Deserialize)] +pub struct MxcErrorBody { + pub code: String, + pub message: String, +} + +#[derive(Debug, Deserialize)] +pub struct ProvisionEnvelope { + pub result: Option, + pub error: Option, +} + +// ── Errors ──────────────────────────────────────────────────────────────────── + +#[derive(Debug, Error)] +pub enum InvokerError { + #[error("wxc-exec spawn failed: {0}")] + Spawn(#[from] std::io::Error), + #[error("wxc-exec config serialization failed: {0}")] + Serialize(#[from] serde_json::Error), + #[error("wxc-exec envelope parse failed (stdout={stdout:?}): {source}")] + Parse { + stdout: String, + source: serde_json::Error, + }, + #[error("wxc-exec process failed with no envelope (exit={exit_code}, stderr={stderr:?})")] + NoEnvelope { exit_code: i32, stderr: String }, + #[error("MXC error [{code}]: {message}")] + Mxc { code: String, message: String }, + /// Exec phase returned a non-zero exit code (the agent's own exit status). + /// Surfaced through the watch stream rather than as a gRPC error. + #[allow(dead_code)] + #[error("wxc-exec exec phase exited with code {0}")] + ExecNonZero(i32), +} + +impl InvokerError { + #[allow(dead_code)] + pub fn to_tonic_status(&self) -> tonic::Status { + match self { + Self::Mxc { code, message } => match code.as_str() { + "malformed_request" | "unsupported_phase" => { + tonic::Status::internal(format!("driver bug: {message}")) + } + "unsupported_containment" + | "not_provisioned" + | "not_started" + | "already_started" + | "already_stopped" => tonic::Status::failed_precondition(message.clone()), + "malformed_id" | "stale_id" => tonic::Status::not_found(message.clone()), + "policy_validation" => tonic::Status::invalid_argument(message.clone()), + "backend_unavailable" => tonic::Status::unavailable(message.clone()), + _ => tonic::Status::internal(message.clone()), + }, + Self::Spawn(e) => tonic::Status::internal(format!("wxc-exec spawn: {e}")), + Self::Serialize(e) => tonic::Status::internal(format!("config serialize: {e}")), + Self::Parse { .. } | Self::NoEnvelope { .. } => { + tonic::Status::internal(self.to_string()) + } + Self::ExecNonZero(code) => { + tonic::Status::internal(format!("agent exited with code {code}")) + } + } + } +} + +// ── Invoker ─────────────────────────────────────────────────────────────────── + +/// Wraps `wxc-exec` invocations for the MXC state-aware lifecycle. +#[derive(Debug, Clone)] +pub struct WxcExecInvoker { + exec_path: PathBuf, + debug: bool, + /// When true, use the in-process mock instead of spawning `wxc-exec.exe`. + mock: bool, +} + +impl WxcExecInvoker { + pub fn new(exec_path: impl Into, debug: bool) -> Self { + Self { + exec_path: exec_path.into(), + debug, + mock: mock_enabled(), + } + } + + /// Test-only constructor that forces mock mode without touching the + /// process-global `OPENSHELL_MXC_MOCK_WXC` env var (avoids races/UB across + /// parallel tests under edition 2024's `unsafe` `set_var`). + #[cfg(test)] + pub(crate) fn mocked(exec_path: impl Into) -> Self { + Self { + exec_path: exec_path.into(), + debug: false, + mock: true, + } + } + + /// Encode `config` as base64 and invoke wxc-exec, returning the parsed envelope. + /// Use this for all **non-exec** phases (provision/start/stop/deprovision). + pub async fn run_phase(&self, config: &serde_json::Value) -> Result<(), InvokerError> { + if self.mock { + // Mock start/stop/deprovision: canned `{"result":{}}` success. + debug!(phase = ?config.get("phase"), "mock wxc-exec phase (no-op success)"); + return Ok(()); + } + let json = serde_json::to_string(config)?; + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let mut cmd = Command::new(&self.exec_path); + cmd.arg("--config-base64").arg(&b64).arg("--experimental"); + if self.debug { + cmd.arg("--debug"); + } + + debug!(config = %json, "wxc-exec phase"); + let output = cmd.output().await?; + + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + + if !output.status.success() { + if let Ok(MxcEnvelope::Err { error }) = serde_json::from_str::(&stdout) { + return Err(InvokerError::Mxc { + code: error.code, + message: error.message, + }); + } + let code = output.status.code().unwrap_or(-1); + return Err(InvokerError::NoEnvelope { + exit_code: code, + stderr, + }); + } + + // Success — parse envelope to surface any embedded error field. + match serde_json::from_str::(&stdout) { + Ok(MxcEnvelope::Err { error }) => Err(InvokerError::Mxc { + code: error.code, + message: error.message, + }), + Ok(MxcEnvelope::Ok { .. }) => Ok(()), + Err(_) if stdout.trim().is_empty() => { + // Some phases return empty stdout on success. + Ok(()) + } + Err(e) => Err(InvokerError::Parse { stdout, source: e }), + } + } + + /// Run the provision phase and return the `sandboxId` from the response. + pub async fn provision( + &self, + configuration_id: &str, + filesystem: MxcFilesystem, + network: Option, + ) -> Result { + if self.mock { + // Mock provision: mint a synthetic `iso:` id and record the granted + // read-write paths so the mock exec can enforce the policy. + let id = format!("iso:mock-{}", uuid::Uuid::new_v4()); + let grants: Vec = filesystem + .readwrite_paths + .iter() + .map(|p| mock_normalize(p)) + .collect(); + mock_grants().lock().unwrap().insert(id.clone(), grants); + #[cfg(test)] + { + let config = provision_config_json(configuration_id, &filesystem, network.as_ref()); + mock_configs().lock().unwrap().insert(id.clone(), config); + } + debug!(sandbox_id = %id, "mock wxc-exec provision"); + return Ok(id); + } + let config = provision_config_json(configuration_id, &filesystem, network.as_ref()); + + let json = serde_json::to_string(&config)?; + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let mut cmd = Command::new(&self.exec_path); + cmd.arg("--config-base64").arg(&b64).arg("--experimental"); + if self.debug { + cmd.arg("--debug"); + } + + debug!(config = %json, "wxc-exec provision"); + let output = cmd.output().await?; + + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + + if !output.status.success() { + let code = output.status.code().unwrap_or(-1); + if let Ok(ProvisionEnvelope { + error: Some(error), .. + }) = serde_json::from_str::(&stdout) + { + return Err(InvokerError::Mxc { + code: error.code, + message: error.message, + }); + } + return Err(InvokerError::NoEnvelope { + exit_code: code, + stderr, + }); + } + + let env: ProvisionEnvelope = + serde_json::from_str(&stdout).map_err(|e| InvokerError::Parse { + stdout: stdout.clone(), + source: e, + })?; + + if let Some(err) = env.error { + return Err(InvokerError::Mxc { + code: err.code, + message: err.message, + }); + } + + env.result + .map(|r| r.sandbox_id) + .ok_or_else(|| InvokerError::NoEnvelope { + exit_code: 0, + stderr: "provision result missing sandboxId".to_string(), + }) + } + + /// Run the start phase for an already-provisioned sandbox. + pub async fn start(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> { + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "start", + "sandboxId": iso_sandbox_id, + "experimental": { + "isolation_session": { + "start": {} + } + } + }); + self.run_phase(&config).await + } + + /// Spawn the exec phase (agent command). Returns the child process handle. + /// **Stdout is raw agent output, not a JSON envelope. Exit code == agent exit code.** + pub async fn spawn_exec( + &self, + iso_sandbox_id: &str, + process: MxcProcess, + ) -> Result { + if self.mock { + return Self::mock_spawn_exec(iso_sandbox_id, &process); + } + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "exec", + "sandboxId": iso_sandbox_id, + "process": { + "commandLine": process.command_line, + "cwd": process.cwd, + "env": process.env, + "timeout": process.timeout, + } + }); + + let json = serde_json::to_string(&config)?; + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let mut cmd = Command::new(&self.exec_path); + cmd.arg("--config-base64") + .arg(&b64) + .arg("--experimental") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .kill_on_drop(true); + if self.debug { + cmd.arg("--debug"); + } + + debug!(sandbox_id = %iso_sandbox_id, command = %process.command_line, "wxc-exec exec spawn"); + let child = cmd.spawn()?; + Ok(child) + } + + /// Mock exec: simulate `AppContainer` filesystem-policy enforcement. + /// + /// The agent's write target is considered **in-policy** iff the command line + /// references one of the granted read-write paths recorded at mock provision. + fn mock_spawn_exec( + iso_sandbox_id: &str, + process: &MxcProcess, + ) -> Result { + let grants = mock_grants() + .lock() + .unwrap() + .get(iso_sandbox_id) + .cloned() + .unwrap_or_default(); + Self::mock_spawn_with_grants(process, &grants) + } + + /// Shared mock enforcement used by both the `isolation_session` exec phase + /// and the one-shot `processContainer` path. + /// + /// In-policy → run the real agent command (so the positive-proof artifact, + /// e.g. `hello.txt`, actually appears on the host shared folder). Out-of-policy + /// → refuse with an access-denied message on stderr and a non-zero exit, + /// mirroring how the `AppContainer` denies the write on the demo box. + fn mock_spawn_with_grants( + process: &MxcProcess, + grants: &[String], + ) -> Result { + let cmd_norm = mock_normalize(&process.command_line); + let in_policy = grants.iter().any(|g| !g.is_empty() && cmd_norm.contains(g)); + + let mut cmd = Command::new("cmd"); + cmd.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .kill_on_drop(true); + if in_policy { + debug!(command = %process.command_line, "mock exec: in-policy, running agent"); + // `command_line` is already encoded with Windows quoting rules. + // Pass it raw so this mock matches wxc-exec/CreateProcess instead + // of asking Rust to quote the entire command as one cmd.exe argv. + cmd.raw_arg(format!("/d /s /c \"{}\"", process.command_line)); + } else { + debug!(command = %process.command_line, "mock exec: OUT-OF-POLICY, denying"); + cmd.arg("/c").arg( + "echo Access is denied. (out-of-policy write blocked by AppContainer) 1>&2& exit 1", + ); + } + let child = cmd.spawn()?; + Ok(child) + } + + /// Build a **one-shot** `processContainer` config (no `phase`) and spawn it. + /// + /// Unlike the `isolation_session` lifecycle (provision → start → exec → + /// stop → deprovision), `processContainer` is a single ephemeral + /// `AppContainer`: one `wxc-exec` invocation creates the container, runs the + /// one process, and tears down when it exits. The `AppContainer` is genuinely + /// default-deny, so a write to any ungranted path is denied by the OS. + /// + /// **Stdout is raw agent output; the exit code is the agent's own exit code.** + pub async fn run_oneshot( + &self, + container_id: &str, + filesystem: MxcFilesystem, + pc: MxcProcessContainer, + process: MxcProcess, + network: Option, + ) -> Result { + let config = + oneshot_config_json(container_id, &filesystem, &pc, &process, network.as_ref()); + if self.mock { + let grants: Vec = filesystem + .readwrite_paths + .iter() + .map(|p| mock_normalize(p)) + .collect(); + #[cfg(test)] + mock_configs() + .lock() + .unwrap() + .insert(container_id.to_owned(), config); + return Self::mock_spawn_with_grants(&process, &grants); + } + + let json = serde_json::to_string(&config)?; + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let mut cmd = Command::new(&self.exec_path); + cmd.arg("--config-base64") + .arg(&b64) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .kill_on_drop(true); + if self.debug { + cmd.arg("--debug"); + } + + debug!(container_id = %container_id, command = %process.command_line, "wxc-exec one-shot processContainer spawn"); + let child = cmd.spawn()?; + Ok(child) + } + + /// Run the stop phase. + /// + /// `stop`/`deprovision` are **unit** variants in the wxc-exec schema: they + /// must serialize as `null`, not `{}`. Empirical (build 26300.8553, + /// wxc-exec 2026-06-10): `"stop": {}` is rejected with `malformed_request` + /// ("invalid type: map, expected unit"); `provision`/`start` accept maps. + pub async fn stop(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> { + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "stop", + "sandboxId": iso_sandbox_id, + "experimental": { + "isolation_session": { + "stop": null + } + } + }); + self.run_phase(&config).await + } + + /// Run the deprovision phase (unit variant — see [`Self::stop`]). + pub async fn deprovision(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> { + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "deprovision", + "sandboxId": iso_sandbox_id, + "experimental": { + "isolation_session": { + "deprovision": null + } + } + }); + self.run_phase(&config).await + } +} + +// ── Tests (pure serde — compile and run cross-platform) ────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provision_envelope_parse_success() { + let json = r#"{"result":{"sandboxId":"iso:wxc-abc123","metadata":{}}}"#; + let env: ProvisionEnvelope = serde_json::from_str(json).unwrap(); + assert_eq!(env.result.unwrap().sandbox_id, "iso:wxc-abc123"); + assert!(env.error.is_none()); + } + + #[test] + fn provision_envelope_parse_error() { + let json = + r#"{"error":{"code":"backend_unavailable","message":"IsoSessionApp.dll missing"}}"#; + let env: ProvisionEnvelope = serde_json::from_str(json).unwrap(); + assert!(env.result.is_none()); + let err = env.error.unwrap(); + assert_eq!(err.code, "backend_unavailable"); + } + + #[test] + fn mxc_envelope_success_variant() { + let json = r#"{"result":{}}"#; + let env: MxcEnvelope = serde_json::from_str(json).unwrap(); + assert!(matches!(env, MxcEnvelope::Ok { .. })); + } + + #[test] + fn mxc_envelope_error_variant() { + let json = r#"{"error":{"code":"not_provisioned","message":"call provision first"}}"#; + let env: MxcEnvelope = serde_json::from_str(json).unwrap(); + assert!(matches!(env, MxcEnvelope::Err { .. })); + } + + #[test] + fn provision_config_json_shape() { + // Verify the JSON we send wxc-exec has the expected shape. + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "provision", + "containment": "isolation_session", + "filesystem": { + "readwritePaths": ["C:\\work\\demo"], + "readonlyPaths": [], + }, + "experimental": { + "isolation_session": { + "configurationId": DEFAULT_CONFIGURATION_ID, + "provision": {} + } + } + }); + assert_eq!(config["phase"], "provision"); + assert_eq!(config["containment"], "isolation_session"); + assert_eq!( + config["experimental"]["isolation_session"]["configurationId"], + "composable" + ); + assert_eq!(config["filesystem"]["readwritePaths"][0], "C:\\work\\demo"); + } + + #[test] + fn oneshot_processcontainer_config_json_shape() { + // Mirror the JSON `run_oneshot` builds for the one-shot processContainer + // path: no `phase` (routes to one-shot), `containment: processcontainer`, + // a `process` block, the `processContainer` knobs, and filesystem grants + // incl. deniedPaths. + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "containerId": "sb-1", + "containment": "processcontainer", + "process": { + "commandLine": "C:\\work\\demo\\agent.exe", + "cwd": "C:\\work\\demo", + "env": Vec::::new(), + "timeout": 0, + }, + "processContainer": { "leastPrivilege": true }, + "filesystem": { + "readwritePaths": ["C:\\work\\demo"], + "deniedPaths": ["C:\\secret"], + }, + }); + assert_eq!(config["containment"], "processcontainer"); + assert!( + config.get("phase").is_none(), + "one-shot config must omit phase" + ); + assert_eq!(config["processContainer"]["leastPrivilege"], true); + assert_eq!(config["filesystem"]["readwritePaths"][0], "C:\\work\\demo"); + assert_eq!(config["filesystem"]["deniedPaths"][0], "C:\\secret"); + } + + #[test] + fn provision_config_json_includes_network_proxy_when_supplied() { + let filesystem = MxcFilesystem { + readwrite_paths: vec!["C:\\work\\demo".into()], + readonly_paths: Vec::new(), + denied_paths: Vec::new(), + }; + let network = MxcNetwork { + default_policy: "block".into(), + proxy: Some("127.0.0.1:18080".parse().unwrap()), + }; + let config = provision_config_json(DEFAULT_CONFIGURATION_ID, &filesystem, Some(&network)); + + assert_eq!(config["network"]["defaultPolicy"], "block"); + assert!( + config["network"]["allowedHosts"] + .as_array() + .unwrap() + .is_empty() + ); + assert!( + config["network"]["blockedHosts"] + .as_array() + .unwrap() + .is_empty() + ); + // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. + assert_eq!(config["network"]["proxy"]["localhost"], 18080); + assert!( + config["network"]["proxy"].get("host").is_none(), + "proxy must not contain 'host' key" + ); + assert!( + config["network"]["proxy"].get("port").is_none(), + "proxy must not contain 'port' key" + ); + } + + #[test] + fn network_json_emits_localhost_port_shape() { + // MXC 0.6.0-alpha rejects {"host":...,"port":...} and accepts only + // {"proxy": {"localhost": N}} — verified against the real binary via + // --dry-run. This test pins the exact emitted JSON shape. + let network = MxcNetwork { + default_policy: "block".into(), + proxy: Some("127.0.0.1:18080".parse().unwrap()), + }; + let value = network_json(&network); + assert_eq!(value["proxy"]["localhost"], 18080); + assert!(value["proxy"].get("host").is_none()); + assert!(value["proxy"].get("port").is_none()); + } + + #[test] + fn oneshot_config_json_omits_network_without_proxy() { + let filesystem = MxcFilesystem { + readwrite_paths: vec!["C:\\work\\demo".into()], + readonly_paths: Vec::new(), + denied_paths: Vec::new(), + }; + let pc = MxcProcessContainer::default(); + let process = MxcProcess { + command_line: "cmd /c exit 0".into(), + cwd: "C:\\work\\demo".into(), + env: Vec::new(), + timeout: 0, + }; + let config = oneshot_config_json("sb-1", &filesystem, &pc, &process, None); + + assert!(config.get("network").is_none()); + } + + #[test] + fn stop_and_deprovision_serialize_as_unit_variants() { + // Pins the empirical schema contract (test box, build 26300.8553): + // stop/deprovision are unit variants and must be `null`; `{}` is + // rejected with malformed_request "invalid type: map, expected unit". + for phase in ["stop", "deprovision"] { + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": phase, + "sandboxId": "iso:wxc-test", + "experimental": { + "isolation_session": { + phase: null + } + } + }); + assert!( + config["experimental"]["isolation_session"][phase].is_null(), + "{phase} must serialize as null (unit variant)" + ); + } + } + + #[test] + fn invoker_error_maps_backend_unavailable_to_unavailable() { + let err = InvokerError::Mxc { + code: "backend_unavailable".into(), + message: "missing DLL".into(), + }; + let status = err.to_tonic_status(); + assert_eq!(status.code(), tonic::Code::Unavailable); + } + + #[test] + fn invoker_error_maps_policy_validation_to_invalid_argument() { + let err = InvokerError::Mxc { + code: "policy_validation".into(), + message: "path denied".into(), + }; + let status = err.to_tonic_status(); + assert_eq!(status.code(), tonic::Code::InvalidArgument); + } + + #[test] + fn invoker_error_maps_stale_id_to_not_found() { + let err = InvokerError::Mxc { + code: "stale_id".into(), + message: "session expired".into(), + }; + let status = err.to_tonic_status(); + assert_eq!(status.code(), tonic::Code::NotFound); + } +} diff --git a/crates/openshell-driver-mxc/src/policy.rs b/crates/openshell-driver-mxc/src/policy.rs new file mode 100644 index 0000000000..1f26d186e5 --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy.rs @@ -0,0 +1,294 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! `PolicyMapper` seam: `SandboxPolicy` → MXC `ContainerConfig` fragment. +//! +//! This file does **not** write the actual policy mapping rules — that logic is +//! **embedded** as the [`crate::policy_map`] module (the source of truth; it was +//! the standalone `openshell-policy-mapper` crate). This file defines the trait +//! seam plus: +//! +//! - [`EmbeddedPolicyMapper`] — the **primary** impl. Calls +//! [`crate::policy_map::map_to_mxc`] directly on the typed `SandboxPolicy` +//! proto (no YAML bridge), extracts the MXC filesystem shares, normalizes +//! their paths to Windows form, and rejects the create on any `error`-severity +//! loss. +//! +//! **Rule: never silently drop policy.** Unmappable rules surface as +//! `MapError::Unsupported` and are rejected by `CreateSandbox` before lifecycle side effects. + +use std::net::SocketAddr; + +use openshell_core::proto::SandboxPolicy; +use thiserror::Error; + +/// The MXC config fragment derived from a `SandboxPolicy`. +/// +/// Carries filesystem share lists for the MXC provision phase, plus the +/// Pattern-C governed-egress handoff when enabled. +#[derive(Debug, Default, Clone)] +pub struct MappedConfig { + /// Paths granted read-write access inside the sandbox. + pub readwrite_paths: Vec, + /// Paths granted read-only access inside the sandbox. + pub readonly_paths: Vec, + /// Network-only policy for the host CONNECT proxy. `None` on the coarse + /// filesystem-only path. + pub trimmed_policy: Option, + /// Loopback address MXC redirects sandbox egress to. `None` when governed + /// egress is disabled. + pub proxy_addr: Option, +} + +/// Context passed to the mapper alongside the policy. +#[derive(Debug)] +pub struct MapCtx { + /// Sandbox ID (gateway-assigned). Used as the MXC `containerId` and to + /// correlate diagnostics. + pub sandbox_id: String, + /// Pattern-C governed-egress redirect address. When set, the embedded + /// mapper uses `split_policy`; otherwise it uses the coarse MXC map. + pub egress: Option, +} + +/// A policy rule that the active mapper cannot enforce. +#[derive(Debug, Clone)] +pub struct LossItem { + pub rule_kind: String, + pub detail: String, +} + +/// Error returned when policy translation fails or is incomplete. +#[derive(Debug, Error)] +pub enum MapError { + #[error("policy rule(s) cannot be enforced by the MXC driver: {}", format_loss(.0))] + Unsupported(Vec), + #[error("policy mapper internal error: {0}")] + Internal(String), +} + +fn format_loss(items: &[LossItem]) -> String { + items + .iter() + .map(|i| format!("{}: {}", i.rule_kind, i.detail)) + .collect::>() + .join("; ") +} + +/// Translates an `OpenShell` `SandboxPolicy` into an MXC `ContainerConfig` +/// fragment, returning a loss report of anything unrepresentable. +pub trait PolicyMapper: Send + Sync { + /// `policy` is `None` only when the gateway failed to stage one (the MXC + /// path treats that as a hard error — the demo's whole point is enforcement). + fn map(&self, policy: Option<&SandboxPolicy>, ctx: &MapCtx) -> Result; +} + +// ── Path normalization ────────────────────────────────────────────────────── + +/// Normalize forward-slash paths to Windows backslash form. Path normalization +/// lives here, in one place — the embedded mapper copies path strings through +/// unchanged. +fn normalize_path(p: &str) -> String { + p.replace('/', "\\") +} + +// ── Embedded mapper (primary impl) ────────────────────────────────────────── + +/// Primary `PolicyMapper`: calls the embedded `policy_map` module (the source of +/// truth) directly on the typed `SandboxPolicy` proto. +pub struct EmbeddedPolicyMapper; + +fn extract_paths(config: &serde_json::Value, key: &str) -> Vec { + config["filesystem"][key] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() +} + +impl PolicyMapper for EmbeddedPolicyMapper { + fn map(&self, policy: Option<&SandboxPolicy>, ctx: &MapCtx) -> Result { + let policy = policy.ok_or_else(|| { + MapError::Internal( + "MXC driver requires a sandbox policy, but none was staged for this sandbox" + .to_owned(), + ) + })?; + + let (config, loss, trimmed_policy, proxy_addr) = if let Some(addr) = ctx.egress { + // Pattern C: MXC handles filesystem + a proxy redirect, while the + // host CONNECT proxy receives the network-only trimmed policy. + let opts = crate::policy_map::MxcMappingOptions { + containment: "processcontainer".to_owned(), + container_id: ctx.sandbox_id.clone(), + proxy_redirect: Some(addr), + ..Default::default() + }; + let result = crate::policy_map::split_policy(policy, &opts).ok_or_else(|| { + MapError::Internal( + "egress proxy was enabled but no proxy address was supplied".into(), + ) + })?; + ( + result.mxc_config, + result.loss, + Some(result.proxy_policy), + Some(addr), + ) + } else { + // Map directly off the typed proto. The default MXC driver path runs + // an isolation session, so use that containment: its network branch + // yields an `error` loss for any host allowlist, which rejects + // network policy below. + let opts = crate::policy_map::MxcMappingOptions { + containment: "isolation_session".to_owned(), + container_id: ctx.sandbox_id.clone(), + ..Default::default() + }; + let result = crate::policy_map::map_to_mxc(policy, &opts); + (result.config, result.loss, None, None) + }; + + // Reject the create on any error-severity loss. Warnings/info (e.g. the + // filesystem default-deny note) are advisory and do not block. + let errors: Vec = loss + .iter() + .filter(|i| i.severity == "error") + .map(|i| LossItem { + rule_kind: i.path.clone(), + detail: i.message.clone(), + }) + .collect(); + if !errors.is_empty() { + return Err(MapError::Unsupported(errors)); + } + + // The embedded mapper copies paths verbatim; normalize them to Windows + // backslash form here, in one place. + let readwrite: Vec = extract_paths(&config, "readwritePaths") + .iter() + .map(|p| normalize_path(p)) + .collect(); + let readonly: Vec = extract_paths(&config, "readonlyPaths") + .iter() + .map(|p| normalize_path(p)) + .collect(); + + Ok(MappedConfig { + readwrite_paths: readwrite, + readonly_paths: readonly, + trimmed_policy, + proxy_addr, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::proto::FilesystemPolicy; + + fn demo_ctx() -> MapCtx { + MapCtx { + sandbox_id: "sb-test".into(), + egress: None, + } + } + + fn fs_policy(rw: &[&str], ro: &[&str]) -> SandboxPolicy { + SandboxPolicy { + filesystem: Some(FilesystemPolicy { + include_workdir: false, + read_only: ro.iter().map(ToString::to_string).collect(), + read_write: rw.iter().map(ToString::to_string).collect(), + }), + ..Default::default() + } + } + + #[test] + fn embedded_maps_policy_read_write_to_share() { + let mapper = EmbeddedPolicyMapper; + let policy = fs_policy(&["C:/work/openshell-mxc-demo"], &["C:/tools"]); + let ctx = demo_ctx(); + let config = mapper.map(Some(&policy), &ctx).unwrap(); + // Forward slashes normalized to Windows backslashes by the bridge. + assert!( + config + .readwrite_paths + .contains(&"C:\\work\\openshell-mxc-demo".to_string()) + ); + assert_eq!(config.readonly_paths, vec!["C:\\tools"]); + } + + #[test] + fn embedded_rejects_missing_policy() { + let mapper = EmbeddedPolicyMapper; + let ctx = demo_ctx(); + let err = mapper.map(None, &ctx).unwrap_err(); + assert!(matches!(err, MapError::Internal(_))); + } + + #[test] + fn embedded_rejects_network_policy_on_isolation_session() { + use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; + let mapper = EmbeddedPolicyMapper; + let mut policy = fs_policy(&["C:/work/demo"], &[]); + policy.network_policies.insert( + "api".to_string(), + NetworkPolicyRule { + name: "api".into(), + endpoints: vec![NetworkEndpoint { + host: "example.com".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + let ctx = demo_ctx(); + let err = mapper.map(Some(&policy), &ctx).unwrap_err(); + assert!(matches!(err, MapError::Unsupported(_))); + } + + #[test] + fn embedded_split_normalizes_paths_and_returns_proxy_handoff() { + use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; + let mapper = EmbeddedPolicyMapper; + let mut policy = fs_policy(&["C:/work/demo"], &["C:/tools"]); + policy.version = 1; + policy.network_policies.insert( + "api".to_string(), + NetworkPolicyRule { + name: "api".into(), + endpoints: vec![NetworkEndpoint { + host: "example.com".into(), + ports: vec![443], + protocol: "rest".into(), + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".into(), + ..Default::default() + }], + }, + ); + let proxy_addr = "127.0.0.1:18080".parse().unwrap(); + let ctx = MapCtx { + sandbox_id: "sb-egress".into(), + + egress: Some(proxy_addr), + }; + + let config = mapper.map(Some(&policy), &ctx).unwrap(); + assert_eq!(config.readwrite_paths, vec!["C:\\work\\demo"]); + assert_eq!(config.readonly_paths, vec!["C:\\tools"]); + assert_eq!(config.proxy_addr, Some(proxy_addr)); + let trimmed = config.trimmed_policy.expect("trimmed policy"); + assert_eq!(trimmed.version, policy.version); + assert_eq!(trimmed.network_policies, policy.network_policies); + assert!(trimmed.filesystem.is_none()); + } +} diff --git a/crates/openshell-driver-mxc/src/policy_map/config.rs b/crates/openshell-driver-mxc/src/policy_map/config.rs new file mode 100644 index 0000000000..f64b64a54a --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy_map/config.rs @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! MXC `ContainerConfig` defaults and backend-specific behavior. + +use openshell_core::proto::SandboxPolicy; +use serde_json::{Value, json}; + +use super::loss::{LossItem, add_loss}; + +/// Placeholder command written into `process.commandLine` when the caller does +/// not supply a real workload command. +pub const DEFAULT_COMMAND: &str = "sh -lc \"echo OpenShell policy mapped to MXC; replace process.commandLine before running a real workload\""; + +/// Default MXC schema version emitted in `version`. +pub const DEFAULT_MXC_VERSION: &str = "0.7.0-alpha"; + +/// Default MXC containment backend for the coarse mapping. +pub const DEFAULT_CONTAINMENT: &str = "bubblewrap"; + +/// Select a default `network.enforcementMode` for the backend, or `None` to +/// omit the field (backends that derive enforcement from host lists / proxy). +pub fn default_enforcement_mode( + containment: &str, + allowed_hosts: &[String], +) -> Option<&'static str> { + if allowed_hosts.is_empty() { + return None; + } + match containment { + "processcontainer" | "process" => Some("both"), + "wslc" | "seatbelt" | "microvm" | "vm" | "windows_sandbox" => None, + // lxc, bubblewrap, hyperlight, and anything else default to firewall. + _ => Some("firewall"), + } +} + +/// Backend-specific advisory about how filesystem default-deny differs from +/// `OpenShell` Landlock. +pub fn filesystem_default_deny_message(containment: &str) -> String { + match containment { + "bubblewrap" => "Bubblewrap policy is not strict OpenShell filesystem parity: MXC \ + may bind host root read-only and overlay policy mounts." + .to_owned(), + "lxc" => "LXC exposes the container rootfs and bind-mounts selected host \ + paths; this is not identical to OpenShell Landlock." + .to_owned(), + "wslc" => "WSLC mounts selected Windows paths, but default-deny behavior is \ + runner/backend specific." + .to_owned(), + "seatbelt" => "Seatbelt starts from a deny-default profile with baseline system \ + allowances, not OpenShell Landlock." + .to_owned(), + _ => "MXC filesystem behavior is backend-specific and not equivalent to \ + OpenShell Landlock by construction." + .to_owned(), + } +} + +/// Add backend-specific config blocks (and reject unsupported backends). +pub fn add_backend_specific_config( + config: &mut Value, + containment: &str, + allowed_hosts: &[String], + items: &mut Vec, +) { + match containment { + "processcontainer" | "process" if !allowed_hosts.is_empty() => { + config["processContainer"] = json!({ "capabilities": ["internetClient"] }); + } + "lxc" => { + config["lxc"] = json!({ "distribution": "alpine", "release": "3.20" }); + } + backend @ ("windows_sandbox" | "isolation_session" | "vm") if !allowed_hosts.is_empty() => { + add_loss( + items, + "containment", + "error", + &format!("{backend} is not a v0 target for OpenShell network policy mapping."), + "OpenShell network policy", + "MXC network behavior is unsupported or unknown for this backend.", + ); + } + "microvm" if !allowed_hosts.is_empty() => { + add_loss( + items, + "containment", + "error", + "microvm network policy enforcement is not defined for this mapper.", + "OpenShell network policy", + "MXC network behavior is unsupported or unknown for microvm.", + ); + } + _ => {} + } +} + +/// Add a backend-specific advisory about host-allowlist fidelity. Only fires +/// when the source policy declares network rules. +pub fn add_backend_network_loss( + policy: &SandboxPolicy, + containment: &str, + items: &mut Vec, +) { + if policy.network_policies.is_empty() { + return; + } + match containment { + "seatbelt" => add_loss( + items, + "network_policies", + "error", + "MXC Seatbelt cannot faithfully enforce arbitrary allowedHosts.", + "host allowlist", + "Seatbelt allowlists can broaden to allow-all outbound.", + ), + "processcontainer" | "process" => add_loss( + items, + "network_policies", + "warning", + "Windows ProcessContainer host allowlists are possible but fragile.", + "host allowlist", + "Review firewall/capability behavior before treating this as parity.", + ), + "wslc" => add_loss( + items, + "network_policies", + "warning", + "WSLC host filtering relies on bridged networking plus in-container iptables.", + "host allowlist", + "Backend privileges and runner behavior determine parity.", + ), + "vm" | "windows_sandbox" => add_loss( + items, + "network_policies", + "error", + "MXC Windows Sandbox / vm cannot faithfully enforce arbitrary allowedHosts.", + "host allowlist", + "Network policy enforcement is unsupported or unknown for this backend.", + ), + _ => {} + } +} diff --git a/crates/openshell-driver-mxc/src/policy_map/loss.rs b/crates/openshell-driver-mxc/src/policy_map/loss.rs new file mode 100644 index 0000000000..9e83c20a78 --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy_map/loss.rs @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Loss-report model shared by the coarse map and the lossless split. + +use std::collections::HashSet; + +use serde::Serialize; + +/// A single mapping observation: something that could not be represented in +/// MXC, was delegated elsewhere, or is informational. +/// +/// `severity` is one of `"error"`, `"warning"`, or `"info"`. `"error"` marks a +/// semantic broadening or an unsupported parity gap; `"warning"` marks lost +/// information that does not obviously broaden access; `"info"` is advisory. +#[derive(Clone, Debug, Serialize)] +pub struct LossItem { + pub path: String, + pub severity: String, + pub message: String, + pub openshell_feature: String, + pub mxc_impact: String, +} + +/// MXC capabilities that have no `OpenShell` *policy* equivalent. Surfaced in the +/// loss report so reviewers understand the mapping is not symmetric. +pub const OPEN_SHELL_SUPERSET_GAPS: &[&str] = &[ + "MXC UI policy has no OpenShell policy equivalent: ui.disable, ui.clipboard, and ui.injection.", + "MXC lifecycle fields have no OpenShell policy equivalent: destroyOnExit, preservePolicy, phase, and sandboxId.", + "MXC backend selection and backend-specific blocks are outside OpenShell policy YAML.", + "MXC process command, cwd, env, and timeout are runtime config fields, not OpenShell policy fields.", + "MXC explicit deniedPaths are not expressible in current OpenShell policy YAML, which relies on default-deny filesystem behavior instead.", + "MXC fallback.allowDaclMutation (host DACL mutation consent) has no OpenShell policy equivalent.", + "MXC network.allowLocalNetwork (inbound bind/listen permission) has no OpenShell policy equivalent.", + "MXC network.proxy configuration has no OpenShell policy equivalent.", + "MXC experimental backend blocks (windows_sandbox, wslc, seatbelt, isolation_session) are outside OpenShell policy YAML.", +]; + +pub fn add_loss( + items: &mut Vec, + path: &str, + severity: &str, + message: &str, + openshell_feature: &str, + mxc_impact: &str, +) { + items.push(LossItem { + path: path.to_owned(), + severity: severity.to_owned(), + message: message.to_owned(), + openshell_feature: openshell_feature.to_owned(), + mxc_impact: mxc_impact.to_owned(), + }); +} + +/// Distinct `OpenShell` features that were lost or degraded, in first-seen order. +pub fn summarize_missing_mxc(items: &[LossItem]) -> Vec { + let mut seen: HashSet<&str> = HashSet::new(); + let mut summary = Vec::new(); + for item in items { + if (item.severity == "error" || item.severity == "warning") + && seen.insert(item.openshell_feature.as_str()) + { + summary.push(item.openshell_feature.clone()); + } + } + summary +} diff --git a/crates/openshell-driver-mxc/src/policy_map/map.rs b/crates/openshell-driver-mxc/src/policy_map/map.rs new file mode 100644 index 0000000000..7d9025e5a6 --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy_map/map.rs @@ -0,0 +1,704 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Coarse OpenShell-policy → MXC `ContainerConfig` mapping. +//! +//! Operates on the typed [`SandboxPolicy`] (parse it with +//! `openshell_policy::parse_sandbox_policy`). Network policy is flattened into +//! an MXC host allowlist; everything MXC cannot express is recorded as a loss +//! item. The top-level `network_policies` map is iterated in sorted key order +//! so the output is deterministic (the proto map is unordered). + +use std::net::SocketAddr; + +use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule, SandboxPolicy}; +use serde_json::{Value, json}; + +use super::config::{ + DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, add_backend_network_loss, + add_backend_specific_config, default_enforcement_mode, filesystem_default_deny_message, +}; +use super::loss::{LossItem, add_loss}; + +/// Options controlling the generated MXC config. Fields not relevant to the +/// coarse map (e.g. `proxy_redirect`) are reserved for the lossless split. +#[derive(Clone, Debug)] +pub struct MxcMappingOptions { + /// MXC schema version written into `version`. + pub mxc_version: String, + /// MXC containment backend. + pub containment: String, + /// `process.commandLine` value. + pub command: String, + /// Resolved `containerId`. + pub container_id: String, + /// Working directory; resolves `filesystem_policy.include_workdir`. + pub cwd: Option, + /// `KEY=VALUE` entries added to `process.env`. + pub env: Vec, + /// `process.timeout`. + pub timeout_ms: u64, + /// Emit `OpenShell` wildcard hosts into `allowedHosts` despite lossiness. + pub allow_wildcards: bool, + /// Governed-egress redirect address (used by the lossless split, not the + /// coarse map). + pub proxy_redirect: Option, +} + +impl Default for MxcMappingOptions { + fn default() -> Self { + Self { + mxc_version: DEFAULT_MXC_VERSION.to_owned(), + containment: DEFAULT_CONTAINMENT.to_owned(), + command: DEFAULT_COMMAND.to_owned(), + container_id: "openshell-policy".to_owned(), + cwd: None, + env: Vec::new(), + timeout_ms: 0, + allow_wildcards: false, + proxy_redirect: None, + } + } +} + +/// Result of a coarse mapping: the MXC config plus the loss items. +#[derive(Clone, Debug)] +pub struct MxcMappingResult { + pub config: Value, + pub loss: Vec, +} + +/// Result of the lossless split: the MXC config carries filesystem grants and a +/// proxy redirect; the full network policy is returned unchanged for the +/// `OpenShell` CONNECT proxy to enforce. +#[derive(Clone, Debug)] +pub struct SplitPolicyResult { + /// MXC `ContainerConfig` with filesystem grants and `network.proxy` redirect. + /// + /// `network.allowedHosts` is empty — direct egress is blocked at the MXC + /// layer. All outbound connections flow through the proxy; the proxy enforces + /// the full `OpenShell` network policy. + pub mxc_config: Value, + /// Full `OpenShell` network policy preserved verbatim for the host CONNECT + /// proxy. Only `network_policies` is populated; the proxy does not enforce + /// filesystem rules. + pub proxy_policy: SandboxPolicy, + /// Loss items from the filesystem side only. Network rules produce no losses + /// here — they are delegated to the proxy rather than approximated. + pub loss: Vec, +} + +/// Map an `OpenShell` policy to a coarse MXC `ContainerConfig`. +pub fn map_to_mxc(policy: &SandboxPolicy, opts: &MxcMappingOptions) -> MxcMappingResult { + let mut loss = Vec::new(); + let config = build_mxc_config(policy, opts, &mut loss); + MxcMappingResult { config, loss } +} + +/// Lossless split: map filesystem + containment to MXC, delegate network to the +/// `OpenShell` CONNECT proxy. +/// +/// The returned [`SplitPolicyResult::mxc_config`] sets `network.proxy` to +/// `opts.proxy_redirect` and leaves `allowedHosts` empty — direct +/// egress is blocked at the MXC layer and all outbound connections flow through +/// the proxy. [`SplitPolicyResult::proxy_policy`] carries the original +/// `network_policies` verbatim; no binary-scope, port, protocol, or wildcard +/// loss items are generated for the network side. +/// +/// Returns `None` if `opts.proxy_redirect` is not set. Use [`map_to_mxc`] +/// for the standalone coarse path when no proxy is in the loop. +pub fn split_policy(policy: &SandboxPolicy, opts: &MxcMappingOptions) -> Option { + let proxy_addr = opts.proxy_redirect?; + let mut loss = Vec::new(); + let mxc_config = build_split_mxc_config(policy, opts, proxy_addr, &mut loss); + let proxy_policy = SandboxPolicy { + version: policy.version, + network_policies: policy.network_policies.clone(), + network_middlewares: policy.network_middlewares.clone(), + ..Default::default() + }; + Some(SplitPolicyResult { + mxc_config, + proxy_policy, + loss, + }) +} + +fn build_split_mxc_config( + policy: &SandboxPolicy, + opts: &MxcMappingOptions, + proxy_addr: SocketAddr, + items: &mut Vec, +) -> Value { + let mut process = json!({ + "commandLine": opts.command, + "timeout": opts.timeout_ms, + }); + if let Some(cwd) = &opts.cwd { + process["cwd"] = json!(cwd); + } + if !opts.env.is_empty() { + process["env"] = json!(opts.env); + } + + let filesystem = map_filesystem(policy, opts, items); + + let proxy_supported = matches!(opts.containment.as_str(), "processcontainer" | "process"); + if !proxy_supported { + add_loss( + items, + "containment", + "error", + &format!( + "`network.proxy` is not supported on `{}`; governed egress requires processcontainer until MXC M1 lands.", + opts.containment + ), + "governed egress proxy redirect", + "The generated MXC config omits network.proxy for this backend.", + ); + } + if !policy.network_policies.is_empty() { + add_loss( + items, + "network_policies", + "info", + &format!( + "{} network rule(s) delegated to the OpenShell host CONNECT proxy.", + policy.network_policies.len() + ), + "governed egress", + "The host proxy receives the trimmed policy and enforces network rules.", + ); + } + + // Direct egress is blocked; all outbound flows through the OpenShell proxy. + // allowedHosts is intentionally empty — the proxy enforces the full policy. + // + // MXC 0.6.0-alpha schema accepts ONLY {"proxy": {"localhost": }}. + // {"host": ..., "port": ...} and every other shape is rejected — verified + // empirically against the real wxc-exec 0.6.0-alpha binary via --dry-run. + if proxy_supported && proxy_addr.ip() != std::net::IpAddr::from([127, 0, 0, 1]) { + add_loss( + items, + "network.proxy", + "error", + &format!( + "MXC schema 0.6.0-alpha can only express a localhost port \ + ({{\"localhost\": N}}); non-127.0.0.1 redirect address \ + {proxy_addr} is not representable." + ), + "per-sandbox egress attribution", + "The redirect cannot be emitted; use a 127.0.0.1:PORT address.", + ); + } + let mut network = json!({ + "defaultPolicy": "block", + "allowedHosts": [], + "blockedHosts": [], + }); + if proxy_supported && proxy_addr.ip() == std::net::IpAddr::from([127, 0, 0, 1]) { + network["proxy"] = json!({ "localhost": proxy_addr.port() }); + } + + let mut config = json!({ + "version": opts.mxc_version, + "containerId": opts.container_id, + "containment": opts.containment, + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false, + }, + "process": process, + "filesystem": filesystem, + "network": network, + "ui": { + "disable": true, + "clipboard": "none", + "injection": false, + }, + }); + + // No network hosts, so backend-specific network blocks (processContainer + // internetClient, etc.) are not added — correct for the proxy path. + add_backend_specific_config(&mut config, &opts.containment, &[], items); + add_static_policy_loss(policy, opts, items); + config +} + +fn build_mxc_config( + policy: &SandboxPolicy, + opts: &MxcMappingOptions, + items: &mut Vec, +) -> Value { + let mut process = json!({ + "commandLine": opts.command, + "timeout": opts.timeout_ms, + }); + if let Some(cwd) = &opts.cwd { + process["cwd"] = json!(cwd); + } + if !opts.env.is_empty() { + process["env"] = json!(opts.env); + } + + let filesystem = map_filesystem(policy, opts, items); + let allowed_hosts = map_network(policy, opts, items); + + let mut network = json!({ + "defaultPolicy": "block", + "allowedHosts": allowed_hosts, + "blockedHosts": [], + }); + if let Some(mode) = default_enforcement_mode(&opts.containment, &allowed_hosts) { + network["enforcementMode"] = json!(mode); + } + + let mut config = json!({ + "version": opts.mxc_version, + "containerId": opts.container_id, + "containment": opts.containment, + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false, + }, + "process": process, + "filesystem": filesystem, + "network": network, + "ui": { + "disable": true, + "clipboard": "none", + "injection": false, + }, + }); + + add_backend_specific_config(&mut config, &opts.containment, &allowed_hosts, items); + add_static_policy_loss(policy, opts, items); + config +} + +fn map_filesystem( + policy: &SandboxPolicy, + opts: &MxcMappingOptions, + items: &mut Vec, +) -> Value { + let mut readwrite: Vec = Vec::new(); + let mut readonly: Vec = Vec::new(); + + match &policy.filesystem { + Some(fs) => { + readwrite.clone_from(&fs.read_write); + readonly.clone_from(&fs.read_only); + if fs.include_workdir { + if let Some(cwd) = &opts.cwd { + append_unique(&mut readwrite, cwd.clone()); + } else { + add_loss( + items, + "filesystem_policy.include_workdir", + "info", + "OpenShell includes the runtime workdir, but no --cwd was supplied.", + "include_workdir", + "The generated MXC config cannot add the workdir path grant.", + ); + } + } + } + None => add_loss( + items, + "filesystem_policy", + "warning", + "No OpenShell filesystem_policy was present.", + "default filesystem policy", + "MXC receives empty filesystem lists; backend defaults determine visibility.", + ), + } + + add_loss( + items, + "filesystem_policy", + "warning", + &filesystem_default_deny_message(&opts.containment), + "OpenShell Landlock/default-deny filesystem model", + "MXC filesystem default-deny parity is backend-specific.", + ); + + json!({ + "readwritePaths": readwrite, + "readonlyPaths": readonly, + "deniedPaths": [], + }) +} + +fn map_network( + policy: &SandboxPolicy, + opts: &MxcMappingOptions, + items: &mut Vec, +) -> Vec { + if !policy.network_middlewares.is_empty() { + add_loss( + items, + "network_middlewares", + "error", + &format!( + "{} network middleware config(s) require the OpenShell host proxy and cannot be enforced by MXC directly.", + policy.network_middlewares.len() + ), + "network egress middleware", + "Middleware transformations and failure behavior would not be applied on the coarse MXC path.", + ); + } + + if policy.network_policies.is_empty() { + add_backend_network_loss(policy, &opts.containment, items); + return Vec::new(); + } + + // The proto map is unordered; sort by rule key for deterministic output. + let mut rules: Vec<(&String, &NetworkPolicyRule)> = policy.network_policies.iter().collect(); + rules.sort_by(|a, b| a.0.cmp(b.0)); + + let mut allowed_hosts: Vec = Vec::new(); + + for (key, rule) in rules { + let rule_path = format!("network_policies.{key}"); + + if rule.endpoints.is_empty() { + add_loss( + items, + &format!("{rule_path}.endpoints"), + "error", + "OpenShell policy entry has no endpoints.", + "network endpoints", + "No MXC host allowlist entries were produced for this policy.", + ); + } + for (index, endpoint) in rule.endpoints.iter().enumerate() { + let endpoint_path = format!("{rule_path}.endpoints[{index}]"); + map_endpoint(endpoint, &endpoint_path, &mut allowed_hosts, opts, items); + } + + if rule.binaries.is_empty() { + add_loss( + items, + &format!("{rule_path}.binaries"), + "error", + "OpenShell requires binary-scoped network grants; this entry has no binaries.", + "binary-scoped network policy", + "MXC cannot represent per-binary grants and scopes network to the sandbox.", + ); + } else { + for (index, binary) in rule.binaries.iter().enumerate() { + add_loss( + items, + &format!("{rule_path}.binaries[{index}].path"), + "error", + &format!( + "Binary scope is not representable in MXC: '{}'.", + binary.path + ), + "binary-scoped network policy", + "Dropping this would broaden access from one executable to the whole sandbox.", + ); + } + } + } + + add_backend_network_loss(policy, &opts.containment, items); + allowed_hosts +} + +fn map_endpoint( + endpoint: &NetworkEndpoint, + path: &str, + allowed_hosts: &mut Vec, + opts: &MxcMappingOptions, + items: &mut Vec, +) { + // host + if endpoint.host.is_empty() { + add_loss( + items, + &format!("{path}.host"), + "error", + "Endpoint has no host.", + "network endpoint host", + "Endpoint was not added to MXC allowedHosts.", + ); + } else if contains_wildcard(&endpoint.host) { + let (message, impact) = if opts.allow_wildcards { + append_unique(allowed_hosts, endpoint.host.clone()); + ( + format!( + "Wildcard host emitted despite non-portable MXC semantics: {}.", + endpoint.host + ), + "Backend behavior is not portable and may fail or broaden access.", + ) + } else { + ( + format!( + "Wildcard host omitted because MXC has no portable syntax: {}.", + endpoint.host + ), + "Generated MXC config is more restrictive for this endpoint.", + ) + }; + add_loss( + items, + &format!("{path}.host"), + "error", + &message, + "OpenShell wildcard host matching", + impact, + ); + } else { + append_unique(allowed_hosts, endpoint.host.clone()); + } + + // port / ports (the proto normalizes a single port into `ports`) + if !endpoint.ports.is_empty() { + let (field, repr) = if endpoint.ports.len() == 1 { + ("port", endpoint.ports[0].to_string()) + } else { + ("ports", format!("{:?}", endpoint.ports)) + }; + add_loss( + items, + &format!("{path}.{field}"), + "error", + &format!("MXC allowedHosts cannot encode port constraint {repr}."), + "port-scoped outbound policy", + "MXC allows or blocks the host as a whole.", + ); + } + + // allowed_ips + for ip in &endpoint.allowed_ips { + append_unique(allowed_hosts, ip.clone()); + add_loss( + items, + &format!("{path}.allowed_ips"), + "warning", + &format!( + "MXC can carry CIDR/IP '{ip}', but cannot bind it to DNS for '{}'.", + endpoint.host + ), + "DNS result pinning / SSRF override", + "The CIDR/IP becomes a standalone allowed destination.", + ); + } + + report_endpoint_l7_losses(endpoint, path, items); +} + +fn report_endpoint_l7_losses(endpoint: &NetworkEndpoint, path: &str, items: &mut Vec) { + if !endpoint.protocol.is_empty() { + add_loss( + items, + &format!("{path}.protocol"), + "error", + &format!( + "MXC has no protocol-aware policy equivalent for '{}'.", + endpoint.protocol + ), + "protocol-aware proxy policy", + "MXC host filtering cannot enforce REST/WebSocket/GraphQL semantics.", + ); + } + + if !endpoint.tls.is_empty() { + let severity = if endpoint.tls == "skip" { + "warning" + } else { + "error" + }; + add_loss( + items, + &format!("{path}.tls"), + severity, + &format!( + "MXC has no OpenShell TLS inspection mode equivalent for '{}'.", + endpoint.tls + ), + "TLS inspection mode", + "MXC network policy is host-level only.", + ); + } + + if !endpoint.enforcement.is_empty() { + if endpoint.enforcement == "audit" { + add_loss( + items, + &format!("{path}.enforcement"), + "error", + "MXC has no audit-only network policy mode.", + "audit-mode endpoint", + "Generated MXC config enforces host-level default block instead.", + ); + } else { + add_loss( + items, + &format!("{path}.enforcement"), + "warning", + "MXC enforcementMode is backend-wide, not per endpoint.", + "per-endpoint enforcement", + "The mapper chooses a backend-level enforcement mode.", + ); + } + } + + if !endpoint.access.is_empty() { + add_loss( + items, + &format!("{path}.access"), + "error", + &format!( + "MXC has no access preset equivalent for '{}'.", + endpoint.access + ), + "REST/WebSocket/GraphQL access preset", + "MXC cannot enforce method or operation-level access.", + ); + } + + if !endpoint.rules.is_empty() { + add_loss( + items, + &format!("{path}.rules"), + "error", + "MXC has no L7 allow-rule equivalent.", + "REST/WebSocket/GraphQL allow rules", + "Method/path/query/operation restrictions are lost.", + ); + } + + if !endpoint.deny_rules.is_empty() { + add_loss( + items, + &format!("{path}.deny_rules"), + "error", + "MXC has no L7 deny-rule equivalent.", + "L7 deny rules", + "Deny precedence over broad allows is lost.", + ); + } + + let bool_losses: &[(bool, &str, &str)] = &[ + ( + endpoint.allow_encoded_slash, + "allow_encoded_slash", + "encoded slash handling", + ), + ( + endpoint.websocket_credential_rewrite, + "websocket_credential_rewrite", + "WebSocket credential rewrite", + ), + ( + endpoint.request_body_credential_rewrite, + "request_body_credential_rewrite", + "request-body credential rewrite", + ), + ]; + for (set, field, feature) in bool_losses { + if *set { + add_loss( + items, + &format!("{path}.{field}"), + "error", + &format!("MXC has no equivalent for {feature}."), + feature, + "Generated config cannot preserve this proxy behavior.", + ); + } + } + + // GraphQL + if !endpoint.persisted_queries.is_empty() { + add_graphql_loss(items, path, "persisted_queries"); + } + if !endpoint.graphql_persisted_queries.is_empty() { + add_graphql_loss(items, path, "graphql_persisted_queries"); + } + if endpoint.graphql_max_body_bytes > 0 { + add_graphql_loss(items, path, "graphql_max_body_bytes"); + } +} + +fn add_graphql_loss(items: &mut Vec, path: &str, field: &str) { + add_loss( + items, + &format!("{path}.{field}"), + "error", + &format!("MXC has no GraphQL policy equivalent for {field}."), + "GraphQL operation policy", + "GraphQL inspection and persisted-query behavior is lost.", + ); +} + +fn add_static_policy_loss( + policy: &SandboxPolicy, + opts: &MxcMappingOptions, + items: &mut Vec, +) { + if policy.landlock.is_some() { + add_loss( + items, + "landlock", + "warning", + "MXC has no Landlock compatibility mode field.", + "Landlock LSM enforcement", + "Backend filesystem controls may not fail like OpenShell best_effort/hard_requirement.", + ); + } + + if let Some(process) = &policy.process { + if !process.run_as_user.is_empty() { + add_process_identity_loss(items, "run_as_user"); + } + if !process.run_as_group.is_empty() { + add_process_identity_loss(items, "run_as_group"); + } + } + + if opts.containment == "processcontainer" + && let Some(fs) = &policy.filesystem + { + let any_linux_path = fs + .read_only + .iter() + .chain(fs.read_write.iter()) + .any(|p| p.starts_with('/')); + if any_linux_path { + add_loss( + items, + "filesystem_policy", + "warning", + "OpenShell example paths are Linux paths; Windows ProcessContainer expects Windows paths.", + "filesystem path syntax", + "Run with path translation or target a Linux-like MXC backend.", + ); + } + } +} + +fn add_process_identity_loss(items: &mut Vec, field: &str) { + add_loss( + items, + &format!("process.{field}"), + "warning", + &format!("MXC has no portable equivalent for OpenShell {field}."), + "process identity", + "MXC backend identity is selected outside this policy mapping.", + ); +} + +fn append_unique(list: &mut Vec, value: String) { + if !list.contains(&value) { + list.push(value); + } +} + +fn contains_wildcard(host: &str) -> bool { + host.contains('*') +} diff --git a/crates/openshell-driver-mxc/src/policy_map/mod.rs b/crates/openshell-driver-mxc/src/policy_map/mod.rs new file mode 100644 index 0000000000..52dc7f186c --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy_map/mod.rs @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Map an `OpenShell` sandbox policy to a Microsoft MXC `ContainerConfig`. +//! +//! This module is the **source of truth** for the OpenShell→MXC policy mapping +//! (it was the standalone `openshell-policy-mapper` crate). It is embedded in the +//! MXC driver as a module, consumed by the [`crate::policy`] seam. +//! +//! It reuses the canonical typed [`SandboxPolicy`] from `openshell-policy` +//! (obtained via `openshell_policy::parse_sandbox_policy`) rather than re-parsing +//! YAML, so the policy schema has a single source of truth. +//! +//! Two mapping shapes are intended: +//! +//! - [`map_to_mxc`] — the *coarse / standalone* mapping. `OpenShell` network +//! policy is flattened into an MXC host allowlist (`network.allowedHosts`), +//! and anything MXC cannot express (ports, protocol, L7 rules, binary scope) +//! is recorded in the loss report. Use this when MXC enforces network on its +//! own, with no `OpenShell` proxy in the loop. +//! - [`split_policy`] — the *lossless* split for the Windows MXC compute +//! driver: MXC handles filesystem + containment + a `network.proxy` redirect, +//! while the full `OpenShell` network policy is preserved in a trimmed policy +//! enforced by the host CONNECT proxy. +//! +//! The report/loss-report helpers are only exercised by the example and the +//! integration tests, so the Windows lib build would otherwise warn on them; +//! `#![allow(dead_code)]` keeps the module quiet without per-item churn. +//! +//! [`SandboxPolicy`]: openshell_core::proto::SandboxPolicy + +#![allow(dead_code)] + +mod config; +mod loss; +mod map; +mod report; + +pub use config::{DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION}; +pub use loss::{LossItem, OPEN_SHELL_SUPERSET_GAPS}; +pub use map::{MxcMappingOptions, MxcMappingResult, SplitPolicyResult, map_to_mxc, split_policy}; +pub use report::{build_loss_report, render_readme}; diff --git a/crates/openshell-driver-mxc/src/policy_map/report.rs b/crates/openshell-driver-mxc/src/policy_map/report.rs new file mode 100644 index 0000000000..0b1ec37b77 --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy_map/report.rs @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Loss-report JSON and human-readable README rendering. + +use serde_json::{Value, json}; + +use super::loss::{LossItem, OPEN_SHELL_SUPERSET_GAPS, summarize_missing_mxc}; + +/// Build the structured `loss-report.json` value. +pub fn build_loss_report( + source_policy: &str, + generated_config: &str, + items: &[LossItem], + schema_errors: &[LossItem], + mxc_version: &str, + containment: &str, + schema: Option<&str>, +) -> Value { + let count = |severity: &str| items.iter().filter(|i| i.severity == severity).count(); + + json!({ + "sourcePolicy": source_policy, + "generatedConfig": generated_config, + "target": { + "schemaVersion": mxc_version, + "containment": containment, + }, + "schemaValidation": { + "schema": schema, + "valid": schema_errors.is_empty(), + }, + "lossy": !items.is_empty(), + "counts": { + "error": count("error"), + "warning": count("warning"), + "info": count("info"), + }, + "items": items, + "openShellFieldsNotInMxc": summarize_missing_mxc(items), + "mxcFieldsNotInOpenShellPolicy": OPEN_SHELL_SUPERSET_GAPS, + }) +} + +/// Render the human-readable `README.md` summarizing the mapping. +pub fn render_readme(source_policy: &str, report: &Value, config: &Value) -> String { + let container_id = config["containerId"].as_str().unwrap_or(""); + let containment = config["containment"].as_str().unwrap_or(""); + let schema_valid = report["schemaValidation"]["valid"] + .as_bool() + .unwrap_or(false); + + let join_strs = |value: &Value| -> String { + let parts: Vec<&str> = value + .as_array() + .map(|a| a.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + if parts.is_empty() { + "(none)".to_owned() + } else { + parts.join(", ") + } + }; + + let allowed = join_strs(&config["network"]["allowedHosts"]); + let rw = join_strs(&config["filesystem"]["readwritePaths"]); + let ro = join_strs(&config["filesystem"]["readonlyPaths"]); + + let mut lines: Vec = vec![ + format!("# {container_id}"), + String::new(), + "## Generated Files".to_owned(), + String::new(), + format!( + "- `mxc-config.json`: direct MXC `ContainerConfig` generated from `{source_policy}`." + ), + "- `loss-report.json`: structured mapping loss report.".to_owned(), + String::new(), + "## MXC Consumption".to_owned(), + String::new(), + "The generated config is intended to be consumable by MXC's direct JSON path.".to_owned(), + "It uses a harmless placeholder `process.commandLine`; replace it with the".to_owned(), + "real workload command before running anything meaningful.".to_owned(), + String::new(), + format!("- Containment: `{containment}`"), + format!("- Schema validation: `{schema_valid}`"), + format!("- Allowed hosts: `{allowed}`"), + format!("- Read-write paths: `{rw}`"), + format!("- Read-only paths: `{ro}`"), + String::new(), + "## Missing In MXC For This OpenShell Policy".to_owned(), + String::new(), + ]; + + let notable: Vec<&Value> = report["items"] + .as_array() + .map(|a| { + a.iter() + .filter(|item| matches!(item["severity"].as_str(), Some("error" | "warning"))) + .collect() + }) + .unwrap_or_default(); + + if notable.is_empty() { + lines.push("- No lossy OpenShell-to-MXC policy mappings were detected.".to_owned()); + } else { + for item in notable { + lines.push(format!( + "- `{}` `{}`: {} Impact: {}", + item["severity"].as_str().unwrap_or(""), + item["path"].as_str().unwrap_or(""), + item["message"].as_str().unwrap_or(""), + item["mxc_impact"].as_str().unwrap_or(""), + )); + } + } + + lines.extend([ + String::new(), + "## Missing In OpenShell Policy For MXC".to_owned(), + String::new(), + ]); + if let Some(gaps) = report["mxcFieldsNotInOpenShellPolicy"].as_array() { + for gap in gaps.iter().filter_map(Value::as_str) { + lines.push(format!("- {gap}")); + } + } + + lines.extend([ + String::new(), + "## Notes".to_owned(), + String::new(), + "- OpenShell network policies are binary-, port-, protocol-, and often L7-scoped.".to_owned(), + "- This coarse mapper emits only MXC host/IP/CIDR allowlists plus filesystem lists.".to_owned(), + "- Treat any `error` item in the loss report as a semantic broadening or unsupported parity gap.".to_owned(), + String::new(), + ]); + + lines.join("\n") +} diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs new file mode 100644 index 0000000000..4d3a3cfa5c --- /dev/null +++ b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs @@ -0,0 +1,492 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Parity/invariant tests for the embedded coarse map over the repository's +//! example policies. +//! +//! Windows-only: the embedded mapper API is gated on `target_os = "windows"`, +//! so this whole file compiles to nothing elsewhere and runs in full on a +//! Windows test lane. +//! +//! Byte-for-byte parity with the previous raw-YAML mapper is intentionally +//! *not* asserted: routing through the canonical typed `SandboxPolicy` +//! normalizes ports and uses an unordered proto map (we sort keys). Instead we +//! assert the substantive invariants — filesystem fidelity, the host allowlist, +//! deny-by-default, and that broadening features are flagged as losses. + +#![cfg(target_os = "windows")] + +use std::path::{Path, PathBuf}; + +use openshell_driver_mxc::{MxcMappingOptions, map_to_mxc, split_policy}; +use openshell_policy::{parse_sandbox_policy, serialize_sandbox_policy, validate_sandbox_policy}; +use serde_json::Value; + +fn examples_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples") +} + +fn discover(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + discover(&path, out); + } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) + && name.contains("policy") + && path + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("yaml")) + { + out.push(path); + } + } +} + +fn str_list(value: &Value) -> Vec { + value + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default() +} + +fn proxy_addr() -> std::net::SocketAddr { + "127.0.0.1:18080".parse().unwrap() +} + +#[test] +fn all_example_policies_map_with_invariants() { + let root = examples_root(); + let mut policies = Vec::new(); + discover(&root, &mut policies); + policies.sort(); + assert!( + !policies.is_empty(), + "no example policies found under {}", + root.display() + ); + + for path in &policies { + let yaml = std::fs::read_to_string(path).expect("read policy"); + let policy = parse_sandbox_policy(&yaml) + .unwrap_or_else(|e| panic!("parse {} failed: {e}", path.display())); + + let result = map_to_mxc(&policy, &MxcMappingOptions::default()); + let cfg = &result.config; + + // Deny-by-default network posture is always emitted. + assert_eq!( + cfg["network"]["defaultPolicy"], + "block", + "{} must emit defaultPolicy=block", + path.display() + ); + + // Filesystem fidelity: read_write / read_only copied exactly. + if let Some(fs) = &policy.filesystem { + assert_eq!( + str_list(&cfg["filesystem"]["readwritePaths"]), + fs.read_write, + "readwrite mismatch for {}", + path.display() + ); + assert_eq!( + str_list(&cfg["filesystem"]["readonlyPaths"]), + fs.read_only, + "readonly mismatch for {}", + path.display() + ); + } + + // Every non-wildcard endpoint host appears in allowedHosts. + let allowed = str_list(&cfg["network"]["allowedHosts"]); + for rule in policy.network_policies.values() { + for ep in &rule.endpoints { + if !ep.host.is_empty() && !ep.host.contains('*') { + assert!( + allowed.contains(&ep.host), + "{} missing host {} in allowedHosts", + path.display(), + ep.host + ); + } + } + } + + // allowedHosts is deduplicated. + let mut sorted = allowed.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!( + sorted.len(), + allowed.len(), + "duplicate hosts for {}", + path.display() + ); + + // Deterministic: mapping twice yields identical config. + let again = map_to_mxc(&policy, &MxcMappingOptions::default()); + assert_eq!( + result.config, + again.config, + "non-deterministic for {}", + path.display() + ); + } +} + +#[test] +fn all_example_policies_split_with_lossless_invariants() { + let root = examples_root(); + let mut policies = Vec::new(); + discover(&root, &mut policies); + policies.sort(); + assert!( + !policies.is_empty(), + "no example policies found under {}", + root.display() + ); + + for path in &policies { + let yaml = std::fs::read_to_string(path).expect("read policy"); + let policy = parse_sandbox_policy(&yaml) + .unwrap_or_else(|e| panic!("parse {} failed: {e}", path.display())); + let opts = MxcMappingOptions { + containment: "processcontainer".to_owned(), + proxy_redirect: Some(proxy_addr()), + ..Default::default() + }; + let result = split_policy(&policy, &opts) + .unwrap_or_else(|| panic!("split returned None for {}", path.display())); + let cfg = &result.mxc_config; + + assert_eq!( + result.proxy_policy.network_policies, + policy.network_policies, + "proxy_policy must carry network rules verbatim for {}", + path.display() + ); + assert_eq!( + result.proxy_policy.version, + policy.version, + "proxy_policy must preserve version for {}", + path.display() + ); + validate_sandbox_policy(&result.proxy_policy).unwrap_or_else(|e| { + panic!("trimmed policy must validate for {}: {e:?}", path.display()) + }); + let serialized = serialize_sandbox_policy(&result.proxy_policy) + .unwrap_or_else(|e| panic!("serialize trimmed policy for {}: {e}", path.display())); + let round_trip = parse_sandbox_policy(&serialized) + .unwrap_or_else(|e| panic!("parse trimmed round-trip for {}: {e}", path.display())); + assert_eq!( + round_trip, + result.proxy_policy, + "trimmed policy must round-trip for {}", + path.display() + ); + + if let Some(fs) = &policy.filesystem { + assert_eq!( + str_list(&cfg["filesystem"]["readwritePaths"]), + fs.read_write, + "split readwrite mismatch for {}", + path.display() + ); + assert_eq!( + str_list(&cfg["filesystem"]["readonlyPaths"]), + fs.read_only, + "split readonly mismatch for {}", + path.display() + ); + } else { + assert!(str_list(&cfg["filesystem"]["readwritePaths"]).is_empty()); + assert!(str_list(&cfg["filesystem"]["readonlyPaths"]).is_empty()); + } + + assert_eq!(cfg["network"]["defaultPolicy"], "block"); + assert!(str_list(&cfg["network"]["allowedHosts"]).is_empty()); + // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. + assert_eq!(cfg["network"]["proxy"]["localhost"], 18080); + assert!(cfg["network"]["proxy"].get("host").is_none()); + assert!(cfg["network"]["proxy"].get("port").is_none()); + assert!( + result.loss.iter().all(|i| i.severity != "error"), + "processcontainer split must not emit error losses for {}: {:?}", + path.display(), + result.loss + ); + } +} + +#[test] +fn quickstart_coarse_mapping() { + let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); + let yaml = std::fs::read_to_string(&path).expect("read quickstart"); + let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); + let result = map_to_mxc(&policy, &MxcMappingOptions::default()); + let cfg = &result.config; + + assert_eq!( + str_list(&cfg["network"]["allowedHosts"]), + vec!["api.github.com".to_owned()] + ); + assert_eq!( + str_list(&cfg["filesystem"]["readwritePaths"]), + vec!["/sandbox", "/tmp", "/dev/null"] + ); + assert_eq!(cfg["containment"], "bubblewrap"); + + // The github_api endpoint loses port, protocol, access, and binary scope. + let has = |severity: &str, needle: &str| { + result + .loss + .iter() + .any(|i| i.severity == severity && i.path.contains(needle)) + }; + assert!(has("error", "endpoints[0].port"), "expected port loss"); + assert!( + has("error", "endpoints[0].protocol"), + "expected protocol loss" + ); + assert!( + has("error", "endpoints[0].access"), + "expected access preset loss" + ); + assert!( + has("error", "binaries[0].path"), + "expected binary-scope loss" + ); +} + +#[test] +fn split_policy_routes_network_to_proxy() { + let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); + let yaml = std::fs::read_to_string(&path).expect("read quickstart"); + let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); + + let opts = MxcMappingOptions { + containment: "processcontainer".to_owned(), + proxy_redirect: Some("127.0.0.2:8080".parse().unwrap()), + ..Default::default() + }; + let result = split_policy(&policy, &opts).expect("split_policy returns Some when addr is set"); + let cfg = &result.mxc_config; + + // Proxy redirect is emitted. 127.0.0.2 is not the loopback 127.0.0.1 so + // the mapper records an error loss and omits the proxy block entirely. + // (MXC 0.6.0-alpha can only encode {"localhost": N}; non-127.0.0.1 is + // not representable.) + assert!( + cfg["network"].get("proxy").is_none() || cfg["network"]["proxy"].is_null(), + "non-127.0.0.1 redirect must NOT produce a proxy block: {:?}", + cfg["network"].get("proxy") + ); + let has_proxy_loss = result + .loss + .iter() + .any(|i| i.path == "network.proxy" && i.severity == "error"); + assert!( + has_proxy_loss, + "non-127.0.0.1 redirect must produce an error loss item" + ); + + // Direct egress is blocked; allowedHosts is empty (proxy enforces the list). + assert_eq!(cfg["network"]["defaultPolicy"], "block"); + assert!( + str_list(&cfg["network"]["allowedHosts"]).is_empty(), + "split path must not populate allowedHosts" + ); + + // Filesystem grants are preserved unchanged. + assert_eq!( + str_list(&cfg["filesystem"]["readwritePaths"]), + policy.filesystem.as_ref().unwrap().read_write + ); + + // Network policy is returned verbatim for the proxy. + assert_eq!( + result.proxy_policy.network_policies, policy.network_policies, + "proxy_policy must carry all network rules verbatim" + ); + assert_eq!(result.proxy_policy.version, policy.version); + assert!( + result.proxy_policy.filesystem.is_none(), + "proxy_policy must not carry filesystem rules" + ); + + // No binary-scope, port, or protocol losses — those are delegated to the proxy. + let net_losses: Vec<_> = result + .loss + .iter() + .filter(|i| i.path.starts_with("network_policies") && i.severity != "info") + .collect(); + assert!( + net_losses.is_empty(), + "split path must not generate lossy network items: {net_losses:?}" + ); + assert!(result.loss.iter().any(|i| { + i.path == "network_policies" && i.severity == "info" && i.message.contains("delegated") + })); +} + +#[test] +fn split_policy_returns_none_without_proxy_addr() { + let opts = MxcMappingOptions::default(); + let policy = parse_sandbox_policy("").unwrap_or_default(); + assert!( + split_policy(&policy, &opts).is_none(), + "split_policy must return None when proxy_redirect is not set" + ); +} + +#[test] +fn split_policy_deterministic() { + let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); + let yaml = std::fs::read_to_string(&path).expect("read quickstart"); + let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); + let opts = MxcMappingOptions { + containment: "processcontainer".to_owned(), + proxy_redirect: Some("127.0.0.1:9999".parse().unwrap()), + ..Default::default() + }; + let a = split_policy(&policy, &opts).unwrap(); + let b = split_policy(&policy, &opts).unwrap(); + assert_eq!( + a.mxc_config, b.mxc_config, + "split_policy must be deterministic" + ); +} + +#[test] +fn split_policy_rejects_proxy_redirect_on_isolation_session() { + let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); + let yaml = std::fs::read_to_string(&path).expect("read quickstart"); + let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); + let opts = MxcMappingOptions { + containment: "isolation_session".to_owned(), + proxy_redirect: Some(proxy_addr()), + ..Default::default() + }; + let result = split_policy(&policy, &opts).unwrap(); + let errors: Vec<_> = result + .loss + .iter() + .filter(|i| i.severity == "error") + .collect(); + assert_eq!( + errors.len(), + 1, + "expected one containment error: {errors:?}" + ); + assert_eq!(errors[0].path, "containment"); + assert!(errors[0].message.contains("MXC M1")); + assert!(result.mxc_config["network"].get("proxy").is_none()); +} + +#[test] +fn network_only_policy_has_empty_filesystem() { + // policy-advisor is a network-only seed (no filesystem_policy). + let path = examples_root().join("policy-advisor/sandbox-policy.yaml"); + let yaml = std::fs::read_to_string(&path).expect("read policy-advisor"); + let policy = parse_sandbox_policy(&yaml).expect("parse policy-advisor"); + let result = map_to_mxc(&policy, &MxcMappingOptions::default()); + let cfg = &result.config; + + assert!(str_list(&cfg["filesystem"]["readwritePaths"]).is_empty()); + assert!(str_list(&cfg["filesystem"]["readonlyPaths"]).is_empty()); + assert_eq!( + str_list(&cfg["network"]["allowedHosts"]), + vec!["api.anthropic.com".to_owned()] + ); +} + +// ── New tests: proxy JSON shape and non-127.0.0.1 guard ────────────────────── + +#[test] +fn split_with_loopback_addr_emits_localhost_port_shape() { + // MXC 0.6.0-alpha accepts ONLY {"proxy": {"localhost": N}}. + // Verified against the real wxc-exec 0.6.0-alpha binary via --dry-run. + let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); + let yaml = std::fs::read_to_string(&path).expect("read quickstart"); + let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); + + let opts = MxcMappingOptions { + containment: "processcontainer".to_owned(), + proxy_redirect: Some("127.0.0.1:18080".parse().unwrap()), + ..Default::default() + }; + let result = split_policy(&policy, &opts).expect("split returns Some"); + let cfg = &result.mxc_config; + + assert_eq!( + cfg["network"]["proxy"]["localhost"], 18080, + "proxy must use {{\"localhost\": N}} shape" + ); + assert!( + cfg["network"]["proxy"].get("host").is_none(), + "proxy must not contain 'host' key" + ); + assert!( + cfg["network"]["proxy"].get("port").is_none(), + "proxy must not contain 'port' key" + ); + // No error losses — 127.0.0.1 is representable. + assert!( + result.loss.iter().all(|i| i.severity != "error"), + "127.0.0.1 proxy must not emit error losses: {:?}", + result + .loss + .iter() + .filter(|i| i.severity == "error") + .collect::>() + ); +} + +#[test] +fn split_with_non_loopback_addr_emits_error_loss_and_no_proxy_block() { + // Non-127.0.0.1 redirect addresses are not representable in MXC 0.6.0-alpha. + // The mapper must record an error loss and omit the proxy block. + let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); + let yaml = std::fs::read_to_string(&path).expect("read quickstart"); + let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); + + let opts = MxcMappingOptions { + containment: "processcontainer".to_owned(), + proxy_redirect: Some("127.0.0.5:18080".parse().unwrap()), + ..Default::default() + }; + let result = split_policy(&policy, &opts).expect("split returns Some"); + let cfg = &result.mxc_config; + + // Proxy block must be absent. + assert!( + cfg["network"].get("proxy").is_none() || cfg["network"]["proxy"].is_null(), + "non-127.0.0.1 redirect must not produce a proxy block: {:?}", + cfg["network"].get("proxy") + ); + + // An error loss for "network.proxy" must be present. + let proxy_loss = result + .loss + .iter() + .find(|i| i.path == "network.proxy" && i.severity == "error"); + assert!( + proxy_loss.is_some(), + "non-127.0.0.1 redirect must produce an error loss item on network.proxy: {:?}", + result.loss + ); + let loss = proxy_loss.unwrap(); + assert_eq!(loss.openshell_feature, "per-sandbox egress attribution"); + assert!( + loss.message.contains("localhost"), + "loss message should mention 'localhost': {}", + loss.message + ); +} diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs new file mode 100644 index 0000000000..39a94bee9b --- /dev/null +++ b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs @@ -0,0 +1,1332 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Tier-0 coverage matrix + schema drift guard for the OpenShell→MXC policy mapper. +//! +//! Three quadrants: +//! A — mappable fields (`OpenShell` ∩ MXC): assert exact MXC output. +//! B — OpenShell-only features MXC cannot express: assert one loss item per +//! field with the documented severity. +//! C — MXC restrictive defaults ("default deny posture"): empty policy maps +//! to the most-restrictive possible MXC config. +//! +//! Plus: `handled_fields_inventory` — the schema drift guard that fails when +//! `openshell-policy` gains a serialized field the mapper does not account for. + +#![cfg(target_os = "windows")] +#![allow( + clippy::doc_link_with_quotes, + clippy::doc_markdown, + clippy::needless_collect, + clippy::uninlined_format_args +)] + +use openshell_core::proto::{ + FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7Rule, LandlockPolicy, + MiddlewareEndpointSelector, NetworkBinary, NetworkEndpoint, NetworkMiddlewareConfig, + NetworkPolicyRule, ProcessPolicy, SandboxPolicy, +}; +use openshell_driver_mxc::{ + EmbeddedPolicyMapper, MapCtx, MapError, MxcMappingOptions, PolicyMapper, map_to_mxc, + split_policy, +}; +use openshell_policy::{serialize_sandbox_policy, validate_sandbox_policy}; +use serde_json::Value; + +// ─── helpers ──────────────────────────────────────────────────────────────── + +fn middleware_config() -> NetworkMiddlewareConfig { + NetworkMiddlewareConfig { + name: "redactor".into(), + middleware: "openshell/regex".into(), + config: None, + on_error: "fail_closed".into(), + endpoints: Some(MiddlewareEndpointSelector { + include: vec!["api.example.com".into()], + exclude: Vec::new(), + }), + order: 0, + } +} + +fn str_list(v: &Value) -> Vec { + v.as_array() + .map(|a| { + a.iter() + .filter_map(|e| e.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default() +} + +fn default_opts() -> MxcMappingOptions { + MxcMappingOptions::default() // bubblewrap containment +} + +fn bubblewrap_opts() -> MxcMappingOptions { + MxcMappingOptions { + containment: "bubblewrap".to_owned(), + ..Default::default() + } +} + +fn proxy_addr() -> std::net::SocketAddr { + "127.0.0.1:18080".parse().unwrap() +} + +fn pc_split_opts() -> MxcMappingOptions { + MxcMappingOptions { + containment: "processcontainer".to_owned(), + proxy_redirect: Some(proxy_addr()), + ..Default::default() + } +} + +/// Build a minimal policy with one network rule whose endpoints carry a single +/// endpoint set up by the caller. +fn net_policy(key: &str, ep: NetworkEndpoint) -> SandboxPolicy { + let mut p = SandboxPolicy::default(); + p.network_policies.insert( + key.to_owned(), + NetworkPolicyRule { + name: key.to_owned(), + endpoints: vec![ep], + binaries: Vec::new(), + }, + ); + p +} + +/// Assert exactly one loss item whose `path` contains `needle` and whose +/// `severity` equals `want_severity`. +fn assert_single_loss( + loss: &[openshell_driver_mxc::LossItem], + needle: &str, + want_severity: &str, + context: &str, +) { + let matching: Vec<_> = loss + .iter() + .filter(|i| i.path.contains(needle) && i.severity == want_severity) + .collect(); + assert!( + !matching.is_empty(), + "{context}: expected a '{want_severity}' loss with path containing '{needle}', got: {loss:?}" + ); + // There should not be more than one item with a DIFFERENT severity for the same path. + let other_severity: Vec<_> = loss + .iter() + .filter(|i| i.path.contains(needle) && i.severity != want_severity) + .collect(); + assert!( + other_severity.is_empty(), + "{context}: unexpected additional loss item(s) for '{needle}' with wrong severity: {other_severity:?}" + ); +} + +// ─── QUADRANT A: mappable fields, assert exact MXC output ─────────────────── + +/// filesystem.read_write → readwritePaths verbatim, order preserved. +#[test] +fn a_rw_paths_verbatim() { + let policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + read_write: vec!["/work".into(), "/tmp".into(), "/data".into()], + ..Default::default() + }), + ..Default::default() + }; + let r = map_to_mxc(&policy, &default_opts()); + assert_eq!( + str_list(&r.config["filesystem"]["readwritePaths"]), + vec!["/work", "/tmp", "/data"], + "readwritePaths must be verbatim, in order" + ); +} + +/// filesystem.read_only → readonlyPaths verbatim, order preserved. +#[test] +fn a_ro_paths_verbatim() { + let policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + read_only: vec!["/usr".into(), "/lib".into()], + ..Default::default() + }), + ..Default::default() + }; + let r = map_to_mxc(&policy, &default_opts()); + assert_eq!( + str_list(&r.config["filesystem"]["readonlyPaths"]), + vec!["/usr", "/lib"], + "readonlyPaths must be verbatim, in order" + ); +} + +/// include_workdir=true + opts.cwd set → cwd appended (unique) to readwritePaths. +#[test] +fn a_include_workdir_with_cwd_appended_unique() { + let policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + include_workdir: true, + read_write: vec!["/work".into()], + ..Default::default() + }), + ..Default::default() + }; + let opts = MxcMappingOptions { + cwd: Some("/work".to_owned()), // already present — must not duplicate + ..default_opts() + }; + let r = map_to_mxc(&policy, &opts); + let rw = str_list(&r.config["filesystem"]["readwritePaths"]); + assert!( + rw.contains(&"/work".to_owned()), + "cwd must appear in readwritePaths" + ); + let count = rw.iter().filter(|p| p.as_str() == "/work").count(); + assert_eq!(count, 1, "cwd must not be duplicated"); + + // Also test where cwd is new. + let opts2 = MxcMappingOptions { + cwd: Some("/newcwd".to_owned()), + ..default_opts() + }; + let policy2 = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + include_workdir: true, + read_write: vec!["/work".into()], + ..Default::default() + }), + ..Default::default() + }; + let r2 = map_to_mxc(&policy2, &opts2); + let rw2 = str_list(&r2.config["filesystem"]["readwritePaths"]); + assert!( + rw2.contains(&"/newcwd".to_owned()), + "new cwd must be appended to readwritePaths" + ); +} + +/// include_workdir=true, no cwd → an "info" loss item, no extra path added. +#[test] +fn a_include_workdir_no_cwd_emits_info_loss() { + let policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + include_workdir: true, + read_write: vec!["/work".into()], + ..Default::default() + }), + ..Default::default() + }; + let r = map_to_mxc(&policy, &default_opts()); // cwd is None + let has_info = r + .loss + .iter() + .any(|i| i.severity == "info" && i.path.contains("include_workdir")); + assert!( + has_info, + "expected info loss for include_workdir without cwd; got: {:?}", + r.loss + ); + // The path list must not have grown beyond the source list. + let rw = str_list(&r.config["filesystem"]["readwritePaths"]); + assert_eq!(rw, vec!["/work"]); +} + +/// Plain endpoint host → appears in allowedHosts. +#[test] +fn a_plain_host_in_allowed_hosts() { + let policy = net_policy( + "api", + NetworkEndpoint { + host: "api.example.com".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let hosts = str_list(&r.config["network"]["allowedHosts"]); + assert!( + hosts.contains(&"api.example.com".to_owned()), + "host must appear in allowedHosts; got: {hosts:?}" + ); +} + +/// endpoint.allowed_ips → each IP appended to allowedHosts + a "warning" loss. +#[test] +fn a_allowed_ips_appended_with_warning() { + let policy = net_policy( + "api", + NetworkEndpoint { + host: "db.internal".into(), + allowed_ips: vec!["10.0.0.1".into(), "10.0.0.2".into()], + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let hosts = str_list(&r.config["network"]["allowedHosts"]); + assert!( + hosts.contains(&"10.0.0.1".to_owned()), + "IP 10.0.0.1 must be in allowedHosts" + ); + assert!( + hosts.contains(&"10.0.0.2".to_owned()), + "IP 10.0.0.2 must be in allowedHosts" + ); + let warnings: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "warning" && i.path.contains("allowed_ips")) + .collect(); + assert!( + !warnings.is_empty(), + "expected warning loss for allowed_ips; got: {:?}", + r.loss + ); +} + +/// Duplicate hosts across rules → deduplicated in allowedHosts. +#[test] +fn a_duplicate_hosts_deduplicated() { + let mut policy = SandboxPolicy::default(); + let ep_a = NetworkEndpoint { + host: "shared.example.com".into(), + ..Default::default() + }; + let ep_b = NetworkEndpoint { + host: "shared.example.com".into(), + ..Default::default() + }; + policy.network_policies.insert( + "rule_a".to_owned(), + NetworkPolicyRule { + name: "rule_a".to_owned(), + endpoints: vec![ep_a], + binaries: Vec::new(), + }, + ); + policy.network_policies.insert( + "rule_b".to_owned(), + NetworkPolicyRule { + name: "rule_b".to_owned(), + endpoints: vec![ep_b], + binaries: Vec::new(), + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let hosts = str_list(&r.config["network"]["allowedHosts"]); + let count = hosts + .iter() + .filter(|h| h.as_str() == "shared.example.com") + .count(); + assert_eq!( + count, 1, + "duplicate hosts must be deduplicated; got: {hosts:?}" + ); +} + +/// Determinism: a policy with 3+ network rules (unordered map) maps twice → identical config JSON. +#[test] +fn a_deterministic_with_multiple_rules() { + let mut policy = SandboxPolicy::default(); + for (key, host) in &[ + ("rule_z", "z.example.com"), + ("rule_a", "a.example.com"), + ("rule_m", "m.example.com"), + ("rule_b", "b.example.com"), + ] { + policy.network_policies.insert( + key.to_string(), + NetworkPolicyRule { + name: key.to_string(), + endpoints: vec![NetworkEndpoint { + host: host.to_string(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + } + let r1 = map_to_mxc(&policy, &bubblewrap_opts()); + let r2 = map_to_mxc(&policy, &bubblewrap_opts()); + assert_eq!( + r1.config, r2.config, + "map_to_mxc must be deterministic across two calls" + ); +} + +/// split path: proxy_policy.network_policies == source's, proxy_policy.version preserved. +#[test] +fn a_split_network_verbatim_and_version_preserved() { + let mut policy = SandboxPolicy { + version: 42, + ..Default::default() + }; + policy.network_policies.insert( + "api".to_owned(), + NetworkPolicyRule { + name: "api".to_owned(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + ports: vec![443], + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + let result = split_policy(&policy, &pc_split_opts()).expect("split must return Some"); + assert_eq!( + result.proxy_policy.network_policies, policy.network_policies, + "split: proxy_policy.network_policies must equal source" + ); + assert_eq!( + result.proxy_policy.version, 42, + "split: proxy_policy.version must equal source" + ); +} + +/// split path: mxc_config["network"]["proxy"]["localhost"] == port (new schema). +#[test] +fn a_split_proxy_localhost_port() { + let policy = SandboxPolicy::default(); + let result = split_policy(&policy, &pc_split_opts()).expect("split must return Some"); + assert_eq!( + result.mxc_config["network"]["proxy"]["localhost"], 18080, + "split must emit network.proxy.localhost == port" + ); + // allowedHosts stays empty on the split path. + assert!( + str_list(&result.mxc_config["network"]["allowedHosts"]).is_empty(), + "split path must have empty allowedHosts" + ); +} + +// ─── QUADRANT B: OpenShell features MXC cannot express ────────────────────── +// +// For each field: build a minimal policy setting only that field (plus the +// minimum needed to reach the code path), map with default bubblewrap +// containment, assert exactly one loss item with the documented path fragment +// and severity. + +/// endpoint.ports → "error" (path contains ".port"). +#[test] +fn b_ports_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + ports: vec![443], + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".port", "error", "endpoint.ports"); +} + +/// endpoint.protocol → "error". +#[test] +fn b_protocol_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + protocol: "rest".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".protocol", "error", "endpoint.protocol"); +} + +/// endpoint.tls = "skip" → "warning". +#[test] +fn b_tls_skip_warning() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + tls: "skip".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".tls", "warning", "endpoint.tls=skip"); +} + +/// endpoint.tls = "full" (any non-skip) → "error". +#[test] +fn b_tls_non_skip_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + tls: "terminate".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".tls", "error", "endpoint.tls=terminate"); +} + +/// endpoint.enforcement = "audit" → "error". +#[test] +fn b_enforcement_audit_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + enforcement: "audit".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".enforcement", + "error", + "endpoint.enforcement=audit", + ); +} + +/// endpoint.enforcement = "enforce" (non-audit) → "warning". +#[test] +fn b_enforcement_non_audit_warning() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + enforcement: "enforce".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".enforcement", + "warning", + "endpoint.enforcement=enforce", + ); +} + +/// endpoint.access → "error". +#[test] +fn b_access_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + access: "read-only".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".access", "error", "endpoint.access"); +} + +/// endpoint.rules (one allow rule) → "error". +#[test] +fn b_rules_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + rules: vec![L7Rule { + allow: Some(L7Allow { + method: "GET".into(), + path: "/api".into(), + ..Default::default() + }), + }], + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".rules", "error", "endpoint.rules"); +} + +/// endpoint.deny_rules → "error". +#[test] +fn b_deny_rules_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + deny_rules: vec![L7DenyRule { + method: "POST".into(), + path: "/admin".into(), + ..Default::default() + }], + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".deny_rules", "error", "endpoint.deny_rules"); +} + +/// endpoint.allow_encoded_slash=true → "error". +#[test] +fn b_allow_encoded_slash_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + allow_encoded_slash: true, + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".allow_encoded_slash", + "error", + "allow_encoded_slash", + ); +} + +/// endpoint.websocket_credential_rewrite=true → "error". +#[test] +fn b_websocket_credential_rewrite_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + websocket_credential_rewrite: true, + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".websocket_credential_rewrite", + "error", + "websocket_credential_rewrite", + ); +} + +/// endpoint.request_body_credential_rewrite=true → "error". +#[test] +fn b_request_body_credential_rewrite_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + request_body_credential_rewrite: true, + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".request_body_credential_rewrite", + "error", + "request_body_credential_rewrite", + ); +} + +/// endpoint.persisted_queries → "error". +#[test] +fn b_persisted_queries_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + persisted_queries: "deny".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".persisted_queries", "error", "persisted_queries"); +} + +/// endpoint.graphql_persisted_queries → "error". +#[test] +fn b_graphql_persisted_queries_error() { + let mut ep = NetworkEndpoint { + host: "api.example.com".into(), + ..Default::default() + }; + ep.graphql_persisted_queries.insert( + "abc".to_owned(), + GraphqlOperation { + operation_type: "query".into(), + ..Default::default() + }, + ); + let policy = net_policy("r", ep); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".graphql_persisted_queries", + "error", + "graphql_persisted_queries", + ); +} + +/// endpoint.graphql_max_body_bytes > 0 → "error". +#[test] +fn b_graphql_max_body_bytes_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + graphql_max_body_bytes: 65536, + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".graphql_max_body_bytes", + "error", + "graphql_max_body_bytes", + ); +} + +/// Wildcard host ("*.example.com") with allow_wildcards=false → "error", host NOT in allowedHosts. +#[test] +fn b_wildcard_host_deny_wildcards_error_host_absent() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "*.example.com".into(), + ..Default::default() + }, + ); + let opts = MxcMappingOptions { + allow_wildcards: false, + containment: "bubblewrap".to_owned(), + ..Default::default() + }; + let r = map_to_mxc(&policy, &opts); + // Must have an "error" loss for the wildcard host. + let err_loss: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "error" && i.path.contains(".host")) + .collect(); + assert!( + !err_loss.is_empty(), + "expected error loss for wildcard host; got: {:?}", + r.loss + ); + // Host must NOT be in allowedHosts when allow_wildcards=false. + let hosts = str_list(&r.config["network"]["allowedHosts"]); + assert!( + !hosts.contains(&"*.example.com".to_owned()), + "wildcard host must be absent from allowedHosts when allow_wildcards=false; got: {hosts:?}" + ); +} + +/// Wildcard host with allow_wildcards=true → "error" (semantics warning), host IS in allowedHosts. +#[test] +fn b_wildcard_host_allow_wildcards_error_host_present() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "*.example.com".into(), + ..Default::default() + }, + ); + let opts = MxcMappingOptions { + allow_wildcards: true, + containment: "bubblewrap".to_owned(), + ..Default::default() + }; + let r = map_to_mxc(&policy, &opts); + // Still an "error" loss (MXC semantics warning), even though we emitted the host. + let err_loss: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "error" && i.path.contains(".host")) + .collect(); + assert!( + !err_loss.is_empty(), + "expected error loss for wildcard host even with allow_wildcards=true; got: {:?}", + r.loss + ); + // Host IS in allowedHosts when allow_wildcards=true. + let hosts = str_list(&r.config["network"]["allowedHosts"]); + assert!( + hosts.contains(&"*.example.com".to_owned()), + "wildcard host must appear in allowedHosts when allow_wildcards=true; got: {hosts:?}" + ); +} + +/// rule.binaries non-empty → "error" per binary. +#[test] +fn b_binaries_error_per_binary() { + let mut policy = SandboxPolicy::default(); + policy.network_policies.insert( + "r".to_owned(), + NetworkPolicyRule { + name: "r".to_owned(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + ..Default::default() + }], + binaries: vec![ + NetworkBinary { + path: "/usr/bin/curl".into(), + ..Default::default() + }, + NetworkBinary { + path: "/usr/bin/wget".into(), + ..Default::default() + }, + ], + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let binary_errors: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "error" && i.path.contains("binaries[")) + .collect(); + assert_eq!( + binary_errors.len(), + 2, + "expected one error loss per binary; got: {:?}", + r.loss + ); +} + +/// rule with empty endpoints → "error". +#[test] +fn b_empty_endpoints_error() { + let mut policy = SandboxPolicy::default(); + policy.network_policies.insert( + "r".to_owned(), + NetworkPolicyRule { + name: "r".to_owned(), + endpoints: Vec::new(), // empty + binaries: Vec::new(), + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let endpoint_errors: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "error" && i.path.contains(".endpoints")) + .collect(); + assert!( + !endpoint_errors.is_empty(), + "expected error loss for empty endpoints; got: {:?}", + r.loss + ); +} + +/// endpoint with empty host → "error". +#[test] +fn b_empty_host_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: String::new(), // empty + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let host_errors: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "error" && i.path.contains(".host")) + .collect(); + assert!( + !host_errors.is_empty(), + "expected error loss for empty host; got: {:?}", + r.loss + ); +} + +/// rule with empty binaries → "error". +#[test] +fn b_empty_binaries_error() { + let mut policy = SandboxPolicy::default(); + policy.network_policies.insert( + "r".to_owned(), + NetworkPolicyRule { + name: "r".to_owned(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + ..Default::default() + }], + binaries: Vec::new(), // empty + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let bin_errors: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "error" && i.path.contains(".binaries")) + .collect(); + assert!( + !bin_errors.is_empty(), + "expected error loss for empty binaries; got: {:?}", + r.loss + ); +} + +/// policy.landlock = Some(default) → "warning". +#[test] +fn b_landlock_warning() { + let policy = SandboxPolicy { + landlock: Some(LandlockPolicy { + compatibility: "best_effort".into(), + }), + ..Default::default() + }; + let r = map_to_mxc(&policy, &default_opts()); + assert_single_loss(&r.loss, "landlock", "warning", "landlock"); +} + +/// process.run_as_user non-empty → "warning". +#[test] +fn b_run_as_user_warning() { + let policy = SandboxPolicy { + process: Some(ProcessPolicy { + run_as_user: "sandbox".into(), + run_as_group: String::new(), + }), + ..Default::default() + }; + let r = map_to_mxc(&policy, &default_opts()); + assert_single_loss(&r.loss, "run_as_user", "warning", "process.run_as_user"); +} + +/// process.run_as_group non-empty → "warning". +#[test] +fn b_run_as_group_warning() { + let policy = SandboxPolicy { + process: Some(ProcessPolicy { + run_as_user: String::new(), + run_as_group: "sandboxers".into(), + }), + ..Default::default() + }; + let r = map_to_mxc(&policy, &default_opts()); + assert_single_loss(&r.loss, "run_as_group", "warning", "process.run_as_group"); +} + +/// Seam-level: EmbeddedPolicyMapper.map over a policy with one error-class field +/// (a port) via MapCtx{egress: None} returns Err(MapError::Unsupported(_)). +#[test] +fn b_seam_returns_unsupported_on_error_field() { + let mapper = EmbeddedPolicyMapper; + // isolation_session containment + network policy → error loss from add_backend_specific_config. + let mut policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + read_write: vec!["C:/work".into()], + ..Default::default() + }), + ..Default::default() + }; + policy.network_policies.insert( + "api".to_owned(), + NetworkPolicyRule { + name: "api".to_owned(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + ports: vec![443], + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + let ctx = MapCtx { + sandbox_id: "sb-test".into(), + egress: None, // coarse path → isolation_session → network policy errors + }; + let err = mapper.map(Some(&policy), &ctx).unwrap_err(); + assert!( + matches!(err, MapError::Unsupported(_)), + "seam must return MapError::Unsupported for error-class losses; got: {err:?}" + ); +} + +// ─── QUADRANT C: restrictive defaults ("default deny posture") ─────────────── + +/// Empty SandboxPolicy (all None/empty) with default options produces the most +/// restrictive possible MXC config. +#[test] +fn c_empty_policy_default_deny_posture() { + let policy = SandboxPolicy::default(); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let cfg = &r.config; + + // Network: default deny, no allowed/blocked hosts. + assert_eq!( + cfg["network"]["defaultPolicy"], "block", + "defaultPolicy must be 'block' on empty policy" + ); + assert!( + str_list(&cfg["network"]["allowedHosts"]).is_empty(), + "allowedHosts must be [] on empty policy" + ); + assert!( + str_list(&cfg["network"]["blockedHosts"]).is_empty(), + "blockedHosts must be [] on empty policy" + ); + + // UI: fully locked down. + assert_eq!(cfg["ui"]["disable"], true, "ui.disable must be true"); + assert_eq!( + cfg["ui"]["clipboard"], "none", + "ui.clipboard must be 'none'" + ); + assert_eq!(cfg["ui"]["injection"], false, "ui.injection must be false"); + + // Lifecycle: destroyOnExit + no policy preservation. + assert_eq!( + cfg["lifecycle"]["destroyOnExit"], true, + "lifecycle.destroyOnExit must be true" + ); + assert_eq!( + cfg["lifecycle"]["preservePolicy"], false, + "lifecycle.preservePolicy must be false" + ); + + // Filesystem: all lists empty, deniedPaths empty. + assert!( + str_list(&cfg["filesystem"]["readwritePaths"]).is_empty(), + "readwritePaths must be [] on empty policy" + ); + assert!( + str_list(&cfg["filesystem"]["readonlyPaths"]).is_empty(), + "readonlyPaths must be [] on empty policy" + ); + assert!( + str_list(&cfg["filesystem"]["deniedPaths"]).is_empty(), + "deniedPaths must be [] on empty policy" + ); + + // No processContainer key when no hosts are granted. + assert!( + cfg.get("processContainer").is_none(), + "processContainer must be absent when no hosts are mapped" + ); + + // No enforcementMode key when allowedHosts is empty. + assert!( + cfg["network"].get("enforcementMode").is_none(), + "enforcementMode must be absent when allowedHosts is empty" + ); +} + +/// Split path with network rules present: allowedHosts stays empty. +#[test] +fn c_split_empty_allowed_hosts_with_network_rules() { + let mut policy = SandboxPolicy::default(); + policy.network_policies.insert( + "api".to_owned(), + NetworkPolicyRule { + name: "api".to_owned(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + let result = split_policy(&policy, &pc_split_opts()).expect("split must return Some"); + assert!( + str_list(&result.mxc_config["network"]["allowedHosts"]).is_empty(), + "split path allowedHosts must be empty even with network rules; got: {:?}", + result.mxc_config["network"]["allowedHosts"] + ); + // But proxy redirect is present. + assert_eq!( + result.mxc_config["network"]["proxy"]["localhost"], 18080, + "split must emit network.proxy.localhost" + ); +} + +// ─── DRIFT GUARD ───────────────────────────────────────────────────────────── +// +// Serialize policies via openshell_policy::serialize_sandbox_policy, collect +// YAML keys, compare against HANDLED_* const slices. Fails when a new field +// is added to the schema without updating the mapper. + +/// Top-level fields of the YAML policy schema that the mapper handles today. +/// Derived from what map.rs and build_split_mxc_config actually read. +/// +/// "version" — emitted into mxc_config["version"] (not a loss) +/// "filesystem_policy" — mapped via map_filesystem +/// "landlock" — loss item emitted in add_static_policy_loss +/// "process" — loss items for run_as_user / run_as_group +/// "network_policies" — mapped via map_network / delegated in split +const HANDLED_TOPLEVEL: &[&str] = &[ + "version", + "filesystem_policy", + "landlock", + "process", + "network_policies", + "network_middlewares", +]; + +/// Per-rule keys under each network_policies entry that the mapper handles. +const HANDLED_RULE_KEYS: &[&str] = &["name", "endpoints", "binaries"]; + +/// Per-endpoint keys that the mapper accounts for (mapping or loss item). +/// +/// "host" — mapped to allowedHosts (or loss if wildcard/empty) +/// "port" — normalized to ports at parse; covered by ports loss +/// "ports" — error loss +/// "protocol" — error loss +/// "tls" — warning (skip) or error loss +/// "enforcement" — error (audit) or warning (other) loss +/// "access" — error loss +/// "rules" — error loss +/// "allowed_ips" — appended to allowedHosts + warning loss +/// "deny_rules" — error loss +/// "allow_encoded_slash" — error loss +/// "websocket_credential_rewrite" — error loss +/// "request_body_credential_rewrite" — error loss +/// "persisted_queries" — error loss +/// "graphql_persisted_queries" — error loss +/// "graphql_max_body_bytes" — error loss +/// "path" — not currently read by the mapper (no loss emitted); +/// included here so the drift guard does not trip on +/// existing schema fields the mapper silently ignores. +/// If the mapper needs to enforce path-scoped routing, +/// remove this entry and add an explicit loss item. +const HANDLED_ENDPOINT_KEYS: &[&str] = &[ + "host", + "port", + "ports", + "protocol", + "tls", + "enforcement", + "access", + "rules", + "allowed_ips", + "deny_rules", + "allow_encoded_slash", + "websocket_credential_rewrite", + "request_body_credential_rewrite", + "persisted_queries", + "graphql_persisted_queries", + "graphql_max_body_bytes", + "path", +]; + +#[test] +fn handled_fields_inventory() { + use std::collections::BTreeSet; + + // ── (1) Top-level keys: serialize a SandboxPolicy with every section + // present-but-minimal, then collect YAML keys. ────────────────────────── + let full_toplevel_policy = SandboxPolicy { + version: 1, + filesystem: Some(FilesystemPolicy { + include_workdir: false, + read_only: vec!["/usr".into()], + read_write: vec!["/work".into()], + }), + landlock: Some(LandlockPolicy { + compatibility: "best_effort".into(), + }), + process: Some(ProcessPolicy { + run_as_user: "sandbox".into(), + run_as_group: "sandbox".into(), + }), + network_policies: { + let mut m = std::collections::HashMap::new(); + m.insert( + "rule".to_owned(), + NetworkPolicyRule { + name: "rule".to_owned(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + port: 443, + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + m + }, + network_middlewares: { + let mut m = std::collections::HashMap::new(); + m.insert("redactor".to_owned(), middleware_config()); + m + }, + }; + + // Validate so the test itself doesn't carry a bad policy. + validate_sandbox_policy(&full_toplevel_policy).expect("test policy must be valid"); + + let yaml_toplevel = + serialize_sandbox_policy(&full_toplevel_policy).expect("serialize full_toplevel_policy"); + let top_value: Value = + serde_yml::from_str::(&yaml_toplevel).expect("re-parse as JSON value"); + let top_obj = top_value + .as_object() + .expect("top-level must be a JSON object"); + let observed_toplevel: BTreeSet<&str> = top_obj.keys().map(String::as_str).collect(); + let expected_toplevel: BTreeSet<&str> = HANDLED_TOPLEVEL.iter().copied().collect(); + + let unhandled_top: Vec<&&str> = observed_toplevel + .iter() + .filter(|k| !expected_toplevel.contains(**k)) + .collect(); + assert!( + unhandled_top.is_empty(), + "openshell-policy gained top-level field(s) {:?} that the policy mapper does not handle \ + — map it, delegate it, or add a loss item, then update HANDLED_TOPLEVEL in this test.", + unhandled_top + ); + + let missing_top: Vec<&&str> = expected_toplevel + .iter() + .filter(|k| !observed_toplevel.contains(**k)) + .collect(); + assert!( + missing_top.is_empty(), + "HANDLED_TOPLEVEL lists field(s) {:?} that are no longer emitted by \ + serialize_sandbox_policy — remove them from HANDLED_TOPLEVEL.", + missing_top + ); + + // ── (2) Per-rule and per-endpoint keys: serialize a policy with one fully- + // populated NetworkPolicyRule / NetworkEndpoint. ───────────────────────── + // + // Note: `port` (scalar) and `ports` (array) are mutually exclusive in the + // serialized form — single port emits `port`; multiple ports emit `ports`. + // To cover both variants we use TWO endpoints: the first with multi-port + // (triggers `ports` key), the second with single-port (triggers `port`). + // The drift guard collects the UNION of all endpoint keys observed. + let mut full_ep = NetworkEndpoint { + host: "api.example.com".into(), + path: "/graphql".into(), + // Two ports → serializes as `ports: [80, 443]` (array form). + ports: vec![80, 443], + protocol: "graphql".into(), + tls: "skip".into(), + enforcement: "enforce".into(), + access: "full".into(), + allowed_ips: vec!["10.0.0.1".into()], + allow_encoded_slash: true, + websocket_credential_rewrite: true, + request_body_credential_rewrite: true, + persisted_queries: "deny".into(), + graphql_max_body_bytes: 65536, + rules: vec![L7Rule { + allow: Some(L7Allow { + method: "GET".into(), + path: "/foo".into(), + ..Default::default() + }), + }], + deny_rules: vec![L7DenyRule { + method: "POST".into(), + path: "/bar".into(), + ..Default::default() + }], + ..Default::default() + }; + full_ep.graphql_persisted_queries.insert( + "abc".to_owned(), + GraphqlOperation { + operation_type: "query".into(), + ..Default::default() + }, + ); + // Second endpoint: single port → serializes as `port: 443` (scalar form). + let single_port_ep = NetworkEndpoint { + host: "other.example.com".into(), + ports: vec![443], + ..Default::default() + }; + + let full_rule_policy = SandboxPolicy { + version: 1, + network_policies: { + let mut m = std::collections::HashMap::new(); + m.insert( + "rule".to_owned(), + NetworkPolicyRule { + name: "rule".to_owned(), + endpoints: vec![full_ep, single_port_ep], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".into(), + ..Default::default() + }], + }, + ); + m + }, + ..Default::default() + }; + + let yaml_rule = + serialize_sandbox_policy(&full_rule_policy).expect("serialize full_rule_policy"); + let rule_value: Value = + serde_yml::from_str::(&yaml_rule).expect("re-parse rule policy as JSON value"); + + // Collect per-rule keys. + let network_policies_obj = rule_value["network_policies"] + .as_object() + .expect("network_policies must be an object"); + let first_rule = network_policies_obj + .values() + .next() + .expect("at least one rule") + .as_object() + .expect("rule must be an object"); + let observed_rule_keys: BTreeSet<&str> = first_rule.keys().map(String::as_str).collect(); + let expected_rule_keys: BTreeSet<&str> = HANDLED_RULE_KEYS.iter().copied().collect(); + + let unhandled_rule: Vec<&&str> = observed_rule_keys + .iter() + .filter(|k| !expected_rule_keys.contains(**k)) + .collect(); + assert!( + unhandled_rule.is_empty(), + "openshell-policy gained network rule field(s) {:?} that the policy mapper does not handle \ + — map it, delegate it, or add a loss item, then update HANDLED_RULE_KEYS in this test.", + unhandled_rule + ); + + let missing_rule: Vec<&&str> = expected_rule_keys + .iter() + .filter(|k| !observed_rule_keys.contains(**k)) + .collect(); + assert!( + missing_rule.is_empty(), + "HANDLED_RULE_KEYS lists field(s) {:?} that are no longer emitted — remove them.", + missing_rule + ); + + // Collect per-endpoint keys: union across ALL endpoints so that mutually- + // exclusive fields like `port` (single-port form) and `ports` (multi-port + // form) are both captured. + let endpoints_arr = first_rule["endpoints"] + .as_array() + .expect("endpoints must be an array"); + let observed_ep_keys: BTreeSet<&str> = endpoints_arr + .iter() + .filter_map(|ep| ep.as_object()) + .flat_map(|obj| obj.keys().map(String::as_str)) + .collect(); + let expected_ep_keys: BTreeSet<&str> = HANDLED_ENDPOINT_KEYS.iter().copied().collect(); + + let unhandled_ep: Vec<&&str> = observed_ep_keys + .iter() + .filter(|k| !expected_ep_keys.contains(**k)) + .collect(); + assert!( + unhandled_ep.is_empty(), + "openshell-policy gained endpoint field(s) {:?} that the policy mapper does not handle \ + — map it, delegate it, or add a loss item, then update HANDLED_ENDPOINT_KEYS in this test.", + unhandled_ep + ); + + let missing_ep: Vec<&&str> = expected_ep_keys + .iter() + .filter(|k| !observed_ep_keys.contains(**k)) + .collect(); + assert!( + missing_ep.is_empty(), + "HANDLED_ENDPOINT_KEYS lists field(s) {:?} that are no longer emitted — remove them.", + missing_ep + ); +} diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs new file mode 100644 index 0000000000..dd16836e4b --- /dev/null +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -0,0 +1,795 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Real-`wxc-exec` integration tests (Tier 2). +//! +//! These tests drive the actual `wxc-exec.exe` binary — no mock shim. Every +//! test is `#[ignore = "requires real wxc-exec"]` so the regular `cargo test` +//! suite (`windows:test:x64`) never blocks on hardware. Run them with: +//! +//! ```powershell +//! $env:OPENSHELL_WXC_EXEC_PATH = "C:\mxc\wxc-exec.exe" +//! cargo test -p openshell-driver-mxc --test wxc_exec_real -- --ignored --test-threads=1 +//! ``` +//! +//! Two families: +//! +//! **(a) Dry-run contract tests** — exercise `--dry-run` only; pass/fail on +//! schema acceptance. Some `wxc-exec` builds select the DACL fallback during +//! dry-run and validate filesystem grants, so these tests use owned temporary +//! directories with concrete Windows paths. +//! +//! **(b) Enforcement tests** — probe-gated; print a human-readable SKIP reason +//! and return early when the backend is not live. The probe distinguishes +//! "binary absent", "`backend_error` / velocity keys not enabled", and +//! "`backend_unavailable`". +//! +//! IMPORTANT: `OPENSHELL_MXC_MOCK_WXC` must NOT be set when running this file. +//! The probe-gated enforcement tests assert that it is absent so a stale env +//! var can never silently re-mock a "real" run. + +#![cfg(target_os = "windows")] + +use base64::Engine as _; +use std::path::PathBuf; +use std::process::Command; + +// ── Path resolution ────────────────────────────────────────────────────────── + +/// Resolve the path to `wxc-exec.exe`. +/// +/// Checks `OPENSHELL_WXC_EXEC_PATH` first, then the canonical demo-box +/// location `C:\mxc\wxc-exec.exe`. Returns `None` when neither path exists so +/// callers can skip rather than fail. +fn wxc_path() -> Option { + if let Ok(p) = std::env::var("OPENSHELL_WXC_EXEC_PATH") { + let pb = PathBuf::from(&p); + if pb.exists() { + return Some(pb); + } + // Env var was set but path is absent — still treat as "not found" so + // tests skip with a clear reason rather than erroring on spawn. + eprintln!("SKIP: OPENSHELL_WXC_EXEC_PATH={p} does not exist"); + return None; + } + let default = PathBuf::from(r"C:\mxc\wxc-exec.exe"); + if default.exists() { + return Some(default); + } + None +} + +// ── Dry-run helper ──────────────────────────────────────────────────────────── + +/// Invoke `wxc-exec --config-base64 --dry-run` synchronously. +/// Returns `(exit_code, stdout, stderr)`. +fn dry_run(wxc: &PathBuf, config: &serde_json::Value) -> (i32, String, String) { + let json = serde_json::to_string(config).expect("config serialize"); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let out = Command::new(wxc) + .arg("--config-base64") + .arg(&b64) + .arg("--dry-run") + .output() + .expect("wxc-exec spawn"); + + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + let code = out.status.code().unwrap_or(-1); + (code, stdout, stderr) +} + +// ── (a) Dry-run contract tests ──────────────────────────────────────────────── +// +// These PASS on any box that has the wxc-exec binary — no enforcement backend +// is required because --dry-run only validates the JSON schema. + +/// Minimal processcontainer one-shot config accepted by `--dry-run`. +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_accepts_minimal_processcontainer_config() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let tmpdir = tempfile::tempdir().expect("tempdir"); + let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "test-minimal", + "containment": "processcontainer", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": tmpdir_str, + "timeout": 0, + }, + "filesystem": { + "readwritePaths": [tmpdir_str], + }, + }); + + let (code, stdout, stderr) = dry_run(&wxc, &config); + assert_eq!( + code, 0, + "minimal processcontainer config rejected by --dry-run\nstdout={stdout}\nstderr={stderr}" + ); +} + +/// Network block without proxy (defaultPolicy block, empty host lists) accepted. +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_accepts_network_block_without_proxy() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let tmpdir = tempfile::tempdir().expect("tempdir"); + let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "test-net-block", + "containment": "processcontainer", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": tmpdir_str, + "timeout": 0, + }, + "filesystem": { + "readwritePaths": [tmpdir_str], + }, + "network": { + "defaultPolicy": "block", + "allowedHosts": [], + "blockedHosts": [], + }, + }); + + let (code, stdout, stderr) = dry_run(&wxc, &config); + assert_eq!( + code, 0, + "network block without proxy rejected by --dry-run\nstdout={stdout}\nstderr={stderr}" + ); +} + +/// The ONLY accepted proxy shape in MXC 0.6.0-alpha: `{"localhost": }`. +/// Verified empirically against the real binary — any other shape is rejected +/// with "Request error". +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_accepts_localhost_proxy_shape() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let tmpdir = tempfile::tempdir().expect("tempdir"); + let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "test-proxy-localhost", + "containment": "processcontainer", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": tmpdir_str, + "timeout": 0, + }, + "filesystem": { + "readwritePaths": [tmpdir_str], + }, + "network": { + "defaultPolicy": "block", + "allowedHosts": [], + "blockedHosts": [], + "proxy": { "localhost": 18080 }, + }, + }); + + let (code, stdout, stderr) = dry_run(&wxc, &config); + assert_eq!( + code, 0, + "{{\"localhost\": N}} proxy shape rejected by --dry-run\nstdout={stdout}\nstderr={stderr}" + ); +} + +/// The `{"host": ..., "port": ...}` proxy shape is REJECTED by MXC 0.6.0-alpha. +/// This test guards the schema contract discovered via dry-run bisection. +/// See docs4gtb/mxc-box-capabilities.md §"Schema contract findings". +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_rejects_host_port_proxy_shape() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "test-proxy-hostport", + "containment": "processcontainer", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": "%TEMP%", + "timeout": 0, + }, + "filesystem": { + "readwritePaths": ["%TEMP%"], + }, + "network": { + "defaultPolicy": "block", + "allowedHosts": [], + "blockedHosts": [], + // MXC 0.6.0-alpha rejects {"host","port"} — verified empirically. + "proxy": { "host": "127.0.0.1", "port": 18080 }, + }, + }); + + let (code, _stdout, _stderr) = dry_run(&wxc, &config); + assert_ne!( + code, 0, + "{{\"host\",\"port\"}} proxy shape was unexpectedly ACCEPTED — \ + schema may have widened in a newer wxc-exec build" + ); +} + +/// Unknown containment value is rejected. +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_rejects_unknown_containment() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "test-bad-containment", + "containment": "nonsense", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": "%TEMP%", + "timeout": 0, + }, + "filesystem": { + "readwritePaths": ["%TEMP%"], + }, + }); + + let (code, _stdout, _stderr) = dry_run(&wxc, &config); + assert_ne!(code, 0, "unknown containment 'nonsense' should be rejected"); +} + +/// The most important dry-run test: parse the quickstart example policy with +/// `openshell_policy`, run `split_policy` (`proxy_redirect` 127.0.0.1:18080, +/// containment "processcontainer"), take the resulting `mxc_config`, inject a +/// real process block with a valid cwd, and verify that `--dry-run` exits 0. +/// +/// This proves that the mapper's emitted JSON is accepted by the real binary — +/// the central contract of the policy-mapper integration. +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_accepts_split_policy_output() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + // Find the quickstart policy relative to CARGO_MANIFEST_DIR. + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let policy_path = manifest_dir.join("../../examples/sandbox-policy-quickstart/policy.yaml"); + + if !policy_path.exists() { + eprintln!( + "SKIP: quickstart policy not found at {}", + policy_path.display() + ); + return; + } + + let yaml = std::fs::read_to_string(&policy_path).expect("read policy YAML"); + let policy = openshell_policy::parse_sandbox_policy(&yaml).expect("parse quickstart policy"); + + let opts = openshell_driver_mxc::MxcMappingOptions { + containment: "processcontainer".to_string(), + proxy_redirect: Some("127.0.0.1:18080".parse().unwrap()), + ..Default::default() + }; + + let result = openshell_driver_mxc::split_policy(&policy, &opts) + .expect("split_policy must return Some when proxy_redirect is set"); + // The quickstart policy has network_policies with error-level losses on + // isolation_session, but on processcontainer there should be zero error + // losses from the split itself. Warn if there are any error losses so the + // test is informative even when it proceeds. + let error_losses: Vec<_> = result + .loss + .iter() + .filter(|l| l.severity == "error") + .collect(); + if !error_losses.is_empty() { + eprintln!( + "split_policy emitted {} error loss item(s); proceeding to dry-run:\n{:#?}", + error_losses.len(), + error_losses + ); + } + + // Take the mapper's MXC config and inject the required process block. + // The split config does not include a process block (that comes from the + // gateway TOML at runtime); wxc-exec --dry-run requires one. + // + // The quickstart policy uses sandbox-internal Unix paths. Replace only the + // environment-dependent filesystem paths with an owned Windows directory: + // this test verifies the mapper's MXC JSON shape, while mapper unit tests + // cover the exact filesystem translation. + let tmpdir = tempfile::tempdir().expect("tempdir"); + let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let mut mxc_config = result.mxc_config.clone(); + mxc_config["filesystem"] = serde_json::json!({ + "readwritePaths": [tmpdir_str], + "readonlyPaths": [], + "deniedPaths": [], + }); + mxc_config["process"] = serde_json::json!({ + "commandLine": "cmd /c exit 0", + "cwd": tmpdir_str, + "timeout": 0, + }); + // containerId is also required for processcontainer. + mxc_config["containerId"] = serde_json::json!("split-policy-dryrun"); + + let (code, stdout, stderr) = dry_run(&wxc, &mxc_config); + assert_eq!( + code, + 0, + "split_policy output rejected by --dry-run; \ + this proves the mapper emits valid MXC JSON\n\ + config={}\nstdout={stdout}\nstderr={stderr}", + serde_json::to_string_pretty(&mxc_config).unwrap_or_default() + ); +} + +// ── (b) Enforcement tests — probe-gated ─────────────────────────────────────── +// +// These skip on this box (processcontainer velocity keys not enabled; +// isolation_session backend absent). They PASS where backends are live. + +/// Probe the processcontainer backend. +/// +/// Runs a trivial one-shot (`cmd /c exit 0`, owned temporary-directory grant). +/// Returns `Ok(())` when the backend is live, or `Err(reason)` when it is not (the +/// caller prints SKIP + reason and returns from the test). +fn probe_processcontainer(wxc: &PathBuf) -> Result<(), String> { + // Abort early if the mock env var is set — a stale OPENSHELL_MXC_MOCK_WXC + // would silently turn this "real" run back into a mock run. + if std::env::var("OPENSHELL_MXC_MOCK_WXC").is_ok_and(|value| value == "1") { + return Err( + "OPENSHELL_MXC_MOCK_WXC=1 is set — unset it before running real enforcement tests" + .to_string(), + ); + } + + let tmpdir = tempfile::tempdir().map_err(|error| format!("tempdir failed: {error}"))?; + let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "probe-pc", + "containment": "processcontainer", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": tmpdir_str, + "timeout": 10, + }, + "filesystem": { + "readwritePaths": [tmpdir_str], + }, + }); + + let json = serde_json::to_string(&config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let out = Command::new(wxc) + .arg("--config-base64") + .arg(&b64) + .output() + .map_err(|e| format!("wxc-exec spawn failed: {e}"))?; + + let stdout = String::from_utf8_lossy(&out.stdout).to_lowercase(); + let stderr = String::from_utf8_lossy(&out.stderr).to_lowercase(); + let combined = format!("{stdout} {stderr}"); + + if combined.contains("backend_error") + || combined.contains("e_notimpl") + || combined.contains("velocity") + || combined.contains("not enabled") + { + // Extract the message if possible for a more useful skip reason. + let reason = + serde_json::from_str::(&String::from_utf8_lossy(&out.stdout)) + .map_or_else( + |_| "backend_error (velocity keys not enabled)".to_string(), + |value| { + value["error"]["message"] + .as_str() + .unwrap_or("backend_error (E_NOTIMPL)") + .to_string() + }, + ); + return Err(reason); + } + + if !out.status.success() { + return Err(format!( + "processcontainer probe returned exit {}: stdout={} stderr={}", + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + )); + } + + Ok(()) +} + +/// Probe the `isolation_session` backend. +/// +/// Attempts a `provision` phase. Returns `Ok(sandbox_id)` when live, or +/// `Err(reason)` when the backend is unavailable (caller prints SKIP). +fn probe_isolation_session(wxc: &PathBuf) -> Result { + if std::env::var("OPENSHELL_MXC_MOCK_WXC").is_ok_and(|value| value == "1") { + return Err( + "OPENSHELL_MXC_MOCK_WXC=1 is set — unset it before running real enforcement tests" + .to_string(), + ); + } + + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "phase": "provision", + "containment": "isolation_session", + "filesystem": { + "readwritePaths": [], + "readonlyPaths": [], + }, + "experimental": { + "isolation_session": { + "configurationId": "composable", + "provision": {} + } + } + }); + + let json = serde_json::to_string(&config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let out = Command::new(wxc) + .arg("--config-base64") + .arg(&b64) + .arg("--experimental") + .output() + .map_err(|e| format!("wxc-exec spawn failed: {e}"))?; + + let stdout_raw = String::from_utf8_lossy(&out.stdout).into_owned(); + let stdout_lower = stdout_raw.to_lowercase(); + let stderr_lower = String::from_utf8_lossy(&out.stderr).to_lowercase(); + let combined = format!("{stdout_lower} {stderr_lower}"); + + if combined.contains("backend_unavailable") || combined.contains("0x80040154") { + return Err( + "backend_unavailable: IsoSessionApp.dll absent or OS build < 26300.8553".to_string(), + ); + } + + if !out.status.success() { + return Err(format!( + "isolation_session provision failed (exit {}): {}", + out.status.code().unwrap_or(-1), + stdout_raw + )); + } + + // Parse the sandboxId from {"result":{"sandboxId":"iso:..."}} + let env: serde_json::Value = serde_json::from_str(&stdout_raw) + .map_err(|e| format!("provision envelope parse failed: {e}: {stdout_raw}"))?; + + let sandbox_id = env["result"]["sandboxId"] + .as_str() + .ok_or_else(|| format!("sandboxId missing in provision result: {stdout_raw}"))? + .to_string(); + + Ok(sandbox_id) +} + +/// RAII guard that best-effort deprovisioning on drop — protects the +/// single-session backend against orphaned sessions. +struct DeprovisionGuard<'a> { + wxc: &'a PathBuf, + sandbox_id: Option, +} + +impl<'a> DeprovisionGuard<'a> { + fn new(wxc: &'a PathBuf, sandbox_id: String) -> Self { + Self { + wxc, + sandbox_id: Some(sandbox_id), + } + } + + fn disarm(&mut self) { + self.sandbox_id = None; + } + + fn deprovision_now(&mut self) { + if let Some(id) = self.sandbox_id.take() { + Self::run_deprovision(self.wxc, &id); + } + } + + fn run_deprovision(wxc: &PathBuf, sandbox_id: &str) { + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "phase": "deprovision", + "sandboxId": sandbox_id, + "experimental": { + "isolation_session": { + // Unit variant: must be null, not {} (malformed_request otherwise). + "deprovision": null + } + } + }); + let json = serde_json::to_string(&config).unwrap_or_default(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + // Best-effort: ignore errors so the test does not panic in drop. + let _ = Command::new(wxc) + .arg("--config-base64") + .arg(&b64) + .arg("--experimental") + .output(); + } +} + +impl Drop for DeprovisionGuard<'_> { + fn drop(&mut self) { + if let Some(id) = self.sandbox_id.take() { + Self::run_deprovision(self.wxc, &id); + } + } +} + +// ── Processcontainer enforcement tests ─────────────────────────────────────── + +/// Write a file inside the granted temp dir; assert the file appears and the +/// exit code is 0. Requires the processcontainer backend to be live. +#[test] +#[ignore = "requires real wxc-exec"] +fn pc_oneshot_in_policy_write_succeeds() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + if let Err(reason) = probe_processcontainer(&wxc) { + eprintln!("SKIP: processcontainer not live: {reason}"); + return; + } + + let tmpdir = tempfile::tempdir().expect("tempdir"); + let target = tmpdir.path().join("pc-in-policy.txt"); + let target_str = target.to_string_lossy().into_owned(); + let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "pc-in-policy-write", + "containment": "processcontainer", + "process": { + "commandLine": format!("cmd /c echo hello > \"{target_str}\""), + "cwd": tmpdir_str, + "timeout": 30, + }, + "filesystem": { + "readwritePaths": [tmpdir_str], + }, + "processContainer": { + "leastPrivilege": false, + }, + }); + + let json = serde_json::to_string(&config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let out = Command::new(&wxc) + .arg("--config-base64") + .arg(&b64) + .output() + .expect("wxc-exec spawn"); + + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + let code = out.status.code().unwrap_or(-1); + + assert_eq!( + code, 0, + "in-policy write should exit 0\nstdout={stdout}\nstderr={stderr}" + ); + assert!( + target.exists(), + "in-policy write: file should exist at {target_str}\nstdout={stdout}\nstderr={stderr}" + ); +} + +/// Write to a path OUTSIDE the granted dir; assert exit non-zero and file absent. +/// This is the genuine OS default-deny proof — the `AppContainer` blocks the write +/// without requiring any host ACL lockdown. The mock can only fake this. +#[test] +#[ignore = "requires real wxc-exec"] +fn pc_oneshot_out_of_policy_write_denied() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + if let Err(reason) = probe_processcontainer(&wxc) { + eprintln!("SKIP: processcontainer not live: {reason}"); + return; + } + + let granted_dir = tempfile::tempdir().expect("granted tempdir"); + let denied_dir = tempfile::tempdir().expect("denied tempdir"); + let denied_file = denied_dir.path().join("pc-out-of-policy.txt"); + let denied_file_str = denied_file.to_string_lossy().into_owned(); + let granted_str = granted_dir.path().to_string_lossy().into_owned(); + + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "pc-out-of-policy-write", + "containment": "processcontainer", + "process": { + "commandLine": format!("cmd /c echo denied > \"{denied_file_str}\""), + "cwd": granted_str, + "timeout": 30, + }, + "filesystem": { + // Only the granted_dir is in policy — denied_dir is NOT granted. + "readwritePaths": [granted_str], + }, + "processContainer": { + "leastPrivilege": false, + }, + }); + + let json = serde_json::to_string(&config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let out = Command::new(&wxc) + .arg("--config-base64") + .arg(&b64) + .output() + .expect("wxc-exec spawn"); + + let code = out.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + + assert_ne!( + code, 0, + "out-of-policy write should be denied (non-zero exit)\nstdout={stdout}\nstderr={stderr}" + ); + assert!( + !denied_file.exists(), + "out-of-policy file must be absent at {denied_file_str} (OS default-deny proof)\n\ + stdout={stdout}\nstderr={stderr}" + ); +} + +// ── Isolation session enforcement tests ────────────────────────────────────── + +/// Full `isolation_session` round trip: provision → start → exec → stop → +/// deprovision. `deprovision` runs in a drop-guard even on panic so the +/// single-session backend is never left orphaned. +#[test] +#[ignore = "requires real wxc-exec"] +fn iso_lifecycle_round_trip() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let sandbox_id = match probe_isolation_session(&wxc) { + Ok(id) => id, + Err(reason) => { + eprintln!("SKIP: isolation_session not live: {reason}"); + return; + } + }; + + // Guard ensures deprovision even on panic. + let mut guard = DeprovisionGuard::new(&wxc, sandbox_id.clone()); + + // start + let start_config = serde_json::json!({ + "version": "0.6.0-alpha", + "phase": "start", + "sandboxId": sandbox_id, + "experimental": { + "isolation_session": { + "start": {} + } + } + }); + let json = serde_json::to_string(&start_config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + let out = Command::new(&wxc) + .arg("--config-base64") + .arg(&b64) + .arg("--experimental") + .output() + .expect("start"); + assert!( + out.status.success(), + "start failed: {}", + String::from_utf8_lossy(&out.stdout) + ); + + // exec. timeout is MILLISECONDS; 0 = no timeout. Empirical (test box, + // build 26300.8553, wxc-exec 2026-06-10): a small positive value (30) is + // rejected by RunProcessWithOptionsAsync with "Invalid timeout value" + // (HRESULT 0x80070057). 0 is the documented no-timeout value and matches + // what the driver's exec path sends by default (MxcProcess.timeout = 0). + let exec_config = serde_json::json!({ + "version": "0.6.0-alpha", + "phase": "exec", + "sandboxId": sandbox_id, + "process": { + "commandLine": "cmd /c exit 0", + "cwd": "C:\\Windows\\Temp", + "env": [], + "timeout": 0, + } + }); + let json = serde_json::to_string(&exec_config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + let out = Command::new(&wxc) + .arg("--config-base64") + .arg(&b64) + .arg("--experimental") + .output() + .expect("exec"); + assert_eq!( + out.status.code().unwrap_or(-1), + 0, + "exec phase should exit 0: {}", + String::from_utf8_lossy(&out.stdout) + ); + + // stop + let stop_config = serde_json::json!({ + "version": "0.6.0-alpha", + "phase": "stop", + "sandboxId": sandbox_id, + "experimental": { + "isolation_session": { + // Unit variant: must be null, not {} (malformed_request otherwise). + "stop": null + } + } + }); + let json = serde_json::to_string(&stop_config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + let out = Command::new(&wxc) + .arg("--config-base64") + .arg(&b64) + .arg("--experimental") + .output() + .expect("stop"); + assert!( + out.status.success(), + "stop failed: {}", + String::from_utf8_lossy(&out.stdout) + ); + + // deprovision (also disarms the guard so Drop does not double-deprovision) + guard.deprovision_now(); + guard.disarm(); +} diff --git a/crates/openshell-extension-core/src/transport.rs b/crates/openshell-extension-core/src/transport.rs index 2f9a633237..4552faf70a 100644 --- a/crates/openshell-extension-core/src/transport.rs +++ b/crates/openshell-extension-core/src/transport.rs @@ -225,13 +225,14 @@ mod tests { #[test] fn accepts_supported_endpoint_forms() { - for endpoint in [ - "http://127.0.0.1:50051", - "https://middleware.example:443", - "unix:///run/openshell/middleware.sock", - ] { + for endpoint in ["http://127.0.0.1:50051", "https://middleware.example:443"] { validate_config(&ExtensionChannelConfig::new(endpoint)).unwrap(); } + #[cfg(unix)] + validate_config(&ExtensionChannelConfig::new( + "unix:///run/openshell/middleware.sock", + )) + .unwrap(); } #[test] diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 9bcf18baca..ae35fc0fbf 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -120,6 +120,11 @@ openshell-driver-docker = { path = "../openshell-driver-docker", optional = true openshell-driver-kubernetes = { path = "../openshell-driver-kubernetes", optional = true } openshell-driver-podman = { path = "../openshell-driver-podman", optional = true } +# MXC is the Windows-only in-process compute backend (openshell-driver-mxc is a +# no-op stub on other targets). It is only linked into the gateway on Windows. +[target.'cfg(target_os = "windows")'.dependencies] +openshell-driver-mxc = { path = "../openshell-driver-mxc" } + [features] default = ["telemetry", "in-tree-compute-drivers"] ## Link the first-party compute drivers into the standard gateway binary. diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index d014623dce..1ca68895f0 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -796,7 +796,12 @@ fn normalize_compute_driver_socket_args(args: &mut RunArgs, matches: &ArgMatches fn is_singleplayer_driver(driver: Option) -> bool { matches!( driver, - Some(ComputeDriverKind::Docker | ComputeDriverKind::Podman | ComputeDriverKind::Vm) + Some( + ComputeDriverKind::Docker + | ComputeDriverKind::Podman + | ComputeDriverKind::Vm + | ComputeDriverKind::Mxc + ) ) } @@ -1667,6 +1672,7 @@ ssh_session_ttl_secs = 1234 openshell_core::ComputeDriverKind::Docker, openshell_core::ComputeDriverKind::Podman, openshell_core::ComputeDriverKind::Vm, + openshell_core::ComputeDriverKind::Mxc, ] { assert!( super::is_singleplayer_driver(Some(driver)), diff --git a/crates/openshell-server/src/compute/driver_config.rs b/crates/openshell-server/src/compute/driver_config.rs index fad71efd36..daf25af4c6 100644 --- a/crates/openshell-server/src/compute/driver_config.rs +++ b/crates/openshell-server/src/compute/driver_config.rs @@ -12,7 +12,11 @@ pub mod builtin; use crate::config_file; use crate::defaults::LocalTlsPaths; +#[cfg(target_os = "windows")] +use openshell_core::ComputeDriverKind; use openshell_core::{Error, Result}; +#[cfg(target_os = "windows")] +use openshell_driver_mxc::MxcComputeConfig; use serde::Deserialize; use std::collections::BTreeMap; use std::path::PathBuf; @@ -49,6 +53,15 @@ pub struct DriverStartupContext<'a> { pub endpoint_overrides: &'a BTreeMap, } +/// Build the selected MXC config from TOML. MXC is Windows-only and has no +/// runtime-default overlay; the driver reads its own settings from the config. +/// The Linux built-in driver configs now live in the `builtin` submodule +/// (compiled only off Windows). +#[cfg(target_os = "windows")] +pub fn mxc_config_from_context(context: DriverStartupContext<'_>) -> Result { + driver_config_from_context(context, ComputeDriverKind::Mxc.as_str()) +} + pub fn remote_driver_config_from_context( context: DriverStartupContext<'_>, name: &str, diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 65411d28f1..b85641c986 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -31,6 +31,8 @@ use futures::{Stream, StreamExt}; #[cfg(unix)] use hyper_util::rt::TokioIo; use openshell_core::ComputeDriverKind; +#[cfg(target_os = "windows")] +use openshell_core::proto::SandboxPolicy; use openshell_core::proto::compute::v1::{ CreateSandboxRequest, DeleteSandboxRequest, DeleteWorkspaceRequest, DeleteWorkspaceResponse, DriverCondition, DriverPlatformEvent, DriverResourceRequirements, DriverSandbox, @@ -56,6 +58,8 @@ use openshell_driver_kubernetes::{ ComputeDriverService as KubernetesDriverService, KubernetesComputeDriver, OperatorNamespaceAllowlist, }; +#[cfg(target_os = "windows")] +use openshell_driver_mxc::{ComputeDriverService as MxcDriverService, MxcComputeConfig}; #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] use openshell_driver_podman::{ComputeDriverService as PodmanDriverService, PodmanComputeDriver}; use prost::Message; @@ -568,6 +572,14 @@ pub struct ComputeRuntime { lifecycle_gates: Arc, gateway_listener_requirements: Vec, replica_id: String, + /// A1 policy side channel for the in-process MXC driver. `create_sandbox` + /// stages the typed `SandboxPolicy` here by sandbox id immediately before + /// dispatching to the driver, which consumes it. `None` for all other + /// drivers. The proto driver contract has no `policy` field and there is no + /// driver-side `GetSandboxConfig`, so this in-process map is how the policy + /// reaches the MXC backend without changing the cross-process contract. + #[cfg(target_os = "windows")] + mxc_policy_sink: Option>>>, } impl fmt::Debug for ComputeRuntime { @@ -686,6 +698,8 @@ impl ComputeRuntime { lifecycle_gates: Arc::new(LifecycleGateRegistry::default()), gateway_listener_requirements, replica_id: lease::replica_id(), + #[cfg(target_os = "windows")] + mxc_policy_sink: None, }) } @@ -818,6 +832,38 @@ impl ComputeRuntime { .await } + /// Construct a `ComputeRuntime` backed by the MXC compute driver. + /// + /// MXC is Windows-only, in-process, and self-reports `Ready` — there is + /// no supervisor session argument because no surrogate or relay is used. + #[cfg(target_os = "windows")] + pub async fn new_mxc( + mxc_config: MxcComputeConfig, + store: Arc, + sandbox_index: SandboxIndex, + sandbox_watch_bus: SandboxWatchBus, + tracing_log_bus: TracingLogBus, + supervisor_sessions: Arc, + ) -> Result { + let backend = openshell_driver_mxc::MxcComputeBackend::new(mxc_config); + // Grab the A1 policy side channel before moving `backend` into the service. + let sink = backend.policy_sink(); + let service: SharedComputeDriver = Arc::new(MxcDriverService::new(backend)); + let mut runtime = Self::from_driver( + ComputeDriverKind::Mxc.as_str().to_string(), + service, + None, + store, + sandbox_index, + sandbox_watch_bus, + tracing_log_bus, + supervisor_sessions, + ) + .await?; + runtime.mxc_policy_sink = Some(sink); + Ok(runtime) + } + #[must_use] pub fn default_image(&self) -> &str { &self.default_image @@ -943,6 +989,16 @@ impl ComputeRuntime { { spec.sandbox_token = token; } + // A1: stage the typed SandboxPolicy out-of-band into the MXC backend's + // side channel, keyed by sandbox id (== DriverSandbox.id), immediately + // before dispatch. The driver removes/consumes it in create_sandbox. The + // proto driver contract has no policy field, so this is the only path. + #[cfg(target_os = "windows")] + if let Some(sink) = &self.mxc_policy_sink + && let Some(p) = sandbox.spec.as_ref().and_then(|s| s.policy.clone()) + { + sink.lock().await.insert(sandbox_id.clone(), p); + } match self .driver .call( @@ -4258,6 +4314,8 @@ pub async fn new_test_runtime_with_driver( lifecycle_gates: Arc::new(LifecycleGateRegistry::default()), gateway_listener_requirements: Vec::new(), replica_id: "test-replica".to_string(), + #[cfg(target_os = "windows")] + mxc_policy_sink: None, } } @@ -4942,6 +5000,8 @@ mod tests { lifecycle_gates: Arc::new(LifecycleGateRegistry::default()), gateway_listener_requirements: Vec::new(), replica_id: "test-replica".to_string(), + #[cfg(target_os = "windows")] + mxc_policy_sink: None, } } diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index 00b7a2f64d..f960a45d8d 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -483,7 +483,9 @@ fn inheritable_keys(driver_name: &str) -> &'static [&'static str] { "guest_tls_cert", "guest_tls_key", ], - None => &[], + // MXC reads its own settings from the driver config table and has no + // gateway-inherited required fields. + Some(ComputeDriverKind::Mxc) | None => &[], } } @@ -712,7 +714,7 @@ allow_unauthenticated_users = true [[openshell.supervisor.middleware]] name = "local-guard" grpc_endpoint = "https://127.0.0.1:50051" -tls_ca_cert_path = "CA_PATH" +tls_ca_cert_path = 'CA_PATH' audience = "urn:openshell:middleware:local-guard" max_payload_bytes = 262144 timeout = "2s" @@ -736,10 +738,11 @@ timeout = "2s" SupervisorMiddlewareService::try_from(&file.openshell.supervisor.middleware[0]) .expect("valid CA resolves"); assert_eq!(registration.timeout, "2s"); - assert_eq!( - registration.tls_ca_cert_pem, - certificate.cert.pem().as_bytes() - ); + let registered_pem = String::from_utf8(registration.tls_ca_cert_pem) + .expect("registered CA remains PEM text") + .replace("\r\n", "\n"); + let generated_pem = certificate.cert.pem().replace("\r\n", "\n"); + assert_eq!(registered_pem, generated_pem); assert_eq!( registration.audience, "urn:openshell:middleware:local-guard" diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 114b43aba0..0bda93a15f 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -3150,6 +3150,20 @@ pub(super) async fn handle_get_sandbox_provider_environment( // Update config handler (policy + settings mutations) // --------------------------------------------------------------------------- +fn validate_live_policy_update_support( + driver_kind: Option, + has_policy: bool, + has_merge_ops: bool, +) -> Result<(), Status> { + if (has_policy || has_merge_ops) && driver_kind == Some(openshell_core::ComputeDriverKind::Mxc) + { + return Err(Status::failed_precondition( + "live policy updates are not supported for MXC sandboxes; recreate the sandbox so the new policy is mapped before launch", + )); + } + Ok(()) +} + pub(super) async fn handle_update_config( state: &Arc, request: Request, @@ -3224,6 +3238,7 @@ async fn handle_update_config_inner( "one of policy, setting_key, or merge_operations must be provided", )); } + validate_live_policy_update_support(state.compute.driver_kind(), has_policy, has_merge_ops)?; if req.global { if !req.annotations.is_empty() { return Err(Status::invalid_argument( @@ -6935,6 +6950,27 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use tonic::Code; + #[test] + fn mxc_rejects_sandbox_policy_replacement_and_merge_updates() { + for (has_policy, has_merge_ops) in [(true, false), (false, true)] { + let error = validate_live_policy_update_support( + Some(openshell_core::ComputeDriverKind::Mxc), + has_policy, + has_merge_ops, + ) + .expect_err("MXC must reject policy mutations after launch"); + assert_eq!(error.code(), Code::FailedPrecondition); + } + + let error = validate_live_policy_update_support( + Some(openshell_core::ComputeDriverKind::Mxc), + true, + false, + ) + .expect_err("global policy replacement also changes desired state for live MXC sandboxes"); + assert_eq!(error.code(), Code::FailedPrecondition); + } + /// Wrap a request with a user `Principal` so handler scope guards treat /// the test caller as a CLI user. Most handler tests exercise /// user-facing behavior and should not trip sandbox equality checks. diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 2667611bcc..cad48ebc3c 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -1324,18 +1324,26 @@ pub fn install_default_compute_drivers() -> ComputeDriverRegistry { .expect("unique vm registration"); } #[cfg(all(target_os = "windows", feature = "in-tree-compute-drivers"))] - for name in ["kubernetes", "podman", "docker", "vm"] { + { registry .install( - ComputeDriverRegistration::new( - name, - u16::MAX, - None, - UnsupportedComputeDriverFactory, - ) - .expect("valid unsupported registration"), + ComputeDriverRegistration::new("mxc", u16::MAX, None, MxcComputeDriverFactory) + .expect("valid mxc registration"), ) - .expect("unique unsupported registration"); + .expect("unique mxc registration"); + for name in ["kubernetes", "podman", "docker", "vm"] { + registry + .install( + ComputeDriverRegistration::new( + name, + u16::MAX, + None, + UnsupportedComputeDriverFactory, + ) + .expect("valid unsupported registration"), + ) + .expect("unique unsupported registration"); + } } registry } @@ -1418,6 +1426,35 @@ impl ComputeDriverBuildContext<'_> { } } +#[cfg(all(target_os = "windows", feature = "in-tree-compute-drivers"))] +#[derive(Clone, Copy)] +struct MxcComputeDriverFactory; + +#[cfg(all(target_os = "windows", feature = "in-tree-compute-drivers"))] +#[async_trait::async_trait] +impl ComputeDriverFactory for MxcComputeDriverFactory { + async fn build( + &self, + context: ComputeDriverBuildContext<'_>, + ) -> Result { + let mxc_config = compute::driver_config::mxc_config_from_context(context.driver_startup)?; + let runtime = ComputeRuntime::new_mxc( + mxc_config, + context.store, + context.sandbox_index, + context.sandbox_watch_bus, + context.tracing_log_bus, + context.supervisor_sessions, + ) + .await + .map_err(|error| Error::execution(format!("failed to create compute runtime: {error}")))?; + Ok(ComputeDriverBuildOutput { + runtime, + operator_allowlist: None, + }) + } +} + #[cfg(all(target_os = "windows", feature = "in-tree-compute-drivers"))] #[derive(Clone, Copy)] struct UnsupportedComputeDriverFactory; diff --git a/crates/openshell-server/src/tracing_setup.rs b/crates/openshell-server/src/tracing_setup.rs index c5b8d9f4a3..968a1c6a05 100644 --- a/crates/openshell-server/src/tracing_setup.rs +++ b/crates/openshell-server/src/tracing_setup.rs @@ -38,16 +38,16 @@ impl TracingHandle { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum InProcessDriverTracing { - #[cfg(feature = "in-tree-compute-drivers")] + #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] Docker, - #[cfg(feature = "in-tree-compute-drivers")] + #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] Kubernetes, - #[cfg(feature = "in-tree-compute-drivers")] + #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] Podman, } impl InProcessDriverTracing { - #[cfg(feature = "in-tree-compute-drivers")] + #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] fn target_prefix(self) -> &'static str { match self { Self::Docker => openshell_driver_docker::otel_tracing::IN_PROCESS_TARGET_PREFIX, @@ -58,7 +58,7 @@ impl InProcessDriverTracing { } fn in_process_driver_tracing(driver: &ConfiguredComputeDriver) -> Option { - #[cfg(feature = "in-tree-compute-drivers")] + #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] match driver { ConfiguredComputeDriver::Registered(registration) if registration.name == "docker" => { Some(InProcessDriverTracing::Docker) @@ -71,7 +71,7 @@ fn in_process_driver_tracing(driver: &ConfiguredComputeDriver) -> Option None, } - #[cfg(not(feature = "in-tree-compute-drivers"))] + #[cfg(not(all(not(target_os = "windows"), feature = "in-tree-compute-drivers")))] { let _ = driver; None @@ -79,18 +79,18 @@ fn in_process_driver_tracing(driver: &ConfiguredComputeDriver) -> Option) -> Option<&'static str> { - #[cfg(feature = "in-tree-compute-drivers")] + #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] { driver.map(InProcessDriverTracing::target_prefix) } - #[cfg(not(feature = "in-tree-compute-drivers"))] + #[cfg(not(all(not(target_os = "windows"), feature = "in-tree-compute-drivers")))] { let _ = driver; None } } -#[cfg(feature = "in-tree-compute-drivers")] +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] fn in_process_driver_provider( driver: Option, endpoint: Option<&str>, @@ -109,7 +109,7 @@ fn in_process_driver_provider( } } -#[cfg(not(feature = "in-tree-compute-drivers"))] +#[cfg(not(all(not(target_os = "windows"), feature = "in-tree-compute-drivers")))] fn in_process_driver_provider( _driver: Option, _endpoint: Option<&str>, @@ -117,7 +117,7 @@ fn in_process_driver_provider( (None, None) } -#[cfg(feature = "in-tree-compute-drivers")] +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] fn in_process_driver_layer( provider: &Option, driver: Option, @@ -139,7 +139,7 @@ where }) } -#[cfg(not(feature = "in-tree-compute-drivers"))] +#[cfg(not(all(not(target_os = "windows"), feature = "in-tree-compute-drivers")))] fn in_process_driver_layer( _provider: &Option, _driver: Option, diff --git a/tasks/windows.toml b/tasks/windows.toml index c7a2e51f99..59c2a8f2c2 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -63,3 +63,18 @@ run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts description = "Run Windows MSVC checks, release builds, x64 tests, and unsupported-driver contract tests" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 ci all" + +["windows:test:mxc-real:x64"] +description = "Run real-wxc-exec Tier-2 integration tests (skip-safe: tests print SKIP when binary/backend absent)" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "cargo test -p openshell-driver-mxc --test wxc_exec_real --target x86_64-pc-windows-msvc -- --ignored --test-threads=1" + +["windows:e2e:mxc"] +description = "Run MXC Tier-3 e2e scenario runner against real wxc-exec (probe-gated; skip-safe on hosts without the binary)" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1" + +["windows:e2e:mxc:mock"] +description = "Run MXC Tier-3 e2e scenario runner in mock/wiring-only mode (no real wxc-exec required)" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 -Mock" From 9f88f8ff9be41a2a815acbbf2756f7b9979fa159 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Thu, 27 Aug 2026 08:48:44 +0000 Subject: [PATCH 031/505] ci: remove rootless podman e2e lane (#2981) Signed-off-by: Evan Lezar --- .github/workflows/e2e-test.yml | 182 -------------------------------- CI.md | 2 +- e2e/rust/e2e-podman-rootless.sh | 19 ---- tasks/test.toml | 4 - 4 files changed, 1 insertion(+), 206 deletions(-) delete mode 100755 e2e/rust/e2e-podman-rootless.sh diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index f176953048..d3e4230636 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -129,188 +129,6 @@ jobs: OPENSHELL_MCP_CONFORMANCE_CLIENT_IMAGE: ${{ format('openshell-mcp-conformance-client:{0}', inputs.image-tag) }} run: ${{ matrix.cmd }} - e2e-podman-rootless: - name: E2E (rust-podman-${{ matrix.suite }}, ${{ matrix.runner }}) - # Run directly on the Ubuntu host so the test observes the host's AppArmor - # and unprivileged-user-namespace policy. A privileged job container masks - # the restrictions that production rootless Podman installations enforce. - # Ubuntu 26.04 provides the supported Podman 5.x and pasta combination. - # Re-add older/slirp4netns environments when direct callbacks through a - # rootless-network namespace relay are supported. - runs-on: ${{ matrix.runner }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - # Keep package versions explicit so hosted-runner tool overrides - # cannot silently change the supported test environment. - - suite: rootless - 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:rootless" - - 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: "env -u OPENSHELL_GATEWAY_BIN mise run --no-deps --skip-deps e2e:podman:external-driver" - - suite: provider-refresh-keycloak - 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:provider-refresh-keycloak" - env: - IMAGE_TAG: ${{ inputs.image-tag }} - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENSHELL_REGISTRY: ghcr.io/nvidia/openshell - OPENSHELL_REGISTRY_HOST: ghcr.io - OPENSHELL_REGISTRY_NAMESPACE: nvidia/openshell - OPENSHELL_REGISTRY_USERNAME: ${{ github.actor }} - OPENSHELL_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} - OPENSHELL_SUPERVISOR_IMAGE: ${{ format('ghcr.io/nvidia/openshell/supervisor:{0}', inputs.image-tag) }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs['checkout-ref'] || github.sha }} - persist-credentials: false - - - name: Use prebuilt OpenShell CLI - if: inputs.cli-artifact-prefix != '' - uses: ./.github/actions/setup-e2e-cli - with: - artifact-prefix: ${{ inputs.cli-artifact-prefix }} - - - name: Use prebuilt OpenShell gateway - if: inputs.gateway-artifact-prefix != '' - uses: ./.github/actions/setup-e2e-gateway - with: - artifact-prefix: ${{ inputs.gateway-artifact-prefix }} - - - name: Install mise - run: | - curl https://mise.run | MISE_VERSION=v2026.4.25 sh - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - echo "$HOME/.local/share/mise/shims" >> "$GITHUB_PATH" - - - name: Install tools - run: mise install --locked - - - name: Install Podman and build dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - apparmor \ - build-essential \ - clang \ - fuse-overlayfs \ - libssl-dev \ - libz3-dev \ - openssh-client \ - passt \ - pkg-config \ - "conmon=${{ matrix.conmon_package_version }}" \ - "podman=${{ matrix.podman_package_version }}" \ - uidmap - # Hosted runners can place newer Podman and conmon binaries under - # /usr/local ahead of Ubuntu's packages. Select the distro CLI and - # use Podman's supported final config override for its conmon path. - podman_config="${RUNNER_TEMP}/openshell-containers.conf" - printf '%s\n' \ - '[engine]' \ - 'conmon_path = ["/usr/bin/conmon"]' \ - > "${podman_config}" - echo "/usr/bin" >> "${GITHUB_PATH}" - echo "CONTAINERS_CONF_OVERRIDE=${podman_config}" >> "${GITHUB_ENV}" - - - name: Allow pasta to receive Podman stop signals - # Ubuntu's packaged pasta profile currently blocks this signal, forcing - # Podman to wait for its SIGKILL fallback. Keep this narrow allowance - # until the distribution package includes the upstream profile fix. - run: | - set -euo pipefail - profile=/etc/apparmor.d/usr.bin.pasta - rule=' signal (receive) peer=podman,' - if ! sudo grep -Fqx "${rule}" "${profile}"; then - sudo sed -i '\|^ include $|a\ signal (receive) peer=podman,' "${profile}" - fi - sudo grep -Fqx "${rule}" "${profile}" - sudo apparmor_parser --replace "${profile}" - - - name: Configure rootless Podman - run: | - set -euo pipefail - if ! grep -q "^${USER}:" /etc/subuid; then - sudo usermod --add-subuids 100000-165535 "$USER" - fi - if ! grep -q "^${USER}:" /etc/subgid; then - sudo usermod --add-subgids 100000-165535 "$USER" - fi - runtime_dir="/run/user/$(id -u)" - sudo install -d -m 0700 -o "$(id -u)" -g "$(id -g)" "$runtime_dir" - echo "XDG_RUNTIME_DIR=$runtime_dir" >> "$GITHUB_ENV" - - - name: Verify rootless Podman environment - run: | - set -euo pipefail - podman_version="$(podman version --format '{{.Client.Version}}')" - case "$podman_version" in - "${{ matrix.podman_major }}".*) ;; - *) echo "ERROR: expected Podman ${{ matrix.podman_major }}.x, found $podman_version" >&2; exit 1 ;; - esac - test "$(dpkg-query -W -f='${Version}' podman)" = "${{ matrix.podman_package_version }}" - test "$(dpkg-query -W -f='${Version}' conmon)" = "${{ matrix.conmon_package_version }}" - test "$(command -v podman)" = "/usr/bin/podman" - test "$(podman info --format '{{.Host.Conmon.Path}}')" = "/usr/bin/conmon" - test "$(podman info --format '{{.Host.Security.Rootless}}')" = "true" - test "$(podman info --format '{{.Host.RootlessNetworkCmd}}')" = "pasta" - test "$(sudo sysctl -n kernel.apparmor_restrict_unprivileged_userns)" = "1" - echo "=== host ===" - uname -a - echo "=== AppArmor ===" - cat /proc/self/attr/current - sudo aa-status || true - echo "=== Podman ===" - podman version - podman info --debug - - - name: Probe rootless capability bounding set - run: | - set -euo pipefail - probe="$RUNNER_TEMP/openshell-capbset-probe" - cc -static -O2 -Wall -Wextra -Werror \ - e2e/support/capbset-probe.c \ - -o "$probe" - podman run --rm \ - --cap-add=SETPCAP \ - --volume "$probe:/openshell-capbset-probe:ro" \ - docker.io/library/alpine:3.22 \ - /openshell-capbset-probe - - - name: Log in to GHCR with Podman - run: echo "${{ secrets.GITHUB_TOKEN }}" | podman login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Run Podman E2E - run: ${{ matrix.cmd }} - - - name: Print AppArmor denials - if: always() - run: sudo dmesg | grep -E 'apparmor=.*DENIED|profile="unprivileged_userns"' | tail -100 || true - - - name: Fail on pasta SIGTERM AppArmor denial - if: always() - run: | - set -euo pipefail - denials="$(sudo dmesg | grep -E 'profile="pasta".*requested_mask="receive".*signal=term.*peer="podman"' || true)" - if [ -n "${denials}" ]; then - echo "::error::pasta denied Podman's SIGTERM; Podman will use its SIGKILL fallback" - printf '%s\n' "${denials}" - exit 1 - fi - e2e-vm: name: E2E (rust-vm-${{ matrix.suite }}) # libkrun needs KVM, so this job must run directly on a GitHub-hosted diff --git a/CI.md b/CI.md index aae22f4c4f..9dd919d1bd 100644 --- a/CI.md +++ b/CI.md @@ -14,7 +14,7 @@ Merge queue validation is a second integration gate for `main`. After a PR has p Three opt-in labels enable the long-running E2E suites: -- `test:e2e` runs the standard Docker, rootless Podman, Kubernetes, and VM E2E +- `test:e2e` runs the standard Docker, Kubernetes, and VM E2E suites in `Branch E2E Checks` - `test:e2e-gpu` runs GPU E2E in `Branch E2E Checks` - `test:e2e-kubernetes` runs Kubernetes E2E with the HA Helm overlay diff --git a/e2e/rust/e2e-podman-rootless.sh b/e2e/rust/e2e-podman-rootless.sh deleted file mode 100755 index d7fb5acd3c..0000000000 --- a/e2e/rust/e2e-podman-rootless.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -# Run the Podman e2e suite and verify rootless mode. -# -# Identical to e2e-podman.sh but fails fast if Podman is not running -# rootless. Use this to explicitly validate the rootless networking -# path (pasta, host-gateway, bind address). - -set -euo pipefail - -rootless="$(podman info --format '{{.Host.Security.Rootless}}' 2>/dev/null || true)" -if [ "${rootless}" != "true" ]; then - echo "ERROR: podman is not running rootless; expected true, got '${rootless:-}'" >&2 - exit 2 -fi - -exec "$(dirname "${BASH_SOURCE[0]}")/e2e-podman.sh" diff --git a/tasks/test.toml b/tasks/test.toml index dfb632dda0..29e06b826a 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -136,10 +136,6 @@ run = [ "CONTAINER_RUNTIME=docker e2e/with-keycloak.sh env OPENSHELL_E2E_OIDC_GATEWAY=1 e2e/with-docker-gateway.sh uv run pytest -m 'not gpu' e2e/python/oidc", ] -["e2e:podman:rootless"] -description = "Run Rust CLI e2e tests against a rootless Podman-backed gateway" -run = "e2e/rust/e2e-podman-rootless.sh" - ["e2e:podman:gpu"] description = "Run GPU e2e against a standalone gateway with the Podman compute driver" env = { OPENSHELL_E2E_PODMAN_GPU = "1", OPENSHELL_E2E_PODMAN_TEST = "gpu", OPENSHELL_E2E_PODMAN_FEATURES = "e2e-podman-gpu" } From 5544715a2540ba645996c0812b82e55de3a08ced Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Thu, 27 Aug 2026 12:07:15 +0000 Subject: [PATCH 032/505] ci(stale): increase stale workflow processing budget (#2979) Signed-off-by: Evan Lezar --- .github/workflows/stale.yml | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index bfdc13af8f..e45ccbf5eb 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -9,21 +9,22 @@ permissions: contents: read jobs: - stale: + stale-issues: runs-on: ubuntu-latest permissions: issues: write - pull-requests: write steps: - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 with: stale-issue-label: state:stale - stale-pr-label: state:stale days-before-issue-stale: 14 days-before-issue-close: -1 # -1 puts this into dry-run mode. Update to 7 to enable closing. - days-before-pr-stale: 14 - days-before-pr-close: -1 # -1 puts this into dry-run mode. Update to 7 to enable closing. + days-before-pr-stale: -1 + days-before-pr-close: -1 + + operations-per-run: 300 + sort-by: updated exempt-issue-labels: state:triage-needed,state:validated,state:accepted,agent:plan-requested,agent:plan-ready,agent:implementation-requested,agent:in-progress,agent:pr-opened,roadmap close-issue-reason: not_planned @@ -35,6 +36,24 @@ jobs: close-issue-message: > Closing this issue after 7 days with no activity since it was marked stale. Reopen it if the work is still relevant. + stale-pull-requests: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 + with: + stale-pr-label: state:stale + + days-before-issue-stale: -1 + days-before-issue-close: -1 + days-before-pr-stale: 14 + days-before-pr-close: -1 # -1 puts this into dry-run mode. Update to 7 to enable closing. + + operations-per-run: 300 + sort-by: updated + stale-pr-message: > This pull request has had no activity for 14 days and is now marked stale. It may be closed in 7 days if there is no further activity. From 5f90c8579c1f708bd59612385e3dc2174fefc0e6 Mon Sep 17 00:00:00 2001 From: alangou Date: Thu, 27 Aug 2026 13:32:16 +0000 Subject: [PATCH 033/505] ci(security): add informational security checks (#2930) Signed-off-by: Adrien Langou --- .github/actionlint-matcher.json | 17 +++ .github/actionlint-sarif-template.txt | 63 ++++++++ .github/actionlint.yml | 18 +++ .github/actions/setup-nix/action.yml | 24 +++ .github/codeql/codeql-config.yml | 15 ++ .github/workflows/codeql.yml | 128 ++++++++++++++++ .github/workflows/dependency-review.yml | 98 +++++++++++++ .github/workflows/workflow-security.yml | 185 ++++++++++++++++++++++++ .github/zizmor.yml | 13 ++ CI.md | 42 ++++++ architecture/build.md | 39 +++++ flake.nix | 2 + 12 files changed, 644 insertions(+) create mode 100644 .github/actionlint-matcher.json create mode 100644 .github/actionlint-sarif-template.txt create mode 100644 .github/actionlint.yml create mode 100644 .github/actions/setup-nix/action.yml create mode 100644 .github/codeql/codeql-config.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/dependency-review.yml create mode 100644 .github/workflows/workflow-security.yml create mode 100644 .github/zizmor.yml diff --git a/.github/actionlint-matcher.json b/.github/actionlint-matcher.json new file mode 100644 index 0000000000..4613e1617b --- /dev/null +++ b/.github/actionlint-matcher.json @@ -0,0 +1,17 @@ +{ + "problemMatcher": [ + { + "owner": "actionlint", + "pattern": [ + { + "regexp": "^(?:\\x1b\\[\\d+m)?(.+?)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*: (?:\\x1b\\[\\d+m)*(.+?)(?:\\x1b\\[\\d+m)* \\[(.+?)\\]$", + "file": 1, + "line": 2, + "column": 3, + "message": 4, + "code": 5 + } + ] + } + ] +} diff --git a/.github/actionlint-sarif-template.txt b/.github/actionlint-sarif-template.txt new file mode 100644 index 0000000000..01c589e218 --- /dev/null +++ b/.github/actionlint-sarif-template.txt @@ -0,0 +1,63 @@ +{ + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Actionlint", + "version": {{ getVersion | json }}, + "informationUri": "https://github.com/rhysd/actionlint", + "rules": [ + {{$first := true}} + {{range $ := allKinds}} + {{if $first}}{{$first = false}}{{else}},{{end}} + { + "id": {{json $.Name}}, + "name": {{$.Name | toPascalCase | json}}, + "defaultConfiguration": { + "level": "warning" + }, + "fullDescription": { + "text": {{json $.Description}} + }, + "helpUri": "https://github.com/rhysd/actionlint/blob/v1.7.12/docs/checks.md" + } + {{end}} + ] + } + }, + "results": [ + {{$first := true}} + {{range $ := .}} + {{if $first}}{{$first = false}}{{else}},{{end}} + { + "ruleId": {{json $.Kind}}, + "level": "warning", + "message": { + "text": {{json $.Message}} + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": {{json $.Filepath}}, + "uriBaseId": "%SRCROOT%" + }, + "region": { + "startLine": {{$.Line}}, + "startColumn": {{$.Column}}, + "endColumn": {{$.EndColumn}}, + "snippet": { + "text": {{json $.Snippet}} + } + } + } + } + ] + } + {{end}} + ] + } + ] +} diff --git a/.github/actionlint.yml b/.github/actionlint.yml new file mode 100644 index 0000000000..35aa72ea78 --- /dev/null +++ b/.github/actionlint.yml @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +self-hosted-runner: + labels: + - linux-amd64-cpu8 + - linux-amd64-gpu-rtxpro6000-latest-1 + - linux-arm64-cpu8 + - linux-arm64-gpu-l4-latest-1 + - nv + - ubuntu-26.04 + - windows-arm64 + - wsl-amd64-gpu-rtxpro6000-latest-1 + +paths: + .github/workflows/windows-msvc.yml: + ignore: + - 'constant expression "false" in condition' diff --git a/.github/actions/setup-nix/action.yml b/.github/actions/setup-nix/action.yml new file mode 100644 index 0000000000..3cd6940c05 --- /dev/null +++ b/.github/actions/setup-nix/action.yml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Setup Nix +description: Install Nix and configure the OpenShell Cachix cache + +inputs: + cachix-auth-token: + description: Token used to write build outputs to Cachix + required: false + default: "" + +runs: + using: composite + steps: + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 + with: + github_access_token: ${{ github.token }} + + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 + with: + name: openshell + authToken: ${{ inputs.cachix-auth-token }} + skipPush: ${{ inputs.cachix-auth-token == '' }} diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000000..3953988b89 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: OpenShell Rust and SDKs + +paths: + - crates + - examples + - sdk/go + - sdk/typescript/src + - python/openshell + +paths-ignore: + - python/openshell/_proto + - sdk/typescript/src/gen diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000000..28057fe18a --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,128 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: CodeQL + +on: + pull_request: + merge_group: + types: [checks_requested] + push: + branches: [main] + schedule: + - cron: "29 5 * * 6" + workflow_dispatch: + +permissions: + contents: read + security-events: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + include: + - language: rust + build-mode: none + - language: go + build-mode: manual + - language: python + build-mode: none + - language: javascript-typescript + build-mode: none + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Go + if: matrix.language == 'go' + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: sdk/go/go.mod + cache-dependency-path: sdk/go/go.sum + + - name: Initialize CodeQL + uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + config-file: ./.github/codeql/codeql-config.yml + + - name: Build Go SDK + if: matrix.language == 'go' + working-directory: sdk/go + run: go build ./... + + - name: Analyze + id: analyze + uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + with: + category: /language:${{ matrix.language }} + output: codeql-results + upload: never + + - name: Summarize findings + if: always() + env: + LANGUAGE: ${{ matrix.language }} + shell: bash + run: | + set -euo pipefail + shopt -s globstar nullglob + sarif_files=(codeql-results/**/*.sarif) + + { + echo "### CodeQL: $LANGUAGE" + echo + if [ "${#sarif_files[@]}" -eq 0 ]; then + echo "No SARIF report was produced." + else + finding_count=$(jq -s '[.[].runs[]?.results[]?] | length' "${sarif_files[@]}") + echo "Findings: $finding_count" + echo + echo "Findings are informational and do not fail CI." + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload SARIF to Code Scanning + if: steps.analyze.outcome == 'success' + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + with: + sarif_file: codeql-results + category: /language:${{ matrix.language }} + + - name: Upload SARIF + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: codeql-${{ matrix.language }}-${{ github.run_id }} + path: codeql-results + if-no-files-found: ignore + retention-days: 14 + + result: + name: OpenShell / CodeQL (informational) + if: always() + needs: analyze + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Evaluate analyzer execution + env: + ANALYZE_RESULT: ${{ needs.analyze.result }} + shell: bash + run: | + if [ "$ANALYZE_RESULT" != "success" ]; then + echo "::error::One or more CodeQL analyzers did not complete successfully." + exit 1 + fi + echo "All CodeQL analyzers completed; findings remain informational." diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000000..fc162fd2cb --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,98 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Dependency Review + +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: Head commit SHA to compare + required: true + type: string + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + review: + name: Dependency Review (informational) + runs-on: ubuntu-latest + env: + BASE_REF: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || inputs.base_sha }} + HEAD_REF: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha || inputs.head_sha }} + steps: + - name: Check Dependency Graph availability + id: preflight + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const shaPattern = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i; + const baseRef = process.env.BASE_REF; + const headRef = process.env.HEAD_REF; + + if (!shaPattern.test(baseRef) || !shaPattern.test(headRef)) { + core.setFailed("Dependency Review requires base and head commit SHAs."); + return; + } + + try { + await github.request( + "GET /repos/{owner}/{repo}/dependency-graph/compare/{basehead}", + { + owner: context.repo.owner, + repo: context.repo.repo, + basehead: `${baseRef}...${headRef}`, + headers: { + "X-GitHub-Api-Version": "2022-11-28", + }, + }, + ); + core.setOutput("available", "true"); + } catch (error) { + const status = error.status; + if (status === 403 || status === 404) { + core.setOutput("available", "false"); + core.warning( + `GitHub Dependency Graph is unavailable (HTTP ${status}); Dependency Review is skipped.`, + ); + await core.summary + .addHeading("Dependency Review", 3) + .addRaw(`GitHub Dependency Graph is unavailable (HTTP ${status}).`, true) + .addRaw( + "The informational review will start automatically once the repository feature is available.", + true, + ) + .write(); + return; + } + + const statusSuffix = status ? ` with HTTP ${status}` : ""; + core.setFailed( + `Dependency Graph preflight failed${statusSuffix}: ${error.message}`, + ); + } + + - name: Review dependency changes + if: steps.preflight.outputs.available == 'true' + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + base-ref: ${{ env.BASE_REF }} + head-ref: ${{ env.HEAD_REF }} + fail-on-severity: high + fail-on-scopes: runtime, development, unknown + warn-only: true + comment-summary-in-pr: never + license-check: false + show-openssf-scorecard: false diff --git a/.github/workflows/workflow-security.yml b/.github/workflows/workflow-security.yml new file mode 100644 index 0000000000..7a87e15483 --- /dev/null +++ b/.github/workflows/workflow-security.yml @@ -0,0 +1,185 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Workflow Security Reports + +on: + pull_request: + merge_group: + types: [checks_requested] + push: + branches: [main] + schedule: + - cron: "17 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +defaults: + run: + shell: nix develop --command bash -euo pipefail {0} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + actionlint: + name: Actionlint (informational) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Nix + uses: ./.github/actions/setup-nix + with: + cachix-auth-token: ${{ github.event_name != 'pull_request' && secrets.CACHIX_AUTH_TOKEN || '' }} + + - name: Run Actionlint + run: | + set -uo pipefail + mkdir -p reports + echo "::add-matcher::.github/actionlint-matcher.json" + + set +e + actionlint -shellcheck= -pyflakes= 2>&1 | tee reports/actionlint.txt + status=${PIPESTATUS[0]} + + echo "::remove-matcher owner=actionlint::" + actionlint \ + -shellcheck= \ + -pyflakes= \ + -format "$(cat .github/actionlint-sarif-template.txt)" \ + > reports/actionlint.sarif + sarif_status=$? + set -e + + if [ "$status" -le 1 ] && [ "$sarif_status" -ne "$status" ]; then + echo "::error::Actionlint could not produce SARIF (exit $sarif_status)." + exit "$sarif_status" + fi + + { + echo "### Actionlint" + echo + } >> "$GITHUB_STEP_SUMMARY" + + case "$status" in + 0) + echo "No findings." >> "$GITHUB_STEP_SUMMARY" + ;; + 1) + echo "::warning::Actionlint reported findings; this check is informational." + echo "Findings were reported as annotations and do not fail CI." >> "$GITHUB_STEP_SUMMARY" + ;; + 2|3) + echo "::error::Actionlint could not complete (exit $status)." + echo "Actionlint failed to run correctly (exit $status)." >> "$GITHUB_STEP_SUMMARY" + exit "$status" + ;; + *) + echo "::error::Actionlint returned unexpected exit code $status." + echo "Actionlint returned unexpected exit code $status." >> "$GITHUB_STEP_SUMMARY" + exit "$status" + ;; + esac + + - name: Upload Actionlint SARIF to Code Scanning + if: success() + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + with: + sarif_file: reports/actionlint.sarif + category: actionlint + + - name: Upload Actionlint report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: actionlint-${{ github.run_id }} + path: | + reports/actionlint.txt + reports/actionlint.sarif + if-no-files-found: ignore + retention-days: 14 + + zizmor: + name: Zizmor High report (informational) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Nix + uses: ./.github/actions/setup-nix + with: + cachix-auth-token: ${{ github.event_name != 'pull_request' && secrets.CACHIX_AUTH_TOKEN || '' }} + + - name: Run Zizmor + run: | + set -euo pipefail + mkdir -p reports + + zizmor \ + --offline \ + --persona=regular \ + --min-severity=high \ + --no-exit-codes \ + --format=json \ + . > reports/zizmor-high.json + + zizmor \ + --offline \ + --persona=regular \ + --min-severity=high \ + --no-exit-codes \ + --format=sarif \ + . > reports/zizmor-high.sarif + + finding_count=$(jq 'length' reports/zizmor-high.json) + { + echo "### Zizmor high-severity report" + echo + echo "Zizmor has no critical severity; high is its maximum level." + echo + echo "Findings: $finding_count" + if [ "$finding_count" -gt 0 ]; then + echo + jq -r \ + 'group_by(.ident) | .[] | "- `\(.[0].ident)`: \(length)"' \ + reports/zizmor-high.json + echo + echo "These findings are informational and do not fail CI." + fi + } >> "$GITHUB_STEP_SUMMARY" + + if [ "$finding_count" -gt 0 ]; then + echo "::warning::Zizmor reported $finding_count high-severity findings; this check is informational." + fi + + - name: Upload Zizmor SARIF to Code Scanning + if: success() + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + with: + sarif_file: reports/zizmor-high.sarif + category: zizmor-high + + - name: Upload Zizmor reports + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: zizmor-high-${{ github.run_id }} + path: | + reports/zizmor-high.json + reports/zizmor-high.sarif + if-no-files-found: ignore + retention-days: 14 diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 0000000000..7b1e035b77 --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +rules: + dangerous-triggers: + ignore: + # These base-branch workflows never check out or execute pull request + # head code. Keep each suppression scoped to its reviewed trigger block. + - dco.yml:3 + - e2e-label-help.yml:13 + - release-canary.yml:3 + - required-ci-gates.yml:3 + - vouch-check.yml:3 diff --git a/CI.md b/CI.md index 9dd919d1bd..c3d33622f7 100644 --- a/CI.md +++ b/CI.md @@ -26,6 +26,41 @@ The `OpenShell / E2E` and `OpenShell / GPU E2E` required statuses are evaluated The GitHub ruleset should require the `OpenShell / ...` statuses published by `Required CI Gates`, not the push-triggered workflow jobs directly. +## Informational security reports + +Security analysis that does not need NVIDIA infrastructure runs directly on +GitHub-hosted runners. These workflows receive no secrets and 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 each scanner uploads results +directly and also retains report artifacts: + +- `Workflow Security Reports` runs Actionlint and Zizmor. Actionlint reports + workflow syntax and expression findings. Zizmor reports only High severity, + its maximum level. Nix provides both scanners. They publish SARIF to Code + Scanning and retain report artifacts. +- `Dependency Review` compares the base and head dependency graphs and reports + newly introduced vulnerabilities with a High-or-higher policy. It runs in + warn-only mode. A preflight turns an unavailable GitHub Dependency Graph into + a warning, so the workflow remains neutral until the repository feature is + available. +- `CodeQL` analyzes product Rust code, examples, and the Go, Python, and + TypeScript SDKs. E2E test code is excluded. Results are uploaded to Code + Scanning and always retained as workflow artifacts. + +Findings do not fail these workflows. Tool startup, configuration, build, and +analysis failures still fail so a broken scanner cannot appear healthy. These +workflows also run on merge groups, but their checks are not required statuses +and do not gate merges. + +Run the workflow-definition scanners locally with: + +```shell +nix develop --command actionlint -shellcheck= -pyflakes= +nix develop --command zizmor --offline --persona=regular --min-severity=high --no-exit-codes . +``` + ## 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 `. @@ -138,10 +173,14 @@ The bot's full administrator documentation is internal to NVIDIA. The only comma | `.github/workflows/branch-checks.yml` | Required non-E2E checks. Triggers on `push: pull-request/[0-9]+` for PR mirrors and `merge_group` for queued merges. | | `.github/workflows/branch-e2e.yml` | Standard, GPU, Kubernetes HA, and Kubernetes credential-driver E2E. PR mirror pushes use `test:e2e`, `test:e2e-gpu`, and `test:e2e-kubernetes` labels; merge groups run core and GPU E2E. | | `.github/workflows/helm-lint.yml` | Helm chart validation. PR mirror pushes skip lint jobs unless Helm inputs changed; merge groups always validate Helm because they represent the final integration state. | +| `.github/actions/setup-nix/action.yml` | Installs Nix and configures the OpenShell Cachix cache, using read-only cache access when no authentication token is available. | | `.github/actions/pr-gate/action.yml` | Composite action that resolves PR metadata and verifies the required label is set for PR mirror pushes. Non-push events are allowed through. | | `.github/actions/pr-merge-base/action.yml` | Composite action that resolves and fetches the merge-base commit for `pull-request/` push workflows. | | `.github/workflows/required-ci-gates.yml` | Posts required PR-head and merge-group statuses for gated CI workflows. This is what branch protection and merge queue should require. | | `.github/workflows/e2e-label-help.yml` | When a `test:e2e*` label is applied, posts a PR comment telling the maintainer the next manual step (re-run an existing workflow run, or `/ok to test ` to refresh the mirror). | +| `.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 informational CodeQL analysis for Rust and the Go, Python, and TypeScript SDKs and retains SARIF artifacts. | ## Release workflows @@ -163,3 +202,6 @@ Require these statuses in the branch ruleset for PR and merge-queue CI: - `OpenShell / Helm Lint` 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. + +Do not add the informational Actionlint, Zizmor, Dependency Review, or CodeQL +jobs to the required status list while they remain in observation mode. diff --git a/architecture/build.md b/architecture/build.md index 7daf4a7c66..a1c6974989 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -272,6 +272,45 @@ Repository CI keeps telemetry compiled into release-parity artifacts but 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 they also cover fork pull +requests and consume no 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. +Triggers differ by workflow: `.github/workflows/workflow-security.yml` and +`.github/workflows/codeql.yml` run 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. + +- **Actionlint and Zizmor** analyze the workflow definitions themselves. + Repository configuration lives in `.github/actionlint.yml` (self-hosted runner + labels, scoped per-file ignores) and `.github/zizmor.yml` (scoped rule + suppressions). Zizmor runs offline and reports only High severity, which is + its maximum level. Both publish SARIF to Code Scanning and retain report + artifacts. The Nix flake provides both scanners, so local runs use + `nix develop --command actionlint -shellcheck= -pyflakes=` and + `nix develop --command zizmor --offline --persona=regular --min-severity=high --no-exit-codes .`. +- **Dependency Review** compares the base and head dependency graphs. It + preflights the GitHub Dependency Graph compare API and neutralizes itself with + a warning while that repository feature is unavailable, so the check begins + reporting on its own once the feature is enabled. Reviews run in warn-only + mode. +- **CodeQL** analyzes product Rust code, examples, and the Go, Python, and + TypeScript SDKs, scoped by `.github/codeql/codeql-config.yml`; E2E test code is + excluded. Only Go requires a build; the other languages use build mode `none`. + Results are uploaded to Code Scanning and always retained as workflow + artifacts. + +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. + See `CI.md` for the contributor workflow, labels, and maintainer merge-queue workflow. ## Docs Site diff --git a/flake.nix b/flake.nix index 8e9fb728b1..68e6156e3b 100644 --- a/flake.nix +++ b/flake.nix @@ -46,6 +46,7 @@ }; testGuestPkgs = import nixpkgs-test-guest { inherit system; }; commonDevShellPackages = with pkgs; [ + actionlint cargo-deny cargo-nextest # Assemble Debian artifacts on macOS and Linux. @@ -55,6 +56,7 @@ # Coverage. lcov uv + zizmor ]; treefmtEval = treefmt-nix.lib.evalModule pkgs { projectRootFile = "flake.nix"; From 23351771caf7857800e08cf6df3f1929c120a128 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:33:36 +0000 Subject: [PATCH 034/505] chore(deps): bump quinn-proto from 0.11.14 to 0.11.17 (#2986) Bumps [quinn-proto](https://github.com/quinn-rs/quinn) from 0.11.14 to 0.11.17. - [Release notes](https://github.com/quinn-rs/quinn/releases) - [Commits](https://github.com/quinn-rs/quinn/compare/quinn-proto-0.11.14...quinn-proto-0.11.17) --- updated-dependencies: - dependency-name: quinn-proto dependency-version: 0.11.17 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 207535ab10..011730760d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2118,11 +2118,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -5371,15 +5369,16 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.2", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash 2.1.2", "rustls 0.23.38", @@ -5502,6 +5501,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xoshiro" version = "0.7.0" From 6e43a8bc99db5dacc6f041018d665c2216d1e0e2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:39:22 +0000 Subject: [PATCH 035/505] chore(deps): bump google.golang.org/grpc in /sdk/go (#2985) Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.81.1 to 1.82.1. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.81.1...v1.82.1) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-version: 1.82.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- sdk/go/go.mod | 10 +++++----- sdk/go/go.sum | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/sdk/go/go.mod b/sdk/go/go.mod index 900c9520c6..7855538943 100644 --- a/sdk/go/go.mod +++ b/sdk/go/go.mod @@ -7,16 +7,16 @@ require ( github.com/stretchr/testify v1.11.1 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.22.0 - google.golang.org/grpc v1.81.1 + google.golang.org/grpc v1.82.1 google.golang.org/protobuf v1.36.11 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/net v0.51.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.34.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/sdk/go/go.sum b/sdk/go/go.sum index 44508540a6..54d6b83e75 100644 --- a/sdk/go/go.sum +++ b/sdk/go/go.sum @@ -30,22 +30,22 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= From ca61ee37440995d1ca17cb081f09c4743609cfff Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Thu, 27 Aug 2026 13:56:38 +0000 Subject: [PATCH 036/505] feat(test-guest): add snap lifecycle reproduction harness (#2865) * feat(test-guest): add snap lifecycle reproduction harness Signed-off-by: Evan Lezar * fix(test-guest): preserve cache seal executability Signed-off-by: Evan Lezar --------- Signed-off-by: Evan Lezar --- nix/test-guest/README.md | 42 ++++++- nix/test-guest/cache-seal.sh | 0 nix/test-guest/configuration/snapd.yml | 38 ++++++ nix/test-guest/default.nix | 1 + nix/test-guest/run.sh | 30 ++++- nix/test-guest/scripts/snap-gateway-repro.sh | 115 +++++++++++++++++++ 6 files changed, 217 insertions(+), 9 deletions(-) mode change 100644 => 100755 nix/test-guest/cache-seal.sh create mode 100644 nix/test-guest/configuration/snapd.yml create mode 100755 nix/test-guest/scripts/snap-gateway-repro.sh diff --git a/nix/test-guest/README.md b/nix/test-guest/README.md index ca62f893e6..b608cb19fe 100644 --- a/nix/test-guest/README.md +++ b/nix/test-guest/README.md @@ -63,6 +63,11 @@ The root [`flake.nix`](../../flake.nix) exposes this directory as the `test-gues | Fedora 44 | No | Yes | Yes | `.rpm` | | Rocky Linux 9 | Yes | Yes | Yes | `.rpm` | +The `snapd` configuration is available for Ubuntu and prepares snapd for +local Snap lifecycle experiments. It does not install Docker, because the Snap +gateway reproduction uses the Docker **Snap** and its `docker:docker-daemon` +interface rather than the host-package Docker configuration. + The Ubuntu 24.04 Podman configuration is available for runtime and packaging checks, but its Podman 4 release does not provide the `pasta` rootless network helper required by OpenShell sandbox callbacks. OpenShell Podman E2E runs use @@ -121,9 +126,11 @@ nix run .#test-guest -- \ Configurations are Ansible playbooks stored under `nix/test-guest/configuration/`. Ansible runs on the host using the VM's ephemeral SSH key and loopback port. The guest does not install Ansible. -Configurations run in the order provided on the command line. OpenShell packages and copied binaries are installed after all configurations succeed. +Configurations run in the order provided on the command line. OpenShell packages and copied files are installed after all configurations succeed. -`--install` packages and `--copy` executables are applied by a dedicated per-run Ansible playbook. They are not stored in prepared VM cache entries. +`--install` packages and `--copy` files are applied by a dedicated per-run +Ansible playbook. `--copy` preserves each source file's ordinary permission +bits. They are not stored in prepared VM cache entries. ## Prepared VM cache @@ -212,9 +219,10 @@ For an x86_64 Linux guest, supply x86_64 binaries and use `package:deb:amd64`. T `--install` is repeatable. Debian packages are accepted by Ubuntu; RPM packages are accepted by CentOS, Fedora, and Rocky Linux. This prototype can install an existing RPM but does not build one. -## Copy binaries directly +## Copy files directly -Use `--copy SOURCE:DEST` to install an executable without creating a package: +Use `--copy SOURCE:DEST` to copy a regular file without creating a package. The +guest file preserves the source's ordinary permission bits: ```shell nix run .#test-guest -- \ @@ -223,6 +231,29 @@ nix run .#test-guest -- \ -- openshell --version ``` +## Reproduce Snap gateway startup + +The gateway Snap must be native to the guest architecture. Copy an existing +Snap artifact and the reproduction script into a prepared Ubuntu guest, then +run the script as root. It follows the Release Canary ordering exactly: install +the Snap, connect Docker/log/system interfaces, and immediately query the +gateway. On each failure it prints snapd and gateway journals. + +```shell +nix run .#test-guest -- \ + --distro ubuntu \ + --with snapd \ + --keep \ + --copy ./openshell_*.snap:/tmp/openshell.snap \ + --copy ./nix/test-guest/scripts/snap-gateway-repro.sh:/usr/local/bin/snap-gateway-repro \ + -- sudo /usr/local/bin/snap-gateway-repro /tmp/openshell.snap 10 30 +``` + +`--keep` retains the overlay and serial log when diagnosing a failure. The +runner prints their location after shutdown. The final `30` accepts automatic +recovery for up to 30 seconds; omit it to require the canary's immediate check. + + The destination must be an absolute guest path. Copied files are installed with mode `0755`. ## Runner options @@ -231,7 +262,8 @@ The destination must be an absolute guest path. Copied files are installed with --distro NAME Base distro: ubuntu, centos, fedora, or rocky --with NAME Apply docker, podman, or selinux; repeatable --install PATH Install a .deb or .rpm package; repeatable ---copy SRC:DEST Copy an executable into the guest; repeatable +--copy SRC:DEST Copy a regular file into the guest, preserving its host mode; + repeatable --ssh-port PORT Use a specific loopback SSH forwarding port --forward-port HOST_PORT:GUEST_PORT Forward a loopback host port to a guest port; repeatable diff --git a/nix/test-guest/cache-seal.sh b/nix/test-guest/cache-seal.sh old mode 100644 new mode 100755 diff --git a/nix/test-guest/configuration/snapd.yml b/nix/test-guest/configuration/snapd.yml new file mode 100644 index 0000000000..c89b81a2aa --- /dev/null +++ b/nix/test-guest/configuration/snapd.yml @@ -0,0 +1,38 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +- name: Configure snapd + hosts: test_vm + become: true + gather_facts: true + + tasks: + - name: Validate snapd support + ansible.builtin.assert: + that: + - ansible_facts.distribution == "Ubuntu" + fail_msg: >- + snapd is currently configured only for Ubuntu test guests, + not {{ ansible_facts.distribution }}. + + - name: Refresh Ubuntu package metadata + ansible.builtin.apt: + update_cache: true + + - name: Install snapd + ansible.builtin.apt: + name: snapd + state: present + install_recommends: false + + - name: Start snapd socket activation + ansible.builtin.systemd_service: + name: snapd.socket + enabled: true + state: started + + - name: Wait for snapd seed + ansible.builtin.command: + cmd: snap wait system seed.loaded + changed_when: false diff --git a/nix/test-guest/default.nix b/nix/test-guest/default.nix index 72937cfec2..753f266ab4 100644 --- a/nix/test-guest/default.nix +++ b/nix/test-guest/default.nix @@ -28,6 +28,7 @@ let docker = ./configuration/docker.yml; podman = ./configuration/podman.yml; selinux = ./configuration/selinux.yml; + snapd = ./configuration/snapd.yml; }; mkDistroProfile = diff --git a/nix/test-guest/run.sh b/nix/test-guest/run.sh index c999679492..6be58eb235 100644 --- a/nix/test-guest/run.sh +++ b/nix/test-guest/run.sh @@ -13,9 +13,10 @@ Usage: Options: --distro NAME Base distro: ubuntu, centos, fedora, or rocky - --with NAME Apply a configuration; repeatable (docker, podman, selinux) + --with NAME Apply a configuration; repeatable (docker, podman, selinux, snapd) --install PATH Install a .deb or .rpm package; repeatable - --copy SRC:DEST Copy an executable to an absolute guest path; repeatable + --copy SRC:DEST Copy a regular file to an absolute guest path, preserving + its host mode; repeatable --ssh-port PORT Use a specific loopback SSH forwarding port --forward-port HOST_PORT:GUEST_PORT Forward a loopback host port to a guest port; repeatable @@ -43,6 +44,26 @@ require_value() { fi } +preserved_file_mode() { + local source_path=$1 + local source_mode + + if [ "$(uname -s)" = Darwin ]; then + if ! source_mode=$(stat -f '%Lp' "${source_path}"); then + echo "could not determine mode for --copy source: ${source_path}" >&2 + return 1 + fi + elif ! source_mode=$(stat -c '%a' "${source_path}"); then + echo "could not determine mode for --copy source: ${source_path}" >&2 + return 1 + fi + if [[ ! ${source_mode} =~ ^[0-7]{3,4}$ ]]; then + echo "could not determine mode for --copy source: ${source_path}" >&2 + return 1 + fi + printf '%03o\n' "$((8#${source_mode} & 8#777))" +} + distro= requested_ssh_port= keep=0 @@ -618,13 +639,14 @@ if [ "${#packages[@]}" -gt 0 ] || [ "${#copies[@]}" -gt 0 ]; then for copy_spec in "${copies[@]}"; do source_path=${copy_spec%%:*} destination=${copy_spec#*:} + mode=$(preserved_file_mode "${source_path}") || exit 2 remote_path=${artifact_staging_dir}/copy-${artifact_index} echo "==> Copying artifact: ${destination}" scp -q "${scp_args[@]}" \ "${source_path}" "openshell@127.0.0.1:${remote_path}" printf -v install_command \ - 'sudo install -D -m 0755 -- %q %q' \ - "${remote_path}" "${destination}" + 'sudo install -D -m %q -- %q %q' \ + "${mode}" "${remote_path}" "${destination}" ssh "${ssh_args[@]}" openshell@127.0.0.1 "${install_command}" artifact_index=$((artifact_index + 1)) done diff --git a/nix/test-guest/scripts/snap-gateway-repro.sh b/nix/test-guest/scripts/snap-gateway-repro.sh new file mode 100755 index 0000000000..f3090aece2 --- /dev/null +++ b/nix/test-guest/scripts/snap-gateway-repro.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Reproduce the Release Canary Snap lifecycle: install the OpenShell Snap, +# connect its interfaces after the daemon is started, then immediately use the +# local gateway. Run this as root inside an Ubuntu guest prepared with --with snapd. + +set -uo pipefail + +usage() { + cat <<'EOF' +Usage: snap-gateway-repro.sh SNAP_FILE [ATTEMPTS] [READY_TIMEOUT_SECONDS] + +Install SNAP_FILE repeatedly using the Release Canary interface ordering. +ATTEMPTS defaults to 1. READY_TIMEOUT_SECONDS defaults to 0, preserving the +canary's immediate readiness check. Set it to a positive value to wait for +automatic gateway recovery after the immediate check fails. Every failed +attempt prints service, connection, snap-change, journal, gateway-log, and +listener diagnostics. +EOF +} + +if [ "$#" -lt 1 ] || [ "$#" -gt 3 ]; then + usage >&2 + exit 2 +fi + +snap_file=$1 +attempts=${2:-1} +ready_timeout=${3:-0} +if [ ! -f "${snap_file}" ]; then + echo "Snap file does not exist: ${snap_file}" >&2 + exit 2 +fi +if [[ ! ${attempts} =~ ^[1-9][0-9]*$ ]]; then + echo "ATTEMPTS must be a positive integer: ${attempts}" >&2 + exit 2 +fi +if [[ ! ${ready_timeout} =~ ^[0-9]+$ ]]; then + echo "READY_TIMEOUT_SECONDS must be a non-negative integer: ${ready_timeout}" >&2 + exit 2 +fi + +diagnostics() { + local attempt=$1 + echo "========== Snap diagnostics (attempt ${attempt}) ==========" >&2 + snap services openshell >&2 || true + snap connections openshell >&2 || true + snap changes >&2 || true + systemctl status snap.openshell.gateway.service --no-pager >&2 || true + journalctl -b -u snap.openshell.gateway.service --no-pager -n 300 >&2 || true + journalctl -b -u snapd.service --no-pager -n 300 >&2 || true + snap logs openshell.gateway -n=300 >&2 || true + ss -ltnp '( sport = :17670 )' >&2 || true +} + +gateway_is_ready() { + runuser -u openshell -- /snap/bin/openshell status >/dev/null 2>&1 +} + +wait_for_gateway() { + local deadline=$((SECONDS + ready_timeout)) + while [ "${SECONDS}" -lt "${deadline}" ]; do + if gateway_is_ready; then + return 0 + fi + sleep 1 + done + gateway_is_ready +} + +if ! snap list docker >/dev/null 2>&1; then + echo "==> Installing Docker Snap" + snap install docker +fi + +failures=0 +for attempt in $(seq 1 "${attempts}"); do + echo "==> Snap gateway reproduction attempt ${attempt}/${attempts}" + snap remove --purge openshell >/dev/null 2>&1 || true + rm -rf /home/openshell/snap/openshell + + if ! snap install "${snap_file}" --dangerous || + ! snap connect openshell:docker docker:docker-daemon || + ! snap connect openshell:log-observe || + ! snap connect openshell:system-observe; then + echo "OpenShell installation or interface connection failed" >&2 + diagnostics "${attempt}" + failures=$((failures + 1)) + continue + fi + + # This deliberately does not wait for the listener. It mirrors the canary + # and exposes a daemon that fails or races after late interface connections. + if ! runuser -u openshell -- /snap/bin/openshell gateway add \ + http://127.0.0.1:17670 --local --name snap-docker || + ! runuser -u openshell -- /snap/bin/openshell gateway select snap-docker || + ! gateway_is_ready; then + if [ "${ready_timeout}" -gt 0 ] && wait_for_gateway; then + echo "Gateway recovered automatically within ${ready_timeout}s" + continue + fi + echo "Gateway was not usable immediately after interface connection" >&2 + diagnostics "${attempt}" + failures=$((failures + 1)) + fi +done + +if [ "${failures}" -gt 0 ]; then + echo "${failures}/${attempts} attempt(s) failed" >&2 + exit 1 +fi + +echo "All ${attempts} attempt(s) passed" From 37072ee81cd7b294c714bfa5ecc829b6927b3d70 Mon Sep 17 00:00:00 2001 From: alangou Date: Thu, 27 Aug 2026 14:53:33 +0000 Subject: [PATCH 037/505] feat(build): publish OCI SBOM and provenance attestations (#2836) * feat(build): embed auditable Rust dependency metadata Signed-off-by: Adrien Langou * feat(build): publish OCI SBOM and provenance attestations Signed-off-by: Adrien Langou --------- Signed-off-by: Adrien Langou --- .agents/skills/sbom/SKILL.md | 17 +++++++- .github/workflows/branch-e2e.yml | 2 + .github/workflows/docker-build.yml | 15 +++++++ .github/workflows/rust-native-build.yml | 2 + architecture/build.md | 36 +++++++++++------ docs/security/verifying-images.mdx | 36 +++++++++++++++++ tasks/scripts/docker-build-image.sh | 15 ++++--- tasks/scripts/verify-image-sbom.sh | 53 +++++++++++++++++++++++++ 8 files changed, 158 insertions(+), 18 deletions(-) create mode 100644 docs/security/verifying-images.mdx create mode 100755 tasks/scripts/verify-image-sbom.sh diff --git a/.agents/skills/sbom/SKILL.md b/.agents/skills/sbom/SKILL.md index 852259293d..6996ffcf66 100644 --- a/.agents/skills/sbom/SKILL.md +++ b/.agents/skills/sbom/SKILL.md @@ -13,13 +13,28 @@ The OpenShell SBOM tooling produces source-tree CycloneDX JSON SBOMs using Syft, SBOMs are **release artifacts only** -- they are generated on demand and not committed to the repository. Output lands in `deploy/sbom/output/` (gitignored). -Release Dev and Release Tag image builds separately embed cargo-auditable metadata in the staged gateway and supervisor binaries. This metadata describes the binary's Rust dependency graph and lets Syft discover Cargo packages from the binary itself. It is not a complete image SBOM and is not an OCI SBOM attestation; publishing such an attestation remains separate work. +Pushed gateway and supervisor images carry an SPDX SBOM and minimal SLSA provenance as OCI attestations. Branch E2E, Release Dev, and Release Tag image binaries embed cargo-auditable metadata, so their image SBOMs include linked Rust crates. ## Prerequisites - `mise install` has been run (installs Syft and other tools) - The repository is checked out at the root +## Inspecting an Image SBOM + +BuildKit uses its default Syft scanner and attaches one SPDX document per platform. Read one without pulling the image: + +```bash +docker buildx imagetools inspect ghcr.io/nvidia/openshell/gateway:latest \ + --format '{{ json (index .SBOM "linux/amd64").SPDX }}' +``` + +Validate the final attestation, requiring a Cargo package for an auditable image: + +```bash +tasks/scripts/verify-image-sbom.sh ghcr.io/nvidia/openshell/gateway:latest --require-cargo +``` + ## Inspecting an Auditable Image Binary Opt into auditable metadata when staging a local image binary: diff --git a/.github/workflows/branch-e2e.yml b/.github/workflows/branch-e2e.yml index 2b4d9d5d46..659027a8d6 100644 --- a/.github/workflows/branch-e2e.yml +++ b/.github/workflows/branch-e2e.yml @@ -86,6 +86,7 @@ jobs: with: component: gateway image-tag: ${{ github.sha }} + auditable: true build-supervisor: needs: [pr_metadata] @@ -97,6 +98,7 @@ jobs: with: component: supervisor image-tag: ${{ github.sha }} + auditable: true build-cli: needs: [pr_metadata] diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 2e7abe2008..73cb678e78 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -294,6 +294,10 @@ jobs: volumes: - /var/run/docker.sock:/var/run/docker.sock steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs['checkout-ref'] || github.sha }} + - name: Log in to GHCR run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin @@ -309,3 +313,14 @@ jobs: --prefer-index=false \ -t "${image}:${{ needs.resolve.outputs.image_tag_base }}" \ "${refs[@]}" + + - name: Verify merged manifest SBOM attestation + env: + IMAGE_REF: ghcr.io/nvidia/openshell/${{ inputs.component }}:${{ needs.resolve.outputs.image_tag_base }} + REQUIRE_CARGO: ${{ inputs.auditable }} + run: | + args=("${IMAGE_REF}") + if [[ "${REQUIRE_CARGO}" == "true" ]]; then + args+=(--require-cargo) + fi + tasks/scripts/verify-image-sbom.sh "${args[@]}" diff --git a/.github/workflows/rust-native-build.yml b/.github/workflows/rust-native-build.yml index 3496f4779f..24124d0ae4 100644 --- a/.github/workflows/rust-native-build.yml +++ b/.github/workflows/rust-native-build.yml @@ -40,6 +40,8 @@ on: required: false type: string default: "" + # Branch E2E and release gateway/supervisor image builds opt in. + # CLI images and standalone glibc-static supervisor validation keep false. auditable: description: "Embed cargo-auditable dependency metadata in the binary" required: false diff --git a/architecture/build.md b/architecture/build.md index a1c6974989..ff8dc424cc 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -131,27 +131,39 @@ step via the `rust-native-build.yml` workflow (per-architecture, per-component) and uploads the result as an artifact that the image build job downloads back into the staging directory before running Buildx. -Gateway and supervisor binaries staged into 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 into the -binary. That section holds data rather than symbols, so it survives the +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 +into the binary. That section holds data rather than symbols, so it survives the workspace's `strip = true` release profile, and Syft can catalog the crates present in image binaries instead of inferring them from the source tree. This is a different artifact from the source SBOM produced by `syft dir:.` in -`tasks/sbom.toml`, which describes the checkout, and from an OCI SBOM -attestation, which remains out of scope. - -Only release image builds are auditable. `docker-build.yml` and -`rust-native-build.yml` take an `auditable` input that defaults to false, so PR -and E2E image builds and standalone release artifacts remain non-auditable. The -CI image gains the pinned `cargo-auditable` tool through `mise install --locked` -but ships no auditable OpenShell binary of its own. Local staging opts in with +`tasks/sbom.toml`, which describes the checkout, and from the image SBOM +attestation below, which describes a published image. + +`docker-build.yml` and `rust-native-build.yml` take an `auditable` input that +defaults to false. The branch E2E gateway and supervisor image builds set it to +true so E2E exercises release-parity image binaries; other PR image builds and +standalone release artifacts stay non-auditable. The CI image gains the pinned +`cargo-auditable` tool through `mise install --locked` but ships no auditable +OpenShell binary of its own. Local staging opts in with `OPENSHELL_AUDITABLE=1`. sccache's `RUSTC_WRAPPER` is unset only around auditable builds, because it would otherwise wrap `cargo-auditable`'s workspace wrapper and be misidentified as `rustc`. Auditable builds are verified by scanning the built binary with Syft and requiring at least one decoded Cargo package; the check runs only for those builds. +Pushed Docker images carry minimal SLSA provenance and a per-platform SPDX SBOM +generated by BuildKit's default Syft scanner. The registry exporter uses OCI +media types and `oci-artifact=true`, so each attestation identifies its subject. +GHCR exposes these through the image index because it has no referrers API. + +Attestations require a registry-backed image index. Local builds therefore keep +`--provenance=false`, and Podman builds carry neither attestation. +`tasks/scripts/verify-image-sbom.sh` verifies the merged multi-arch tag and runs +with `--require-cargo` for auditable builds, so those attestations must also +contain Cargo packages. + Runtime layout: - **Gateway**: `gcr.io/distroless/cc-debian13:nonroot` base, GNU-linked binary at diff --git a/docs/security/verifying-images.mdx b/docs/security/verifying-images.mdx new file mode 100644 index 0000000000..4b9704d3b5 --- /dev/null +++ b/docs/security/verifying-images.mdx @@ -0,0 +1,36 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "Verify the Contents of Published OpenShell Images" +sidebar-title: "Verify Image Contents" +slug: "security/verify-image-contents" +description: "Read the SBOM attestation attached to published gateway and supervisor images to audit what each image contains." +keywords: "Generative AI, Cybersecurity, Supply Chain, SBOM, Container Images" +position: 2 +--- + +Published gateway and supervisor images carry one SPDX SBOM per platform as OCI attestations. + +## Inspect an Image + +Read a platform's document without pulling the image: + +```shell +docker buildx imagetools inspect ghcr.io/nvidia/openshell/gateway:latest --format '{{ json (index .SBOM "linux/amd64").SPDX }}' +``` + +List the packages instead of the full document: + +```shell +docker buildx imagetools inspect ghcr.io/nvidia/openshell/gateway:latest --format '{{ range (index .SBOM "linux/amd64").SPDX.packages }}{{ .name }}@{{ .versionInfo }}{{ println }}{{ end }}' +``` + +The same commands work for `ghcr.io/nvidia/openshell/supervisor`. + +## Coverage + +Every SBOM lists the base-image packages. Release Dev and Release Tag images also list the Rust crates compiled into their OpenShell binary. + + +OpenShell also publishes minimal SLSA provenance. It records how BuildKit produced the image, including its source revision, build platform, and base-image materials, without the extra build parameters included by full provenance. + diff --git a/tasks/scripts/docker-build-image.sh b/tasks/scripts/docker-build-image.sh index 8570180f12..08ba00e066 100755 --- a/tasks/scripts/docker-build-image.sh +++ b/tasks/scripts/docker-build-image.sh @@ -168,14 +168,19 @@ if [[ "${IS_FINAL_IMAGE}" == "1" ]]; then TAG_ARGS=(-t "${IMAGE_NAME}:${IMAGE_TAG}") fi +ATTESTATION_ARGS=(--provenance=false) OUTPUT_ARGS=() if [[ -n "${DOCKER_OUTPUT:-}" ]]; then OUTPUT_ARGS=(--output "${DOCKER_OUTPUT}") elif [[ "${IS_FINAL_IMAGE}" == "1" ]]; then - if [[ "${DOCKER_PUSH:-}" == "1" ]]; then - OUTPUT_ARGS=(--push) - elif [[ "${DOCKER_PLATFORM:-}" == *","* ]]; then - OUTPUT_ARGS=(--push) + if [[ "${DOCKER_PUSH:-}" == "1" || "${DOCKER_PLATFORM:-}" == *","* ]]; then + if ce_is_docker; then + # Attestations require a registry-backed image index. + ATTESTATION_ARGS=(--provenance=mode=min --attest type=sbom) + OUTPUT_ARGS=(--output "type=image,push=true,oci-mediatypes=true,oci-artifact=true") + else + OUTPUT_ARGS=(--push) + fi else OUTPUT_ARGS=(--load) fi @@ -191,7 +196,7 @@ ce_build \ -f "${DOCKERFILE}" \ --target "${DOCKER_TARGET}" \ ${TAG_ARGS[@]+"${TAG_ARGS[@]}"} \ - --provenance=false \ + ${ATTESTATION_ARGS[@]+"${ATTESTATION_ARGS[@]}"} \ "$@" \ ${OUTPUT_ARGS[@]+"${OUTPUT_ARGS[@]}"} \ . diff --git a/tasks/scripts/verify-image-sbom.sh b/tasks/scripts/verify-image-sbom.sh new file mode 100755 index 0000000000..1703b0608a --- /dev/null +++ b/tasks/scripts/verify-image-sbom.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +# Validate the final image's SBOM attestation and, for auditable builds, require +# at least one Cargo package discovered from the binary metadata. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/container-engine.sh" + +usage() { + echo "Usage: verify-image-sbom.sh [--require-cargo]" >&2 +} + +IMAGE=${1:-} +REQUIRE_CARGO=${2:-} +if [[ -z "${IMAGE}" || $# -gt 2 || ( -n "${REQUIRE_CARGO}" && "${REQUIRE_CARGO}" != "--require-cargo" ) ]]; then + usage + exit 2 +fi + +if ! ce_is_docker; then + echo "Error: SBOM attestations are produced on the Docker/buildx path; ${CONTAINER_ENGINE} has no imagetools equivalent" >&2 + exit 2 +fi + +echo "==> Inspecting SBOM attestation of ${IMAGE}" +SBOM_JSON="$(ce buildx imagetools inspect "${IMAGE}" --format '{{ json .SBOM }}')" +COUNTS="$( + jq -r ' + [.. | objects | .SPDX? | select(type == "object")] as $documents + | [ + ($documents | length), + ([$documents[] | .. | strings | select(startswith("pkg:cargo/"))] | length) + ] + | @tsv + ' <<<"${SBOM_JSON}" +)" +read -r SPDX_COUNT CARGO_COUNT <<<"${COUNTS}" + +if [[ "${SPDX_COUNT}" -eq 0 ]]; then + echo "Error: ${IMAGE} carries no SPDX SBOM" >&2 + exit 1 +fi +if [[ "${REQUIRE_CARGO}" == "--require-cargo" && "${CARGO_COUNT}" -eq 0 ]]; then + echo "Error: ${IMAGE} SBOM contains no Cargo packages" >&2 + exit 1 +fi + +echo "SBOM attestation verified: SPDX=${SPDX_COUNT}, Cargo=${CARGO_COUNT}" From 981606d2f8e22a7e2a7d7c64f1bd5a797318c688 Mon Sep 17 00:00:00 2001 From: Simon Scatton <44714756+SDAChess@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:25:06 +0000 Subject: [PATCH 038/505] ci: build release binaries with Nix (#2977) * ci: build release binaries with Nix Refs #1683 Signed-off-by: Simon Scatton * ci: build VM artifacts with Nix Refs #1683 Signed-off-by: Simon Scatton * ci: build images from Nix artifacts Refs #1683 Signed-off-by: Simon Scatton * fix(nix): prevent host header leakage Signed-off-by: Simon Scatton * ci: parallelize artifact builds Refs #1683 Signed-off-by: Simon Scatton * ci: build external driver test artifacts Refs #1683 Signed-off-by: Simon Scatton * fix(nix): disable mold in musl shells Signed-off-by: Simon Scatton * ci: key Rust cache by Nix shell derivation Signed-off-by: Simon Scatton * ci: refactor end-to-end workflows Signed-off-by: Simon Scatton * ci: split platform binary workflows Signed-off-by: Simon Scatton * ci: remove obsolete native build workflows Signed-off-by: Simon Scatton * ci: replace disallowed mise action Signed-off-by: Simon Scatton * ci: fix refactored e2e lanes Signed-off-by: Simon Scatton * ci: check out local result action Signed-off-by: Simon Scatton * ci: cache mise installations Signed-off-by: Simon Scatton * ci: run docker builds on host runners Signed-off-by: Simon Scatton * ci: disable unstable kubernetes e2e lanes Signed-off-by: Simon Scatton * fix(ci): scope binary builds to cargo packages Signed-off-by: Simon Scatton * fix(ci): address zizmor template injection findings Signed-off-by: Simon Scatton * fix(ci): resolve remaining zizmor annotations Signed-off-by: Simon Scatton --------- Signed-off-by: Simon Scatton --- .github/actions/build-docker-image/action.yml | 75 +++ .github/actions/build-rust-binary/action.yml | 113 ++++ .github/actions/check-job-results/action.yml | 33 + .github/actions/setup-e2e-cli/action.yml | 12 +- .github/actions/setup-e2e-driver/action.yml | 33 + .github/actions/setup-e2e-gateway/action.yml | 14 +- .github/actions/setup-e2e-kind/action.yml | 82 +++ .github/actions/setup-e2e-podman/action.yml | 126 ++++ .../actions/setup-e2e-vm-driver/action.yml | 25 +- .github/actions/setup-e2e-vm/action.yml | 66 ++ .github/actions/setup-mise/action.yml | 42 ++ .github/workflows/branch-e2e.yml | 390 +++++++----- .github/workflows/build-binaries.yml | 78 +++ .github/workflows/build-cli-binaries.yml | 51 ++ .github/workflows/build-gateway-binaries.yml | 55 ++ .github/workflows/build-sandbox-binaries.yml | 48 ++ .github/workflows/build-vm-driver.yml | 102 ++++ .github/workflows/deb-package.yml | 23 +- .github/workflows/docker-build.yml | 324 ++-------- .github/workflows/driver-vm-linux.yml | 221 ------- .github/workflows/driver-vm-macos.yml | 221 ------- .github/workflows/e2e-docker-test.yml | 109 ++++ .github/workflows/e2e-gpu-test.yaml | 16 - .github/workflows/e2e-kubernetes-test.yml | 83 +-- .github/workflows/e2e-podman-test.yml | 91 +++ .github/workflows/e2e-test.yml | 234 ------- .github/workflows/e2e-vm-test.yml | 58 ++ .../workflows/package-release-binaries.yml | 77 +++ .github/workflows/release-dev.yml | 561 +++-------------- .github/workflows/release-tag.yml | 574 +++--------------- .github/workflows/rpm-package.yml | 14 +- .github/workflows/rust-native-build.yml | 383 ------------ .github/workflows/snap-package.yml | 29 +- .../workflows/supervisor-static-validate.yml | 70 --- CI.md | 10 +- architecture/build.md | 25 +- deploy/docker/Dockerfile.gateway | 15 +- deploy/docker/Dockerfile.supervisor | 24 +- e2e/rust/e2e-vm.sh | 2 +- e2e/support/gateway-common.sh | 13 +- e2e/with-kube-gateway.sh | 17 +- flake.nix | 7 + nix/devShells/musl.nix | 2 +- nix/pkgs/vm-runtime.nix | 46 ++ nix/toolchains/linux-gnu-2.28/default.nix | 5 +- scripts/baseline_workflow_metrics.py | 16 +- 46 files changed, 1909 insertions(+), 2706 deletions(-) create mode 100644 .github/actions/build-docker-image/action.yml create mode 100644 .github/actions/build-rust-binary/action.yml create mode 100644 .github/actions/check-job-results/action.yml create mode 100644 .github/actions/setup-e2e-driver/action.yml create mode 100644 .github/actions/setup-e2e-kind/action.yml create mode 100644 .github/actions/setup-e2e-podman/action.yml create mode 100644 .github/actions/setup-e2e-vm/action.yml create mode 100644 .github/actions/setup-mise/action.yml create mode 100644 .github/workflows/build-binaries.yml create mode 100644 .github/workflows/build-cli-binaries.yml create mode 100644 .github/workflows/build-gateway-binaries.yml create mode 100644 .github/workflows/build-sandbox-binaries.yml create mode 100644 .github/workflows/build-vm-driver.yml delete mode 100644 .github/workflows/driver-vm-linux.yml delete mode 100644 .github/workflows/driver-vm-macos.yml create mode 100644 .github/workflows/e2e-docker-test.yml create mode 100644 .github/workflows/e2e-podman-test.yml delete mode 100644 .github/workflows/e2e-test.yml create mode 100644 .github/workflows/e2e-vm-test.yml create mode 100644 .github/workflows/package-release-binaries.yml delete mode 100644 .github/workflows/rust-native-build.yml delete mode 100644 .github/workflows/supervisor-static-validate.yml create mode 100644 nix/pkgs/vm-runtime.nix diff --git a/.github/actions/build-docker-image/action.yml b/.github/actions/build-docker-image/action.yml new file mode 100644 index 0000000000..08557fbfd4 --- /dev/null +++ b/.github/actions/build-docker-image/action.yml @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Build Docker Image +description: Build and push one architecture of an OpenShell image + +inputs: + component: + description: Docker image and Dockerfile name + required: true + binary: + description: Binary staged in the Docker build context + required: true + triple: + description: Binary artifact target triple + required: true + arch: + description: Docker architecture name + required: true + platform: + description: Docker platform + required: true + image-tag: + description: Docker image tag + required: true + github-token: + description: Token used to push the image + required: true + +runs: + using: composite + steps: + - uses: ./.github/actions/setup-buildx + with: + buildkitd-config: /etc/buildkit/buildkitd.toml + + - name: Log in to GHCR + shell: bash + run: echo "${INPUTS_GITHUB_TOKEN}" | docker login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin + env: + INPUTS_GITHUB_TOKEN: ${{ inputs.github-token }} + + - name: Download ${{ inputs.binary }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.binary }}-${{ inputs.triple }} + path: artifact + + - name: Stage ${{ inputs.binary }} + shell: bash + run: install -Dm0755 artifact/${INPUTS_BINARY} deploy/docker/.build/prebuilt-binaries/${INPUTS_ARCH}/${INPUTS_BINARY} + env: + INPUTS_BINARY: ${{ inputs.binary }} + INPUTS_ARCH: ${{ inputs.arch }} + + - name: Build ${{ inputs.component }} image + shell: bash + env: + IMAGE_TAG: ${{ inputs.image-tag }} + INPUTS_PLATFORM: ${{ inputs.platform }} + INPUTS_COMPONENT: ${{ inputs.component }} + INPUTS_ARCH: ${{ inputs.arch }} + run: | + docker buildx build \ + --builder openshell \ + --platform ${INPUTS_PLATFORM} \ + --file deploy/docker/Dockerfile.${INPUTS_COMPONENT} \ + --target ${INPUTS_COMPONENT} \ + --tag ghcr.io/nvidia/openshell/${INPUTS_COMPONENT}:${IMAGE_TAG}-${INPUTS_ARCH} \ + --cache-from type=gha,scope=${INPUTS_COMPONENT}-${INPUTS_ARCH} \ + --cache-to type=gha,mode=max,scope=${INPUTS_COMPONENT}-${INPUTS_ARCH} \ + --provenance=mode=min \ + --attest type=sbom \ + --output type=image,push=true,oci-mediatypes=true,oci-artifact=true \ + . diff --git a/.github/actions/build-rust-binary/action.yml b/.github/actions/build-rust-binary/action.yml new file mode 100644 index 0000000000..98ba51b88e --- /dev/null +++ b/.github/actions/build-rust-binary/action.yml @@ -0,0 +1,113 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Build Rust Binary +description: Build, verify, package, and upload an auditable Rust binary + +inputs: + package: + description: Cargo package that owns the binary + required: true + binary: + description: Cargo binary name + required: true + 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 + image-tag: + description: Default supervisor image tag embedded in the binary + required: false + default: "" + artifact-name: + description: GitHub artifact name + required: false + default: "" + extra-cargo-flags: + 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 + steps: + - 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" + + - name: Cache Rust artifacts + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + shared-key: binaries-${{ inputs.binary }}-${{ inputs.triple }}-${{ steps.dev-shell.outputs.hash }} + cache-on-failure: "true" + cache-workspace-crates: "true" + cache-bin: "false" + cmd-format: nix develop ${{ inputs.dev-shell }} -c {0} + + - name: Set version + shell: nix develop ${{ inputs.dev-shell }} -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} + env: + OPENSHELL_IMAGE_TAG: ${{ inputs.image-tag }} + INPUTS_PACKAGE: ${{ inputs.package }} + INPUTS_BINARY: ${{ inputs.binary }} + 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: Verify ${{ inputs.binary }} + shell: nix develop ${{ inputs.dev-shell }} -c bash -euo pipefail {0} + env: + INPUTS_BINARY: ${{ inputs.binary }} + run: | + # Confirm the binary runs and reports the expected name. + target/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" + + - 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 }} + compression-level: 0 + retention-days: 5 + if-no-files-found: error diff --git a/.github/actions/check-job-results/action.yml b/.github/actions/check-job-results/action.yml new file mode 100644 index 0000000000..bd6beb9456 --- /dev/null +++ b/.github/actions/check-job-results/action.yml @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Check job results +description: Fail when any required upstream job did not succeed + +inputs: + results: + description: JSON-encoded GitHub Actions needs context + required: true + +runs: + using: composite + steps: + - name: Check required jobs + shell: bash + env: + JOB_RESULTS: ${{ inputs.results }} + run: | + set -euo pipefail + failures="$( + jq -r ' + to_entries[] + | select(.value.result != "success") + | "\(.key) concluded \(.value.result)" + ' <<< "$JOB_RESULTS" + )" + if [ -n "$failures" ]; then + while IFS= read -r failure; do + echo "::error::$failure" + done <<< "$failures" + exit 1 + fi diff --git a/.github/actions/setup-e2e-cli/action.yml b/.github/actions/setup-e2e-cli/action.yml index fb894194c8..4d43e232a4 100644 --- a/.github/actions/setup-e2e-cli/action.yml +++ b/.github/actions/setup-e2e-cli/action.yml @@ -1,29 +1,19 @@ name: Setup E2E CLI description: Download an architecture-matched prebuilt OpenShell CLI for E2E tests -inputs: - artifact-prefix: - description: Artifact name prefix; linux- is appended automatically - required: true - runs: using: composite steps: - name: Download prebuilt CLI uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: ${{ format('{0}-linux-{1}', inputs.artifact-prefix, runner.arch == 'X64' && 'amd64' || 'arm64') }} + name: ${{ runner.arch == 'X64' && 'openshell-x86_64-unknown-linux-musl' || 'openshell-aarch64-unknown-linux-musl' }} path: .e2e/prebuilt-cli - name: Configure prebuilt CLI shell: bash run: | - set -euo pipefail cli="$GITHUB_WORKSPACE/.e2e/prebuilt-cli/openshell" - if [[ ! -f "$cli" ]]; then - echo "downloaded artifact is missing $cli" >&2 - exit 1 - fi chmod +x "$cli" "$cli" --version echo "OPENSHELL_BIN=$cli" >> "$GITHUB_ENV" diff --git a/.github/actions/setup-e2e-driver/action.yml b/.github/actions/setup-e2e-driver/action.yml new file mode 100644 index 0000000000..c2172b2a63 --- /dev/null +++ b/.github/actions/setup-e2e-driver/action.yml @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Setup E2E Driver +description: Download an architecture-matched standalone compute driver for E2E tests + +inputs: + binary: + description: Compute driver binary name + required: true + +runs: + using: composite + steps: + - name: Download prebuilt ${{ inputs.binary }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.binary }}-${{ runner.arch == 'X64' && 'x86_64-unknown-linux-gnu' || 'aarch64-unknown-linux-gnu' }} + path: .e2e/prebuilt-driver + + - name: Configure prebuilt ${{ inputs.binary }} + shell: bash + run: | # zizmor: ignore[github-env] validated filename under the trusted workspace path + if [[ ! "${INPUTS_BINARY}" =~ ^[a-zA-Z0-9._-]+$ ]]; then + echo "invalid driver binary name: ${INPUTS_BINARY}" >&2 + exit 1 + fi + driver="$GITHUB_WORKSPACE/.e2e/prebuilt-driver/${INPUTS_BINARY}" + chmod +x "$driver" + "$driver" --version + echo "OPENSHELL_EXTERNAL_DRIVER_BIN=$driver" >> "$GITHUB_ENV" + env: + INPUTS_BINARY: ${{ inputs.binary }} diff --git a/.github/actions/setup-e2e-gateway/action.yml b/.github/actions/setup-e2e-gateway/action.yml index b0ee52392b..255a7f4e4c 100644 --- a/.github/actions/setup-e2e-gateway/action.yml +++ b/.github/actions/setup-e2e-gateway/action.yml @@ -2,9 +2,10 @@ name: Setup E2E Gateway description: Download an architecture-matched prebuilt OpenShell gateway for E2E tests inputs: - artifact-prefix: - description: Artifact name prefix; linux- is appended automatically - required: true + artifact-name: + description: GitHub artifact name + required: false + default: "" runs: using: composite @@ -12,18 +13,13 @@ runs: - name: Download prebuilt gateway uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: ${{ format('{0}-linux-{1}', inputs.artifact-prefix, runner.arch == 'X64' && 'amd64' || 'arm64') }} + name: ${{ inputs.artifact-name || (runner.arch == 'X64' && 'openshell-gateway-x86_64-unknown-linux-gnu' || 'openshell-gateway-aarch64-unknown-linux-gnu') }} path: .e2e/prebuilt-gateway - name: Configure prebuilt gateway shell: bash run: | - set -euo pipefail gateway="$GITHUB_WORKSPACE/.e2e/prebuilt-gateway/openshell-gateway" - if [[ ! -f "$gateway" ]]; then - echo "downloaded artifact is missing $gateway" >&2 - exit 1 - fi chmod +x "$gateway" "$gateway" --version echo "OPENSHELL_GATEWAY_BIN=$gateway" >> "$GITHUB_ENV" diff --git a/.github/actions/setup-e2e-kind/action.yml b/.github/actions/setup-e2e-kind/action.yml new file mode 100644 index 0000000000..b9fa8375ba --- /dev/null +++ b/.github/actions/setup-e2e-kind/action.yml @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Setup E2E kind +description: Create a kind cluster and preload OpenShell images for E2E tests + +inputs: + cluster-name: + description: kind cluster name + required: true + image-tag: + description: OpenShell image tag to load + required: true + images: + description: Space-separated OpenShell image components to load + required: false + default: gateway supervisor + registry: + description: Container registry and namespace + required: false + default: ghcr.io/nvidia/openshell + registry-username: + description: Registry username + required: true + registry-password: + description: Registry password + required: true + mise-version: + description: mise release to install + required: false + default: v2026.4.25 + +runs: + using: composite + steps: + - uses: ./.github/actions/setup-mise + with: + version: ${{ inputs.mise-version }} + + - name: Log in to GHCR + shell: bash + env: + REGISTRY: ${{ inputs.registry }} + REGISTRY_USERNAME: ${{ inputs.registry-username }} + REGISTRY_PASSWORD: ${{ inputs.registry-password }} + run: echo "$REGISTRY_PASSWORD" | docker login "${REGISTRY%%/*}" -u "$REGISTRY_USERNAME" --password-stdin + + - name: Create kind cluster + uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0 + with: + cluster_name: ${{ inputs.cluster-name }} + wait: 120s + + - name: Export kind kubeconfig to mise path + shell: bash + env: + CLUSTER_NAME: ${{ inputs.cluster-name }} + run: | + set -euo pipefail + kind get kubeconfig --name "$CLUSTER_NAME" > "$GITHUB_WORKSPACE/kubeconfig" + chmod 600 "$GITHUB_WORKSPACE/kubeconfig" + + - name: Load OpenShell images into kind + shell: bash + env: + CLUSTER_NAME: ${{ inputs.cluster-name }} + IMAGE_COMPONENTS: ${{ inputs.images }} + IMAGE_TAG: ${{ inputs.image-tag }} + REGISTRY: ${{ inputs.registry }} + run: | + set -euo pipefail + for component in $IMAGE_COMPONENTS; do + case "$component" in + gateway | supervisor) ;; + *) echo "ERROR: unsupported OpenShell image component: $component" >&2; exit 1 ;; + esac + image="${REGISTRY}/${component}:${IMAGE_TAG}" + archive="${RUNNER_TEMP:-/tmp}/openshell-${component}-linux-amd64.tar" + docker pull --platform linux/amd64 "$image" + docker image save --platform linux/amd64 --output "$archive" "$image" + kind load image-archive "$archive" --name "$CLUSTER_NAME" + done diff --git a/.github/actions/setup-e2e-podman/action.yml b/.github/actions/setup-e2e-podman/action.yml new file mode 100644 index 0000000000..47f57f5be8 --- /dev/null +++ b/.github/actions/setup-e2e-podman/action.yml @@ -0,0 +1,126 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Setup E2E Podman +description: Configure the supported rootless Podman environment for E2E tests + +inputs: + mise-version: + description: mise release to install + required: false + default: v2026.4.25 + podman-major: + description: Expected Podman major version + required: true + podman-package-version: + description: Exact Ubuntu Podman package version + required: true + conmon-package-version: + description: Exact Ubuntu conmon package version + required: true + +runs: + using: composite + steps: + - uses: ./.github/actions/setup-mise + with: + version: ${{ inputs.mise-version }} + + - name: Install Podman and build dependencies + shell: bash + run: | # zizmor: ignore[github-env] persists only literal and trusted runner-derived paths + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + apparmor \ + build-essential \ + fuse-overlayfs \ + libssl-dev \ + openssh-client \ + passt \ + pkg-config \ + "conmon=${INPUTS_CONMON_PACKAGE_VERSION}" \ + "podman=${INPUTS_PODMAN_PACKAGE_VERSION}" \ + uidmap + # Hosted runners can place newer binaries under /usr/local. Select the + # distro CLI and use Podman's supported final conmon-path override. + podman_config="${RUNNER_TEMP}/openshell-containers.conf" + printf '%s\n' \ + '[engine]' \ + 'conmon_path = ["/usr/bin/conmon"]' \ + > "${podman_config}" + echo "/usr/bin" >> "${GITHUB_PATH}" + echo "CONTAINERS_CONF_OVERRIDE=${podman_config}" >> "${GITHUB_ENV}" + env: + INPUTS_CONMON_PACKAGE_VERSION: ${{ inputs.conmon-package-version }} + INPUTS_PODMAN_PACKAGE_VERSION: ${{ inputs.podman-package-version }} + + - name: Allow pasta to receive Podman stop signals + shell: bash + run: | + set -euo pipefail + # Ubuntu's packaged profile blocks this signal and makes Podman wait + # for its SIGKILL fallback. + profile=/etc/apparmor.d/usr.bin.pasta + rule=' signal (receive) peer=podman,' + if ! sudo grep -Fqx "${rule}" "${profile}"; then + sudo sed -i '\|^ include $|a\ signal (receive) peer=podman,' "${profile}" + fi + sudo grep -Fqx "${rule}" "${profile}" + sudo apparmor_parser --replace "${profile}" + + - name: Configure rootless Podman + shell: bash + run: | # zizmor: ignore[github-env] runtime path is derived solely from the runner UID + set -euo pipefail + if ! grep -q "^${USER}:" /etc/subuid; then + sudo usermod --add-subuids 100000-165535 "$USER" + fi + if ! grep -q "^${USER}:" /etc/subgid; then + sudo usermod --add-subgids 100000-165535 "$USER" + fi + runtime_dir="/run/user/$(id -u)" + sudo install -d -m 0700 -o "$(id -u)" -g "$(id -g)" "$runtime_dir" + echo "XDG_RUNTIME_DIR=$runtime_dir" >> "$GITHUB_ENV" + + - name: Verify rootless Podman environment + shell: bash + run: | + set -euo pipefail + podman_version="$(podman version --format '{{.Client.Version}}')" + case "$podman_version" in + "${INPUTS_PODMAN_MAJOR}".*) ;; + *) echo "ERROR: expected Podman ${INPUTS_PODMAN_MAJOR}.x, found $podman_version" >&2; exit 1 ;; + esac + test "$(dpkg-query -W -f='${Version}' podman)" = "${INPUTS_PODMAN_PACKAGE_VERSION}" + test "$(dpkg-query -W -f='${Version}' conmon)" = "${INPUTS_CONMON_PACKAGE_VERSION}" + test "$(command -v podman)" = "/usr/bin/podman" + test "$(podman info --format '{{.Host.Conmon.Path}}')" = "/usr/bin/conmon" + test "$(podman info --format '{{.Host.Security.Rootless}}')" = "true" + test "$(podman info --format '{{.Host.RootlessNetworkCmd}}')" = "pasta" + test "$(sudo sysctl -n kernel.apparmor_restrict_unprivileged_userns)" = "1" + echo "=== host ===" + uname -a + echo "=== AppArmor ===" + cat /proc/self/attr/current + sudo aa-status || true + echo "=== Podman ===" + podman version + podman info --debug + env: + INPUTS_PODMAN_MAJOR: ${{ inputs.podman-major }} + INPUTS_PODMAN_PACKAGE_VERSION: ${{ inputs.podman-package-version }} + INPUTS_CONMON_PACKAGE_VERSION: ${{ inputs.conmon-package-version }} + + - name: Probe rootless capability bounding set + shell: bash + run: | + set -euo pipefail + probe="$RUNNER_TEMP/openshell-capbset-probe" + cc -static -O2 -Wall -Wextra -Werror \ + e2e/support/capbset-probe.c \ + -o "$probe" + podman run --rm \ + --cap-add=SETPCAP \ + --volume "$probe:/openshell-capbset-probe:ro" \ + docker.io/library/alpine:3.22 \ + /openshell-capbset-probe diff --git a/.github/actions/setup-e2e-vm-driver/action.yml b/.github/actions/setup-e2e-vm-driver/action.yml index 20c43cf98f..a074ed97a8 100644 --- a/.github/actions/setup-e2e-vm-driver/action.yml +++ b/.github/actions/setup-e2e-vm-driver/action.yml @@ -1,10 +1,5 @@ name: Setup E2E VM Driver -description: Download a prebuilt OpenShell VM driver for E2E tests - -inputs: - artifact-name: - description: Artifact name to download, such as driver-vm-linux-amd64 - required: true +description: Download an architecture-matched prebuilt OpenShell VM driver for E2E tests runs: using: composite @@ -12,27 +7,13 @@ runs: - name: Download prebuilt VM driver uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: ${{ inputs.artifact-name }} + name: ${{ runner.arch == 'X64' && 'openshell-driver-vm-x86_64-unknown-linux-gnu' || 'openshell-driver-vm-aarch64-unknown-linux-gnu' }} path: .e2e/prebuilt-vm-driver - name: Configure prebuilt VM driver shell: bash run: | - set -euo pipefail - extract_dir="$GITHUB_WORKSPACE/.e2e/prebuilt-vm-driver/bin" - mkdir -p "$extract_dir" - archive="$(find "$GITHUB_WORKSPACE/.e2e/prebuilt-vm-driver" -maxdepth 1 -type f -name 'openshell-driver-vm-*.tar.gz' -print -quit)" - if [[ -z "$archive" ]]; then - echo "downloaded artifact is missing openshell-driver-vm-*.tar.gz" >&2 - find "$GITHUB_WORKSPACE/.e2e/prebuilt-vm-driver" -maxdepth 2 -type f -print >&2 || true - exit 1 - fi - tar -xzf "$archive" -C "$extract_dir" - driver="$extract_dir/openshell-driver-vm" - if [[ ! -f "$driver" ]]; then - echo "downloaded artifact is missing $driver" >&2 - exit 1 - fi + driver="$GITHUB_WORKSPACE/.e2e/prebuilt-vm-driver/openshell-driver-vm" chmod +x "$driver" "$driver" --version echo "OPENSHELL_VM_DRIVER_BIN=$driver" >> "$GITHUB_ENV" diff --git a/.github/actions/setup-e2e-vm/action.yml b/.github/actions/setup-e2e-vm/action.yml new file mode 100644 index 0000000000..6164d999c5 --- /dev/null +++ b/.github/actions/setup-e2e-vm/action.yml @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Setup E2E VM +description: Configure a bare Linux KVM host for VM E2E tests + +inputs: + mise-version: + description: mise release to install + required: false + default: v2026.4.25 + rust-cache-key: + description: Shared key for Rust test artifacts + required: false + default: e2e-vm-linux-amd64 + +runs: + using: composite + steps: + - name: Install system dependencies + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential \ + libssl-dev \ + openssh-client \ + pkg-config \ + socat \ + zstd + + - name: Enable KVM access + shell: bash + run: | + set -euo pipefail + if [[ ! -c /dev/kvm ]]; then + echo "::error::The GitHub-hosted runner did not expose /dev/kvm" + lscpu + grep -m1 -E '^(flags|Features)' /proc/cpuinfo || true + ls -la /dev + exit 1 + fi + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --settle --name-match=kvm + ls -l /dev/kvm + exec 3<>/dev/kvm + exec 3>&- + + - name: Validate VM host tools + shell: bash + run: | + command -v mke2fs + command -v mkfs.ext4 + command -v debugfs + + - uses: ./.github/actions/setup-mise + with: + version: ${{ inputs.mise-version }} + + - name: Cache Rust target and registry + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + shared-key: ${{ inputs.rust-cache-key }} + cache-on-failure: "true" diff --git a/.github/actions/setup-mise/action.yml b/.github/actions/setup-mise/action.yml new file mode 100644 index 0000000000..e5a94a6801 --- /dev/null +++ b/.github/actions/setup-mise/action.yml @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Setup mise +description: Install mise and the tools pinned by the repository + +inputs: + version: + description: mise release to install + required: false + default: v2026.4.25 + +runs: + using: composite + steps: + - name: Restore mise cache + id: mise-cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: ~/.local/share/mise + key: mise-${{ runner.os }}-${{ runner.arch }}-${{ inputs.version }}-${{ hashFiles('mise.toml', 'mise.lock') }} + restore-keys: | + mise-${{ runner.os }}-${{ runner.arch }}-${{ inputs.version }}- + + - name: Install mise and tools + shell: bash + env: + MISE_VERSION: ${{ inputs.version }} + run: | # zizmor: ignore[github-env] PATH entries are fixed beneath the runner-controlled HOME + set -euo pipefail + curl https://mise.run | sh + export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + echo "$HOME/.local/share/mise/shims" >> "$GITHUB_PATH" + mise install --locked + + - name: Save mise cache + if: steps.mise-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: ~/.local/share/mise + key: ${{ steps.mise-cache.outputs.cache-primary-key }} diff --git a/.github/workflows/branch-e2e.yml b/.github/workflows/branch-e2e.yml index 659027a8d6..0b050d0e51 100644 --- a/.github/workflows/branch-e2e.yml +++ b/.github/workflows/branch-e2e.yml @@ -44,8 +44,8 @@ jobs: push) run_core_e2e="$(jq -r 'index("test:e2e") != null' <<< "$LABELS_JSON")" run_gpu_e2e="$(jq -r 'index("test:e2e-gpu") != null' <<< "$LABELS_JSON")" - run_kubernetes_ha_e2e="$(jq -r 'index("test:e2e-kubernetes") != null' <<< "$LABELS_JSON")" - run_kubernetes_credential_drivers_e2e="$(jq -r 'index("test:e2e-kubernetes") != null' <<< "$LABELS_JSON")" + run_kubernetes_ha_e2e=false + run_kubernetes_credential_drivers_e2e=false ;; merge_group) # Merge groups have no PR labels. When GPU E2E is required as documented @@ -59,8 +59,8 @@ jobs: *) run_core_e2e=true run_gpu_e2e=true - run_kubernetes_ha_e2e=true - run_kubernetes_credential_drivers_e2e=true + run_kubernetes_ha_e2e=false + run_kubernetes_credential_drivers_e2e=false ;; esac if [ "$run_core_e2e" = "true" ] || [ "$run_gpu_e2e" = "true" ] || [ "$run_kubernetes_ha_e2e" = "true" ] || [ "$run_kubernetes_credential_drivers_e2e" = "true" ]; then @@ -76,20 +76,159 @@ jobs: echo "run_any_e2e=$run_any_e2e" } >> "$GITHUB_OUTPUT" - build-gateway: + version: needs: [pr_metadata] if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_any_e2e == 'true' + permissions: + contents: read + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + cargo: ${{ steps.version.outputs.cargo }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Compute version + id: version + run: | + cargo="$(python3 tasks/scripts/release.py get-version --cargo)" + echo "cargo=$cargo" >> "$GITHUB_OUTPUT" + + build-cli: + needs: version + permissions: + contents: read + uses: ./.github/workflows/build-cli-binaries.yml + with: + cargo-version: ${{ needs.version.outputs.cargo }} + secrets: inherit + + build-sandbox: + needs: version + permissions: + contents: read + uses: ./.github/workflows/build-sandbox-binaries.yml + with: + cargo-version: ${{ needs.version.outputs.cargo }} + secrets: inherit + + build-gateway: + needs: version + permissions: + contents: read + uses: ./.github/workflows/build-gateway-binaries.yml + with: + cargo-version: ${{ needs.version.outputs.cargo }} + secrets: inherit + + build-gateway-plain: + needs: [pr_metadata, version] + if: needs.pr_metadata.outputs.run_core_e2e == 'true' + permissions: + contents: read + strategy: + matrix: + 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 + 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: + needs: [pr_metadata, version] + if: needs.pr_metadata.outputs.run_core_e2e == 'true' + permissions: + contents: read + uses: ./.github/workflows/build-binaries.yml + with: + package: openshell-driver-docker + 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: + needs: [pr_metadata, version] + if: needs.pr_metadata.outputs.run_core_e2e == 'true' + permissions: + contents: read + uses: ./.github/workflows/build-binaries.yml + with: + package: openshell-driver-podman + 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: + needs: [pr_metadata, version] + if: needs.pr_metadata.outputs.run_core_e2e == 'true' + permissions: + contents: read + uses: ./.github/workflows/build-binaries.yml + with: + package: openshell-driver-kubernetes + 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: + needs: [pr_metadata, version, build-sandbox] + if: needs.pr_metadata.outputs.run_core_e2e == 'true' + permissions: + contents: read + uses: ./.github/workflows/build-vm-driver.yml + with: + cargo-version: ${{ needs.version.outputs.cargo }} + secrets: inherit + + build-gateway-image: + needs: [pr_metadata, build-gateway] + if: >- + needs.pr_metadata.outputs.should_run == 'true' && + (needs.pr_metadata.outputs.run_core_e2e == 'true' || + needs.pr_metadata.outputs.run_kubernetes_ha_e2e == 'true' || + needs.pr_metadata.outputs.run_kubernetes_credential_drivers_e2e == 'true') permissions: contents: read packages: write uses: ./.github/workflows/docker-build.yml with: component: gateway - image-tag: ${{ github.sha }} - auditable: true + binary: openshell-gateway + target-suffix: unknown-linux-gnu + secrets: inherit - build-supervisor: - needs: [pr_metadata] + build-supervisor-image: + needs: [pr_metadata, build-sandbox] if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_any_e2e == 'true' permissions: contents: read @@ -97,51 +236,88 @@ jobs: uses: ./.github/workflows/docker-build.yml with: component: supervisor - image-tag: ${{ github.sha }} - auditable: true + binary: openshell-sandbox + target-suffix: unknown-linux-musl + secrets: inherit - build-cli: - needs: [pr_metadata] - if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_any_e2e == 'true' + docker-e2e: + needs: [pr_metadata, build-cli, build-gateway, build-supervisor-image] + if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true' permissions: + actions: read contents: read - packages: write - uses: ./.github/workflows/docker-build.yml + packages: read + uses: ./.github/workflows/e2e-docker-test.yml with: - component: cli - platform: linux/amd64,linux/arm64 + image-tag: ${{ github.sha }} + runner: linux-arm64-cpu8 - build-driver-vm-linux: - needs: [pr_metadata] + podman-e2e: + needs: [pr_metadata, build-cli, build-gateway, build-supervisor-image] 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/driver-vm-linux.yml + uses: ./.github/workflows/e2e-podman-test.yml with: image-tag: ${{ github.sha }} - checkout-ref: ${{ github.sha }} - runtime-platforms: linux-x86_64 - driver-targets: >- - [{"arch":"amd64","runner":"linux-amd64-cpu8","target":"x86_64-unknown-linux-gnu","zig_target":"x86_64-unknown-linux-gnu.2.28","platform":"linux-x86_64","guest_arch":"x86_64"}] - e2e: - needs: [pr_metadata, build-gateway, build-supervisor, build-cli, build-driver-vm-linux] + vm-e2e: + needs: [pr_metadata, build-cli, 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-test.yml + uses: ./.github/workflows/e2e-vm-test.yml + + docker-external-driver-e2e: + needs: [pr_metadata, build-cli, 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 + contents: read + packages: read + uses: ./.github/workflows/e2e-docker-test.yml with: image-tag: ${{ github.sha }} runner: linux-arm64-cpu8 - cli-artifact-prefix: rust-binary-cli - gateway-artifact-prefix: rust-binary-gateway - vm-driver-artifact-name: driver-vm-linux-amd64 + gateway-artifact: openshell-gateway-plain-aarch64-unknown-linux-gnu + external-driver-binary: openshell-driver-docker + 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] + 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-podman-test.yml + with: + image-tag: ${{ github.sha }} + gateway-artifact: openshell-gateway-plain-x86_64-unknown-linux-gnu + external-driver-binary: openshell-driver-podman + 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] + 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: + gateway-artifact: openshell-gateway-plain-x86_64-unknown-linux-gnu + suite-name: external-driver + e2e-task: e2e:vm:external-driver gpu-e2e: - needs: [pr_metadata, build-gateway, build-supervisor, build-cli] + needs: [pr_metadata, build-cli, build-gateway, build-supervisor-image] if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_gpu_e2e == 'true' permissions: actions: read @@ -150,11 +326,9 @@ jobs: uses: ./.github/workflows/e2e-gpu-test.yaml with: image-tag: ${{ github.sha }} - cli-artifact-prefix: rust-binary-cli - gateway-artifact-prefix: rust-binary-gateway kubernetes-e2e: - needs: [pr_metadata, build-gateway, build-supervisor, build-cli] + needs: [pr_metadata, build-cli, 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 @@ -182,10 +356,9 @@ jobs: job-name: Kubernetes E2E (Rust smoke, ${{ matrix.topology }}, Agent Sandbox ${{ matrix.agent_sandbox_api }}) agent-sandbox-version: ${{ matrix.agent_sandbox_version }} extra-helm-values: ${{ matrix.extra_helm_values }} - cli-artifact-prefix: rust-binary-cli kubernetes-workspace-managed-e2e: - needs: [pr_metadata, build-gateway, build-supervisor, build-cli] + needs: [pr_metadata, build-cli, 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 @@ -196,10 +369,9 @@ jobs: image-tag: ${{ github.sha }} job-name: Kubernetes E2E (workspace managed mode) e2e-task: e2e:kubernetes:workspace-managed - cli-artifact-prefix: rust-binary-cli kubernetes-external-driver-e2e: - needs: [pr_metadata, build-gateway, build-supervisor, build-cli] + needs: [pr_metadata, build-cli, 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 @@ -210,10 +382,12 @@ jobs: image-tag: ${{ github.sha }} job-name: Kubernetes E2E (external compute driver) e2e-task: e2e:kubernetes:external-driver - cli-artifact-prefix: rust-binary-cli + gateway-artifact: openshell-gateway-plain-x86_64-unknown-linux-gnu + external-driver-binary: openshell-driver-kubernetes + cluster-images: supervisor kubernetes-workspace-operator-e2e: - needs: [pr_metadata, build-gateway, build-supervisor, build-cli] + needs: [pr_metadata, build-cli, 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 @@ -224,10 +398,9 @@ jobs: image-tag: ${{ github.sha }} job-name: Kubernetes E2E (workspace operator mode) e2e-task: e2e:kubernetes:workspace-operator - cli-artifact-prefix: rust-binary-cli kubernetes-ha-e2e: - needs: [pr_metadata, build-gateway, build-supervisor, build-cli] + needs: [pr_metadata, build-cli, 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 @@ -239,10 +412,9 @@ jobs: job-name: Kubernetes HA E2E (Rust smoke) extra-helm-values: deploy/helm/openshell/ci/values-high-availability.yaml external-postgres-secret: openshell-ha-pg - cli-artifact-prefix: rust-binary-cli kubernetes-credential-drivers-e2e: - needs: [pr_metadata, build-gateway, build-supervisor] + needs: [pr_metadata, build-cli, 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 @@ -256,124 +428,52 @@ jobs: core-e2e-result: name: Core E2E result - needs: [pr_metadata, build-gateway, build-supervisor, build-cli, build-driver-vm-linux, e2e, kubernetes-e2e, kubernetes-external-driver-e2e, kubernetes-workspace-managed-e2e, kubernetes-workspace-operator-e2e] + needs: [pr_metadata, docker-e2e, podman-e2e, vm-e2e, docker-external-driver-e2e, podman-external-driver-e2e, vm-external-driver-e2e, kubernetes-e2e, kubernetes-external-driver-e2e, kubernetes-workspace-managed-e2e, kubernetes-workspace-operator-e2e] if: always() && needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true' runs-on: ubuntu-latest steps: - - name: Verify core E2E jobs - env: - BUILD_GATEWAY_RESULT: ${{ needs.build-gateway.result }} - BUILD_SUPERVISOR_RESULT: ${{ needs.build-supervisor.result }} - BUILD_CLI_RESULT: ${{ needs.build-cli.result }} - BUILD_DRIVER_VM_RESULT: ${{ needs.build-driver-vm-linux.result }} - E2E_RESULT: ${{ needs.e2e.result }} - KUBERNETES_E2E_RESULT: ${{ needs.kubernetes-e2e.result }} - KUBERNETES_EXTERNAL_DRIVER_E2E_RESULT: ${{ needs.kubernetes-external-driver-e2e.result }} - KUBERNETES_WORKSPACE_MANAGED_E2E_RESULT: ${{ needs.kubernetes-workspace-managed-e2e.result }} - KUBERNETES_WORKSPACE_OPERATOR_E2E_RESULT: ${{ needs.kubernetes-workspace-operator-e2e.result }} - run: | - set -euo pipefail - failed=0 - for item in \ - "build-gateway:$BUILD_GATEWAY_RESULT" \ - "build-supervisor:$BUILD_SUPERVISOR_RESULT" \ - "build-cli:$BUILD_CLI_RESULT" \ - "build-driver-vm-linux:$BUILD_DRIVER_VM_RESULT" \ - "e2e:$E2E_RESULT" \ - "kubernetes-e2e:$KUBERNETES_E2E_RESULT" \ - "kubernetes-external-driver-e2e:$KUBERNETES_EXTERNAL_DRIVER_E2E_RESULT" \ - "kubernetes-workspace-managed-e2e:$KUBERNETES_WORKSPACE_MANAGED_E2E_RESULT" \ - "kubernetes-workspace-operator-e2e:$KUBERNETES_WORKSPACE_OPERATOR_E2E_RESULT"; do - name="${item%%:*}" - result="${item#*:}" - if [ "$result" != "success" ]; then - echo "::error::$name concluded $result" - failed=1 - fi - done - exit "$failed" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/check-job-results + with: + results: ${{ toJSON(needs) }} gpu-e2e-result: name: GPU E2E result - needs: [pr_metadata, build-gateway, build-supervisor, build-cli, gpu-e2e] + needs: [pr_metadata, gpu-e2e] if: always() && needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_gpu_e2e == 'true' runs-on: ubuntu-latest steps: - - name: Verify GPU E2E jobs - env: - BUILD_GATEWAY_RESULT: ${{ needs.build-gateway.result }} - BUILD_SUPERVISOR_RESULT: ${{ needs.build-supervisor.result }} - BUILD_CLI_RESULT: ${{ needs.build-cli.result }} - GPU_E2E_RESULT: ${{ needs.gpu-e2e.result }} - run: | - set -euo pipefail - failed=0 - for item in \ - "build-gateway:$BUILD_GATEWAY_RESULT" \ - "build-supervisor:$BUILD_SUPERVISOR_RESULT" \ - "build-cli:$BUILD_CLI_RESULT" \ - "gpu-e2e:$GPU_E2E_RESULT"; do - name="${item%%:*}" - result="${item#*:}" - if [ "$result" != "success" ]; then - echo "::error::$name concluded $result" - failed=1 - fi - done - exit "$failed" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/check-job-results + with: + results: ${{ toJSON(needs) }} kubernetes-ha-e2e-result: name: Kubernetes HA E2E result - needs: [pr_metadata, build-gateway, build-supervisor, build-cli, kubernetes-ha-e2e] + needs: [pr_metadata, kubernetes-ha-e2e] if: always() && needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_kubernetes_ha_e2e == 'true' runs-on: ubuntu-latest steps: - - name: Verify Kubernetes HA E2E jobs - env: - BUILD_GATEWAY_RESULT: ${{ needs.build-gateway.result }} - BUILD_SUPERVISOR_RESULT: ${{ needs.build-supervisor.result }} - BUILD_CLI_RESULT: ${{ needs.build-cli.result }} - KUBERNETES_HA_E2E_RESULT: ${{ needs.kubernetes-ha-e2e.result }} - run: | - set -euo pipefail - failed=0 - for item in \ - "build-gateway:$BUILD_GATEWAY_RESULT" \ - "build-supervisor:$BUILD_SUPERVISOR_RESULT" \ - "build-cli:$BUILD_CLI_RESULT" \ - "kubernetes-ha-e2e:$KUBERNETES_HA_E2E_RESULT"; do - name="${item%%:*}" - result="${item#*:}" - if [ "$result" != "success" ]; then - echo "::error::$name concluded $result" - failed=1 - fi - done - exit "$failed" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/check-job-results + with: + results: ${{ toJSON(needs) }} kubernetes-credential-drivers-e2e-result: name: Kubernetes Credential Drivers E2E result - needs: [pr_metadata, build-gateway, build-supervisor, kubernetes-credential-drivers-e2e] + needs: [pr_metadata, kubernetes-credential-drivers-e2e] if: always() && needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_kubernetes_credential_drivers_e2e == 'true' runs-on: ubuntu-latest steps: - - name: Verify Kubernetes credential drivers E2E jobs - env: - BUILD_GATEWAY_RESULT: ${{ needs.build-gateway.result }} - BUILD_SUPERVISOR_RESULT: ${{ needs.build-supervisor.result }} - KUBERNETES_CREDENTIAL_DRIVERS_E2E_RESULT: ${{ needs.kubernetes-credential-drivers-e2e.result }} - run: | - set -euo pipefail - failed=0 - for item in \ - "build-gateway:$BUILD_GATEWAY_RESULT" \ - "build-supervisor:$BUILD_SUPERVISOR_RESULT" \ - "kubernetes-credential-drivers-e2e:$KUBERNETES_CREDENTIAL_DRIVERS_E2E_RESULT"; do - name="${item%%:*}" - result="${item#*:}" - if [ "$result" != "success" ]; then - echo "::error::$name concluded $result" - failed=1 - fi - done - exit "$failed" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/check-job-results + with: + results: ${{ toJSON(needs) }} diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml new file mode 100644 index 0000000000..1305108fdf --- /dev/null +++ b/.github/workflows/build-binaries.yml @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Build Binary + +on: + workflow_call: + inputs: + package: + required: true + type: string + binary: + required: true + type: string + triple: + required: true + type: string + runner: + required: true + type: string + dev-shell: + required: true + type: string + cargo-version: + required: true + type: string + image-tag: + required: false + type: string + default: "" + artifact-name: + required: false + type: string + default: "" + extra-cargo-flags: + required: false + type: string + default: "" + interpreter: + required: false + type: string + default: "" + checkout-ref: + required: false + type: string + default: "" + secrets: + CACHIX_AUTH_TOKEN: + required: true + +permissions: + contents: read + +jobs: + build: + name: ${{ inputs.binary }} (${{ inputs.triple }}) + runs-on: ${{ inputs.runner }} + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs['checkout-ref'] || github.sha }} + + - uses: ./.github/actions/setup-nix + with: + cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - uses: ./.github/actions/build-rust-binary + with: + 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 }} diff --git a/.github/workflows/build-cli-binaries.yml b/.github/workflows/build-cli-binaries.yml new file mode 100644 index 0000000000..b8fa96dd7d --- /dev/null +++ b/.github/workflows/build-cli-binaries.yml @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Build CLI Binaries + +on: + workflow_call: + inputs: + cargo-version: + required: true + type: string + image-tag: + required: false + type: string + default: "" + checkout-ref: + required: false + type: string + default: "" + secrets: + CACHIX_AUTH_TOKEN: + required: true + +permissions: + contents: read + +jobs: + build: + strategy: + matrix: + 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 }} + secrets: inherit diff --git a/.github/workflows/build-gateway-binaries.yml b/.github/workflows/build-gateway-binaries.yml new file mode 100644 index 0000000000..ed5219e7c6 --- /dev/null +++ b/.github/workflows/build-gateway-binaries.yml @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Build Gateway Binaries + +on: + workflow_call: + inputs: + cargo-version: + required: true + type: string + image-tag: + required: false + type: string + default: "" + checkout-ref: + required: false + type: string + default: "" + secrets: + CACHIX_AUTH_TOKEN: + required: true + +permissions: + contents: read + +jobs: + build: + strategy: + matrix: + 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 + 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 diff --git a/.github/workflows/build-sandbox-binaries.yml b/.github/workflows/build-sandbox-binaries.yml new file mode 100644 index 0000000000..53f81bec40 --- /dev/null +++ b/.github/workflows/build-sandbox-binaries.yml @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Build Sandbox Binaries + +on: + workflow_call: + inputs: + cargo-version: + required: true + type: string + image-tag: + required: false + type: string + default: "" + checkout-ref: + required: false + type: string + default: "" + secrets: + CACHIX_AUTH_TOKEN: + required: true + +permissions: + contents: read + +jobs: + build: + strategy: + matrix: + 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 }} + secrets: inherit diff --git a/.github/workflows/build-vm-driver.yml b/.github/workflows/build-vm-driver.yml new file mode 100644 index 0000000000..a83c8c0834 --- /dev/null +++ b/.github/workflows/build-vm-driver.yml @@ -0,0 +1,102 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Build VM Driver + +on: + workflow_call: + inputs: + cargo-version: + required: true + type: string + image-tag: + required: false + type: string + default: "" + checkout-ref: + required: false + type: string + default: "" + secrets: + CACHIX_AUTH_TOKEN: + required: true + +permissions: + contents: read + +jobs: + build: + name: openshell-driver-vm (${{ matrix.triple }}) + strategy: + matrix: + include: + - 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} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs['checkout-ref'] || github.sha }} + + - uses: ./.github/actions/setup-nix + with: + cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - name: Download openshell-sandbox + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: openshell-sandbox-${{ matrix.arch }}-unknown-linux-musl + path: sandbox + + - name: Build VM runtime + run: nix build .#vm-runtime + + - name: Compress Linux VM runtime + if: runner.os == 'Linux' + run: | + install -d target/vm-runtime-compressed + zstd -19 -T0 result/libkrun.so -o target/vm-runtime-compressed/libkrun.so.zst + zstd -19 -T0 result/libkrunfw.so.5 -o target/vm-runtime-compressed/libkrunfw.so.5.zst + zstd -19 -T0 result/gvproxy -o target/vm-runtime-compressed/gvproxy.zst + zstd -19 -T0 result/umoci -o target/vm-runtime-compressed/umoci.zst + + - name: Compress macOS VM runtime + if: runner.os == 'macOS' + run: | + install -d target/vm-runtime-compressed + zstd -19 -T0 result/libkrun.dylib -o target/vm-runtime-compressed/libkrun.dylib.zst + zstd -19 -T0 result/libkrunfw.5.dylib -o target/vm-runtime-compressed/libkrunfw.5.dylib.zst + zstd -19 -T0 result/gvproxy -o target/vm-runtime-compressed/gvproxy.zst + zstd -19 -T0 result/umoci -o target/vm-runtime-compressed/umoci.zst + + - name: Add openshell-sandbox to VM runtime + run: zstd -19 -T0 sandbox/openshell-sandbox -o target/vm-runtime-compressed/openshell-sandbox.zst + + - name: Build openshell-driver-vm + uses: ./.github/actions/build-rust-binary + env: + OPENSHELL_VM_RUNTIME_COMPRESSED_DIR: ${{ github.workspace }}/target/vm-runtime-compressed + with: + 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 }} diff --git a/.github/workflows/deb-package.yml b/.github/workflows/deb-package.yml index 3929203909..7008acd4ed 100644 --- a/.github/workflows/deb-package.yml +++ b/.github/workflows/deb-package.yml @@ -49,28 +49,25 @@ jobs: - name: Download CLI artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: cli-linux-${{ matrix.arch }} - path: package-input/ + name: openshell-${{ matrix.cli_target }} + path: package-binaries/ - name: Download gateway artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: gateway-binary-linux-${{ matrix.arch }} - path: package-input/ + name: openshell-gateway-${{ matrix.gnu_target }} + path: package-binaries/ - name: Download VM driver artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: driver-vm-linux-${{ matrix.arch }} - path: package-input/ + name: openshell-driver-vm-${{ matrix.gnu_target }} + path: package-binaries/ - - name: Extract package inputs + - name: Configure package inputs run: | set -euo pipefail - mkdir -p package-binaries - tar -xzf "package-input/openshell-${{ matrix.cli_target }}.tar.gz" -C package-binaries - tar -xzf "package-input/openshell-gateway-${{ matrix.gnu_target }}.tar.gz" -C package-binaries - tar -xzf "package-input/openshell-driver-vm-${{ matrix.gnu_target }}.tar.gz" -C package-binaries + chmod +x package-binaries/openshell{,-gateway,-driver-vm} ls -lah package-binaries - name: Build Debian package @@ -79,10 +76,12 @@ jobs: OPENSHELL_CLI_BINARY="${PWD}/package-binaries/openshell" \ OPENSHELL_GATEWAY_BINARY="${PWD}/package-binaries/openshell-gateway" \ OPENSHELL_DRIVER_VM_BINARY="${PWD}/package-binaries/openshell-driver-vm" \ - OPENSHELL_DEB_VERSION="${{ inputs['deb-version'] }}" \ + OPENSHELL_DEB_VERSION="${INPUTS_DEB_VERSION}" \ OPENSHELL_DEB_ARCH="${{ matrix.deb_arch }}" \ OPENSHELL_OUTPUT_DIR=artifacts \ tasks/scripts/package-deb.sh + env: + INPUTS_DEB_VERSION: ${{ inputs['deb-version'] }} - name: Upload Debian package artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 73cb678e78..d27ba1e984 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -1,326 +1,94 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + name: Docker Build on: workflow_call: inputs: component: - description: "Component to build (gateway, supervisor, cli)" required: true type: string - timeout-minutes: - description: "Per-arch Docker image job timeout in minutes" - required: false - type: number - default: 20 - push: - description: "Push image to registry" - required: false - type: boolean - default: true - platform: - description: "Target platform(s) for Docker build" - required: false - type: string - default: "linux/amd64,linux/arm64" - runner: - description: "Deprecated; per-arch native runners are selected automatically" - required: false + binary: + required: true type: string - default: "linux-amd64-cpu8" - cargo-version: - description: "Pre-computed cargo version (skips internal git-based computation)" - required: false + target-suffix: + required: true type: string - default: "" - auditable: - description: "Embed cargo-auditable dependency metadata in image binaries" - required: false - type: boolean - default: false image-tag: - description: "Image tag base to build/push (defaults to the GitHub SHA)" required: false type: string default: "" checkout-ref: - description: "Git ref to check out for build inputs (defaults to the workflow SHA)" required: false type: string default: "" - publish-manifest: - description: "Push the bare-SHA manifest. Set false for single-arch branch workflows." - required: false - type: boolean - default: true - -env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} permissions: contents: read packages: write -defaults: - run: - shell: bash - jobs: - resolve: - name: Resolve build plan - runs-on: ubuntu-latest - outputs: - matrix: ${{ steps.resolve.outputs.matrix }} - platform_count: ${{ steps.resolve.outputs.platform_count }} - arches: ${{ steps.resolve.outputs.arches }} - binary_component: ${{ steps.resolve.outputs.binary_component }} - binary_name: ${{ steps.resolve.outputs.binary_name }} - artifact_prefix: ${{ steps.resolve.outputs.artifact_prefix }} - image_tag_base: ${{ steps.resolve.outputs.image_tag_base }} - features: ${{ steps.resolve.outputs.features }} - has_image: ${{ steps.resolve.outputs.has_image }} - steps: - - name: Resolve component and platform matrix - id: resolve - run: | - set -euo pipefail - - component="${{ inputs.component }}" - case "$component" in - gateway) - binary_component=gateway - binary_name=openshell-gateway - features="bundled-z3" - has_image=true - ;; - supervisor) - binary_component=sandbox - binary_name=openshell-sandbox - features="" - has_image=true - ;; - cli) - binary_component=cli - binary_name=openshell - features="" - has_image=false - ;; - *) - echo "unsupported component: $component" >&2 - exit 1 - ;; - esac - - image_tag_base="${{ inputs['image-tag'] }}" - if [[ -z "$image_tag_base" ]]; then - image_tag_base="$GITHUB_SHA" - fi - - platform_input="${{ inputs.platform }}" - platform_input="${platform_input//[[:space:]]/}" - if [[ -z "$platform_input" ]]; then - echo "platform input must not be empty" >&2 - exit 1 - fi - - IFS=',' read -r -a platforms <<< "$platform_input" - matrix='{"include":[' - arches=() - count=0 - - for platform in "${platforms[@]}"; do - case "$platform" in - linux/amd64) - arch=amd64 - runner=linux-amd64-cpu8 - ;; - linux/arm64) - arch=arm64 - runner=linux-arm64-cpu8 - ;; - *) - echo "unsupported platform: $platform" >&2 - echo "supported platforms: linux/amd64, linux/arm64" >&2 - exit 1 - ;; - esac - - if [[ $count -gt 0 ]]; then - matrix+=',' - fi - matrix+='{"platform":"'"$platform"'","arch":"'"$arch"'","runner":"'"$runner"'"}' - arches+=("$arch") - count=$((count + 1)) - done - - matrix+=']}' - { - echo "matrix=$matrix" - echo "platform_count=$count" - echo "arches=${arches[*]}" - echo "binary_component=$binary_component" - echo "binary_name=$binary_name" - echo "artifact_prefix=rust-binary-${component}" - echo "image_tag_base=$image_tag_base" - echo "features=$features" - echo "has_image=$has_image" - } >> "$GITHUB_OUTPUT" - - rust-binary: - name: Rust ${{ needs.resolve.outputs.binary_component }} (${{ matrix.arch }}) - needs: resolve - permissions: - contents: read - packages: read - strategy: - fail-fast: false - matrix: ${{ fromJSON(needs.resolve.outputs.matrix) }} - uses: ./.github/workflows/rust-native-build.yml - with: - component: ${{ needs.resolve.outputs.binary_component }} - arch: ${{ matrix.arch }} - cargo-version: ${{ inputs['cargo-version'] }} - image-tag: ${{ needs.resolve.outputs.image_tag_base }} - checkout-ref: ${{ inputs['checkout-ref'] }} - features: ${{ needs.resolve.outputs.features }} - auditable: ${{ inputs.auditable }} - artifact-name: ${{ needs.resolve.outputs.artifact_prefix }}-linux-${{ matrix.arch }} - secrets: inherit - build: - name: Build ${{ inputs.component }} (${{ matrix.arch }}) - needs: [resolve, rust-binary] - if: needs.resolve.outputs.has_image == 'true' - runs-on: ${{ matrix.runner }} - timeout-minutes: ${{ inputs['timeout-minutes'] }} + name: ${{ inputs.component }} (${{ matrix.platform }}) strategy: fail-fast: false - matrix: ${{ fromJSON(needs.resolve.outputs.matrix) }} - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - volumes: - - /var/run/docker.sock:/var/run/docker.sock - # Expose the nv-gha-runners buildkitd.toml registry mirror config - # inside the container so setup-buildx can read it. - - /etc/buildkit:/etc/buildkit:ro - env: - IMAGE_TAG: ${{ format('{0}-{1}', needs.resolve.outputs.image_tag_base, matrix.arch) }} - IMAGE_REGISTRY: ghcr.io/nvidia/openshell - DOCKER_PUSH: ${{ inputs.push && '1' || '0' }} - DOCKER_PLATFORM: ${{ matrix.platform }} + matrix: + include: + - arch: amd64 + rust_arch: x86_64 + platform: linux/amd64 + runner: linux-amd64-cpu8 + - arch: arm64 + rust_arch: aarch64 + platform: linux/arm64 + runner: linux-arm64-cpu8 + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs['checkout-ref'] || github.sha }} - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Install tools - run: mise install --locked - - - name: Log in to GHCR - if: ${{ inputs.push }} - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Set up buildx (local driver) - uses: ./.github/actions/setup-buildx - with: - buildkitd-config: /etc/buildkit/buildkitd.toml - - name: Download Rust binary artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: ./.github/actions/build-docker-image with: - name: ${{ needs.resolve.outputs.artifact_prefix }}-linux-${{ matrix.arch }} - path: prebuilt-rust-binary - - - name: Stage Rust binary in Docker build context - run: | - set -euo pipefail - binary="${{ needs.resolve.outputs.binary_name }}" - download_dir="prebuilt-rust-binary" - stage="deploy/docker/.build/prebuilt-binaries/${{ matrix.arch }}" - found="$(find "$download_dir" -type f -name "$binary" -print -quit)" - if [[ -z "$found" ]]; then - echo "missing downloaded artifact file: $binary" >&2 - find "$download_dir" -maxdepth 4 -type f -print >&2 || true - exit 1 - fi - mkdir -p "$stage" - install -m 0755 "$found" "$stage/$binary" - ls -lh "$stage/" - - - name: Build ${{ inputs.component }} image - env: - DOCKER_BUILDER: openshell - run: | - set -euo pipefail - mise exec -- tasks/scripts/docker-build-image.sh "${{ inputs.component }}" \ - --cache-from "type=gha,scope=${{ inputs.component }}-${{ matrix.arch }}" \ - --cache-to "type=gha,mode=max,scope=${{ inputs.component }}-${{ matrix.arch }}" - - - name: Smoke check ${{ inputs.component }} image - run: | - set -euo pipefail - image="${IMAGE_REGISTRY}/${{ inputs.component }}:${IMAGE_TAG}" - case "${{ inputs.component }}" in - gateway) - output="$(docker run --rm --platform "${{ matrix.platform }}" "$image" --version)" - echo "$output" - grep -q '^openshell-gateway ' <<<"$output" - ;; - supervisor) - output="$(docker run --rm --platform "${{ matrix.platform }}" --entrypoint /openshell-sandbox "$image" --version)" - echo "$output" - grep -q '^openshell-sandbox ' <<<"$output" - ;; - esac - - merge: - name: Merge ${{ inputs.component }} manifest - needs: [resolve, build] - if: ${{ inputs.push && inputs['publish-manifest'] && needs.resolve.outputs.has_image == 'true' }} + component: ${{ inputs.component }} + binary: ${{ inputs.binary }} + triple: ${{ matrix.rust_arch }}-${{ inputs['target-suffix'] }} + arch: ${{ matrix.arch }} + platform: ${{ matrix.platform }} + image-tag: ${{ inputs.image-tag || github.sha }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + manifest: + name: ${{ inputs.component }} manifest + needs: build runs-on: linux-amd64-cpu8 timeout-minutes: 10 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - volumes: - - /var/run/docker.sock:/var/run/docker.sock steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs['checkout-ref'] || github.sha }} - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + shell: bash + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin - - name: Create multi-arch manifest + - name: Create manifest + shell: bash + env: + IMAGE_TAG: ${{ inputs.image-tag || github.sha }} + INPUTS_COMPONENT: ${{ inputs.component }} run: | - set -euo pipefail - image="ghcr.io/nvidia/openshell/${{ inputs.component }}" - refs=() - for arch in ${{ needs.resolve.outputs.arches }}; do - refs+=("${image}:${{ needs.resolve.outputs.image_tag_base }}-${arch}") - done + image=ghcr.io/nvidia/openshell/${INPUTS_COMPONENT} docker buildx imagetools create \ --prefer-index=false \ - -t "${image}:${{ needs.resolve.outputs.image_tag_base }}" \ - "${refs[@]}" + --tag "$image:${IMAGE_TAG}" \ + "$image:${IMAGE_TAG}-amd64" \ + "$image:${IMAGE_TAG}-arm64" - name: Verify merged manifest SBOM attestation + shell: bash env: - IMAGE_REF: ghcr.io/nvidia/openshell/${{ inputs.component }}:${{ needs.resolve.outputs.image_tag_base }} - REQUIRE_CARGO: ${{ inputs.auditable }} - run: | - args=("${IMAGE_REF}") - if [[ "${REQUIRE_CARGO}" == "true" ]]; then - args+=(--require-cargo) - fi - tasks/scripts/verify-image-sbom.sh "${args[@]}" + IMAGE_REF: ghcr.io/nvidia/openshell/${{ inputs.component }}:${{ inputs.image-tag || github.sha }} + run: tasks/scripts/verify-image-sbom.sh "${IMAGE_REF}" --require-cargo diff --git a/.github/workflows/driver-vm-linux.yml b/.github/workflows/driver-vm-linux.yml deleted file mode 100644 index 942cacdfbd..0000000000 --- a/.github/workflows/driver-vm-linux.yml +++ /dev/null @@ -1,221 +0,0 @@ -name: Driver VM Linux - -on: - workflow_call: - inputs: - cargo-version: - required: false - type: string - default: "" - image-tag: - required: true - type: string - checkout-ref: - required: true - type: string - runtime-platforms: - description: "Comma-separated VM runtime platforms to download: linux-aarch64,linux-x86_64" - required: false - type: string - default: "linux-aarch64,linux-x86_64" - driver-targets: - description: "JSON matrix entries for Linux VM driver targets" - required: false - type: string - default: >- - [{"arch":"arm64","runner":"linux-arm64-cpu8","target":"aarch64-unknown-linux-gnu","zig_target":"aarch64-unknown-linux-gnu.2.28","platform":"linux-aarch64","guest_arch":"aarch64"},{"arch":"amd64","runner":"linux-amd64-cpu8","target":"x86_64-unknown-linux-gnu","zig_target":"x86_64-unknown-linux-gnu.2.28","platform":"linux-x86_64","guest_arch":"x86_64"}] - -permissions: - contents: read - packages: read - -defaults: - run: - shell: bash - -jobs: - download-kernel-runtime: - name: Download Kernel Runtime - runs-on: linux-amd64-cpu8 - timeout-minutes: 10 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs['checkout-ref'] }} - - - name: Download Linux runtime tarballs - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RUNTIME_PLATFORMS: ${{ inputs['runtime-platforms'] }} - run: | - set -euo pipefail - mkdir -p runtime-artifacts - - runtime_platforms="${RUNTIME_PLATFORMS//[[:space:]]/}" - IFS=',' read -r -a platforms <<< "$runtime_platforms" - if [ "${#platforms[@]}" -eq 0 ]; then - echo "::error::No VM runtime platforms requested" - exit 1 - fi - for platform in "${platforms[@]}"; do - case "$platform" in - linux-aarch64 | linux-x86_64) ;; - *) - echo "::error::Unsupported VM runtime platform '$platform' in runtime-platforms" - exit 1 - ;; - esac - done - printf '%s\n' "${platforms[@]}" > runtime-artifacts/platforms.txt - - for platform in "${platforms[@]}"; do - asset="vm-runtime-${platform}.tar.zst" - echo "Downloading ${asset}..." - if ! gh release download vm-runtime \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "${asset}" \ - --dir runtime-artifacts \ - --clobber; then - echo "::error::No ${asset} asset found on vm-runtime release" - exit 1 - fi - done - - ls -lah runtime-artifacts/ - - - name: Verify downloads - run: | - set -euo pipefail - while IFS= read -r platform; do - test -f "runtime-artifacts/vm-runtime-${platform}.tar.zst" - done < runtime-artifacts/platforms.txt - - - name: Upload runtime artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: vm-driver-kernel-runtime-tarballs - path: runtime-artifacts/vm-runtime-*.tar.zst - retention-days: 1 - - build-driver-vm-linux: - name: Build Driver VM (Linux ${{ matrix.arch }}) - needs: [download-kernel-runtime] - strategy: - matrix: - include: ${{ fromJSON(inputs['driver-targets']) }} - runs-on: ${{ matrix.runner }} - timeout-minutes: 30 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENSHELL_IMAGE_TAG: ${{ inputs['image-tag'] }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs['checkout-ref'] }} - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Install tools - run: mise install --locked - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - with: - shared-key: driver-vm-linux-${{ matrix.arch }} - cache-directories: .cache/sccache - cache-targets: "true" - - - name: Install zstd - run: apt-get update && apt-get install -y --no-install-recommends zstd && rm -rf /var/lib/apt/lists/* - - - name: Download kernel runtime tarball - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: vm-driver-kernel-runtime-tarballs - path: runtime-download/ - - - name: Stage compressed runtime for embedding - run: | - set -euo pipefail - COMPRESSED_DIR="${PWD}/target/vm-runtime-compressed" - VM_RUNTIME_TARBALL="${PWD}/runtime-download/vm-runtime-${{ matrix.platform }}.tar.zst" \ - VM_RUNTIME_PLATFORM="${{ matrix.platform }}" \ - OPENSHELL_VM_RUNTIME_COMPRESSED_DIR="$COMPRESSED_DIR" \ - tasks/scripts/vm/compress-vm-runtime.sh - - - name: Build bundled supervisor - run: | - set -euo pipefail - OPENSHELL_VM_RUNTIME_COMPRESSED_DIR="${PWD}/target/vm-runtime-compressed" \ - tasks/scripts/vm/build-supervisor-bundle.sh --arch "${{ matrix.guest_arch }}" - - - name: Verify embedded driver inputs - run: | - set -euo pipefail - for file in libkrun.so.zst libkrunfw.so.5.zst gvproxy.zst umoci.zst openshell-sandbox.zst; do - test -s "target/vm-runtime-compressed/${file}" - done - - - name: Scope workspace to driver-vm crates - run: | - set -euo pipefail - sed -i 's|members = \["crates/\*"\]|members = ["crates/openshell-driver-vm", "crates/openshell-core"]|' Cargo.toml - - - name: Patch workspace version - if: ${{ inputs['cargo-version'] != '' }} - run: | - set -euo pipefail - sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ inputs['cargo-version'] }}"'"/}' Cargo.toml - - - name: Build openshell-driver-vm with glibc 2.28 floor - run: | - set -euo pipefail - mise x -- rustup target add ${{ matrix.target }} - OPENSHELL_VM_RUNTIME_COMPRESSED_DIR="${PWD}/target/vm-runtime-compressed" \ - mise x -- cargo zigbuild --release --target ${{ matrix.zig_target }} -p openshell-driver-vm --bin openshell-driver-vm - mkdir -p artifacts/bin - install -m 0755 target/${{ matrix.target }}/release/openshell-driver-vm artifacts/bin/openshell-driver-vm - - - name: Verify packaged binary - run: | - set -euo pipefail - OUTPUT="$(artifacts/bin/openshell-driver-vm --version)" - echo "$OUTPUT" - grep -q '^openshell-driver-vm ' <<<"$OUTPUT" - - - name: Verify glibc symbol floor - run: tasks/scripts/verify-glibc-symbols.sh 2.28 artifacts/bin/openshell-driver-vm - - - name: sccache stats - if: always() - run: mise x -- sccache --show-stats - - - name: Package binary - run: | - set -euo pipefail - mkdir -p artifacts - tar -czf "artifacts/openshell-driver-vm-${{ matrix.target }}.tar.gz" \ - -C artifacts/bin openshell-driver-vm - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: driver-vm-linux-${{ matrix.arch }} - path: artifacts/*.tar.gz - retention-days: 5 diff --git a/.github/workflows/driver-vm-macos.yml b/.github/workflows/driver-vm-macos.yml deleted file mode 100644 index a97ade9cbb..0000000000 --- a/.github/workflows/driver-vm-macos.yml +++ /dev/null @@ -1,221 +0,0 @@ -name: Driver VM macOS - -on: - workflow_call: - inputs: - cargo-version: - required: true - type: string - image-tag: - required: true - type: string - checkout-ref: - required: true - type: string - -permissions: - contents: read - packages: read - -defaults: - run: - shell: bash - -jobs: - download-kernel-runtime: - name: Download Kernel Runtime - runs-on: linux-amd64-cpu8 - timeout-minutes: 10 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs['checkout-ref'] }} - - - name: Download macOS runtime tarball - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - mkdir -p runtime-artifacts - - asset="vm-runtime-darwin-aarch64.tar.zst" - echo "Downloading ${asset}..." - if ! gh release download vm-runtime \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "${asset}" \ - --dir runtime-artifacts \ - --clobber; then - echo "::error::No ${asset} asset found on vm-runtime release" - exit 1 - fi - - ls -lah runtime-artifacts/ - - - name: Verify download - run: test -f runtime-artifacts/vm-runtime-darwin-aarch64.tar.zst - - - name: Upload runtime artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: vm-driver-macos-kernel-runtime-tarball - path: runtime-artifacts/vm-runtime-darwin-aarch64.tar.zst - retention-days: 1 - - build-supervisor-arm64: - name: Build Supervisor Bundle (arm64) - runs-on: linux-arm64-cpu8 - timeout-minutes: 30 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENSHELL_IMAGE_TAG: ${{ inputs['image-tag'] }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs['checkout-ref'] }} - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Install tools - run: mise install --locked - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - with: - shared-key: driver-vm-supervisor-arm64 - cache-directories: .cache/sccache - cache-targets: "true" - - - name: Install zstd - run: apt-get update && apt-get install -y --no-install-recommends zstd && rm -rf /var/lib/apt/lists/* - - - name: Build bundled supervisor - run: | - set -euo pipefail - tasks/scripts/vm/build-supervisor-bundle.sh --arch aarch64 - - - name: sccache stats - if: always() - run: mise x -- sccache --show-stats - - - name: Upload supervisor bundle - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: driver-vm-supervisor-arm64 - path: target/vm-runtime-compressed/openshell-sandbox.zst - retention-days: 1 - - build-driver-vm-macos: - name: Build Driver VM (macOS) - needs: [download-kernel-runtime, build-supervisor-arm64] - runs-on: linux-amd64-cpu8 - timeout-minutes: 60 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - volumes: - - /var/run/docker.sock:/var/run/docker.sock - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs['checkout-ref'] }} - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Set up Docker Buildx - uses: ./.github/actions/setup-buildx - - - name: Install zstd - run: apt-get update && apt-get install -y --no-install-recommends zstd && rm -rf /var/lib/apt/lists/* - - - name: Download kernel runtime tarball - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: vm-driver-macos-kernel-runtime-tarball - path: runtime-download/ - - - name: Prepare compressed runtime directory - run: | - set -euo pipefail - COMPRESSED_DIR="${PWD}/target/vm-runtime-compressed-macos" - VM_RUNTIME_TARBALL="${PWD}/runtime-download/vm-runtime-darwin-aarch64.tar.zst" \ - VM_RUNTIME_PLATFORM="darwin-aarch64" \ - OPENSHELL_VM_RUNTIME_COMPRESSED_DIR="$COMPRESSED_DIR" \ - tasks/scripts/vm/compress-vm-runtime.sh - - - name: Download bundled supervisor - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: driver-vm-supervisor-arm64 - path: target/vm-runtime-compressed-macos/ - - - name: Verify bundled supervisor - run: | - set -euo pipefail - test -f target/vm-runtime-compressed-macos/openshell-sandbox.zst - ls -lh target/vm-runtime-compressed-macos/openshell-sandbox.zst - - - name: Verify embedded driver inputs - run: | - set -euo pipefail - for file in libkrun.dylib.zst libkrunfw.5.dylib.zst gvproxy.zst umoci.zst openshell-sandbox.zst; do - test -s "target/vm-runtime-compressed-macos/${file}" - done - - - name: Build macOS binary via Docker - run: | - set -euo pipefail - docker buildx build \ - --file deploy/docker/Dockerfile.driver-vm-macos \ - --build-arg OPENSHELL_CARGO_VERSION="${{ inputs['cargo-version'] }}" \ - --build-arg OPENSHELL_IMAGE_TAG="${{ inputs['image-tag'] }}" \ - --build-arg CARGO_TARGET_CACHE_SCOPE="${{ github.sha }}" \ - --build-context vm-runtime-compressed="${PWD}/target/vm-runtime-compressed-macos" \ - --target binary \ - --output type=local,dest=out/ \ - . - - - name: Verify packaged binary shape - run: test -x out/openshell-driver-vm - - - name: Package binary - run: | - set -euo pipefail - mkdir -p artifacts - tar -czf artifacts/openshell-driver-vm-aarch64-apple-darwin.tar.gz \ - -C out openshell-driver-vm - ls -lh artifacts/ - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: driver-vm-macos - path: artifacts/*.tar.gz - retention-days: 5 diff --git a/.github/workflows/e2e-docker-test.yml b/.github/workflows/e2e-docker-test.yml new file mode 100644 index 0000000000..aa884bc3e2 --- /dev/null +++ b/.github/workflows/e2e-docker-test.yml @@ -0,0 +1,109 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Docker E2E Test + +on: + workflow_call: + inputs: + image-tag: + required: true + type: string + runner: + required: false + type: string + default: linux-amd64-cpu8 + checkout-ref: + required: false + type: string + default: "" + gateway-artifact: + required: false + type: string + default: "" + external-driver-binary: + required: false + type: string + default: "" + suite-matrix: + required: false + type: string + default: >- + [{"suite":"python","cmd":"mise run --no-deps --skip-deps e2e:python","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-python","cmd":"mise run --no-deps --skip-deps e2e:oidc-python:docker","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-pkce-docker","cmd":"mise run --no-deps --skip-deps e2e:oidc-pkce:docker","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"rust-docker","cmd":"mise run --no-deps --skip-deps e2e:rust","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"mcp","cmd":"mise run --no-deps --skip-deps e2e:mcp","apt_packages":"","python_proto":false,"mcp":true}] + +permissions: + actions: read + contents: read + packages: read + +jobs: + e2e: + name: E2E (${{ matrix.suite }}) + runs-on: ${{ inputs.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(inputs.suite-matrix) }} + container: + image: ghcr.io/nvidia/openshell/ci:37072ee81cd7b294c714bfa5ecc829b6927b3d70 + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + options: --privileged + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /home/runner/_work:/home/runner/_work + env: + MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + IMAGE_TAG: ${{ inputs.image-tag }} + OPENSHELL_REGISTRY: ghcr.io/nvidia/openshell + OPENSHELL_SUPERVISOR_IMAGE: ${{ format('ghcr.io/nvidia/openshell/supervisor:{0}', inputs.image-tag) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.checkout-ref || github.sha }} + persist-credentials: false + + - uses: ./.github/actions/setup-e2e-cli + + - uses: ./.github/actions/setup-e2e-gateway + with: + artifact-name: ${{ inputs.gateway-artifact }} + + - if: inputs.external-driver-binary != '' + uses: ./.github/actions/setup-e2e-driver + with: + binary: ${{ inputs.external-driver-binary }} + + - name: Check out MCP conformance tests + if: matrix.mcp + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: modelcontextprotocol/conformance + ref: b9041ea41b0188581803459dbae71bc7e02fd995 + path: .cache/mcp-conformance + persist-credentials: false + + - name: Install OS test dependencies + if: matrix.apt_packages != '' + shell: bash + env: + APT_PACKAGES: ${{ matrix.apt_packages }} + run: | + read -ra apt_packages <<< "$APT_PACKAGES" + apt-get update + apt-get install -y "${apt_packages[@]}" + rm -rf /var/lib/apt/lists/* + + - name: Log in to GHCR + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin + + - name: Install Python dependencies and generate protobuf stubs + if: matrix.python_proto + run: uv sync --frozen && mise run --no-deps python:proto + + - name: Run tests + env: + OPENSHELL_MCP_CONFORMANCE_CLIENT_IMAGE: ${{ format('openshell-mcp-conformance-client:{0}', inputs.image-tag) }} + run: ${{ matrix.cmd }} diff --git a/.github/workflows/e2e-gpu-test.yaml b/.github/workflows/e2e-gpu-test.yaml index be2b66e1d4..1a8275a274 100644 --- a/.github/workflows/e2e-gpu-test.yaml +++ b/.github/workflows/e2e-gpu-test.yaml @@ -7,16 +7,6 @@ on: description: "Image tag to test (typically the commit SHA)" required: true type: string - cli-artifact-prefix: - description: "Optional prebuilt CLI artifact prefix (artifact suffix is linux-)" - required: false - type: string - default: "" - gateway-artifact-prefix: - description: "Optional prebuilt gateway artifact prefix (artifact suffix is linux-)" - required: false - type: string - default: "" permissions: actions: read @@ -70,16 +60,10 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Use prebuilt OpenShell CLI - if: inputs.cli-artifact-prefix != '' uses: ./.github/actions/setup-e2e-cli - with: - artifact-prefix: ${{ inputs.cli-artifact-prefix }} - name: Use prebuilt OpenShell gateway - if: inputs.gateway-artifact-prefix != '' uses: ./.github/actions/setup-e2e-gateway - with: - artifact-prefix: ${{ inputs.gateway-artifact-prefix }} - name: Log in to GHCR run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin diff --git a/.github/workflows/e2e-kubernetes-test.yml b/.github/workflows/e2e-kubernetes-test.yml index 3e36570144..4498f9d44a 100644 --- a/.github/workflows/e2e-kubernetes-test.yml +++ b/.github/workflows/e2e-kubernetes-test.yml @@ -47,11 +47,21 @@ on: required: false type: string default: "v2026.4.25" - cli-artifact-prefix: - description: "Optional prebuilt CLI artifact prefix (artifact suffix is linux-)" + gateway-artifact: + description: "Optional prebuilt gateway artifact used to compose a local test image" required: false type: string default: "" + external-driver-binary: + description: "Optional standalone compute driver binary used to compose a local test image" + required: false + type: string + default: "" + cluster-images: + description: "Space-separated published images to preload into kind" + required: false + type: string + default: "gateway supervisor" permissions: actions: read @@ -81,62 +91,29 @@ jobs: ref: ${{ inputs['checkout-ref'] || github.sha }} - name: Use prebuilt OpenShell CLI - if: inputs.cli-artifact-prefix != '' uses: ./.github/actions/setup-e2e-cli - with: - artifact-prefix: ${{ inputs.cli-artifact-prefix }} - - name: Install mise - run: | - curl https://mise.run | MISE_VERSION=v2026.4.25 sh - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - echo "$HOME/.local/share/mise/shims" >> "$GITHUB_PATH" - - - name: Install tools - run: mise install --locked - - # The openshell-policy crate transitively pulls in z3-sys, whose - # build script needs the z3 C/C++ headers, clang/bindgen, and CMake to - # compile both system-linked and bundled-Z3 builds. The bare runner - # doesn't ship them; the CI container - # image used by other Rust e2e jobs does, but we can't run this job - # there (the runner's container handler injects its own --network - # bridge, which conflicts with the --network host we need so kind's - # API server is reachable from the test process). - - name: Install z3 build deps - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends libz3-dev clang cmake - - - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Create kind cluster - uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0 + - name: Use prebuilt OpenShell gateway + if: inputs.gateway-artifact != '' + uses: ./.github/actions/setup-e2e-gateway with: - cluster_name: ${{ env.KIND_CLUSTER_NAME }} - wait: 120s + artifact-name: ${{ inputs.gateway-artifact }} - # mise.toml sets KUBECONFIG="{{config_root}}/kubeconfig"; helm/kind-action - # writes to ~/.kube/config. Materialize the kind context at the mise path - # so `mise run e2e:kubernetes` (and the wrapper's `kubectl --context=...`) - # finds the kind cluster. - - name: Export kind kubeconfig to mise path - run: | - set -euo pipefail - kind get kubeconfig --name "$KIND_CLUSTER_NAME" > "$GITHUB_WORKSPACE/kubeconfig" - chmod 600 "$GITHUB_WORKSPACE/kubeconfig" + - name: Use prebuilt external compute driver + if: inputs.external-driver-binary != '' + uses: ./.github/actions/setup-e2e-driver + with: + binary: ${{ inputs.external-driver-binary }} - - name: Load gateway and supervisor images into kind - run: | - set -euo pipefail - for component in gateway supervisor; do - image="ghcr.io/nvidia/openshell/${component}:${{ inputs.image-tag }}" - archive="${RUNNER_TEMP:-/tmp}/openshell-${component}-linux-amd64.tar" - docker pull --platform linux/amd64 "$image" - docker image save --platform linux/amd64 --output "$archive" "$image" - kind load image-archive "$archive" --name "$KIND_CLUSTER_NAME" - done + - name: Set up kind cluster + uses: ./.github/actions/setup-e2e-kind + with: + cluster-name: ${{ env.KIND_CLUSTER_NAME }} + image-tag: ${{ inputs.image-tag }} + images: ${{ inputs.cluster-images }} + registry-username: ${{ github.actor }} + registry-password: ${{ secrets.GITHUB_TOKEN }} + mise-version: ${{ inputs.mise-version }} - name: Run Kubernetes E2E env: diff --git a/.github/workflows/e2e-podman-test.yml b/.github/workflows/e2e-podman-test.yml new file mode 100644 index 0000000000..4a5a6d38d7 --- /dev/null +++ b/.github/workflows/e2e-podman-test.yml @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Podman E2E Test + +on: + workflow_call: + inputs: + image-tag: + required: true + type: string + checkout-ref: + required: false + type: string + default: "" + gateway-artifact: + required: false + type: string + default: "" + external-driver-binary: + required: false + type: string + default: "" + suite-matrix: + required: false + type: string + default: >- + [{"suite":"provider-refresh-keycloak","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:provider-refresh-keycloak"}] + +permissions: + actions: read + contents: read + packages: read + +jobs: + e2e: + name: E2E (rust-podman-${{ matrix.suite }}, ${{ matrix.runner }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(inputs.suite-matrix) }} + env: + IMAGE_TAG: ${{ inputs.image-tag }} + MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENSHELL_REGISTRY: ghcr.io/nvidia/openshell + OPENSHELL_SUPERVISOR_IMAGE: ${{ format('ghcr.io/nvidia/openshell/supervisor:{0}', inputs.image-tag) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.checkout-ref || github.sha }} + persist-credentials: false + + - uses: ./.github/actions/setup-e2e-cli + + - uses: ./.github/actions/setup-e2e-gateway + with: + artifact-name: ${{ inputs.gateway-artifact }} + + - if: inputs.external-driver-binary != '' + uses: ./.github/actions/setup-e2e-driver + with: + binary: ${{ inputs.external-driver-binary }} + + - uses: ./.github/actions/setup-e2e-podman + with: + podman-major: ${{ matrix.podman_major }} + podman-package-version: ${{ matrix.podman_package_version }} + conmon-package-version: ${{ matrix.conmon_package_version }} + + - name: Log in to GHCR + run: echo "${{ secrets.GITHUB_TOKEN }}" | podman login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin + + - name: Run tests + run: ${{ matrix.cmd }} + + - name: Print AppArmor denials + if: always() + run: sudo dmesg | grep -E 'apparmor=.*DENIED|profile="unprivileged_userns"' | tail -100 || true + + - name: Fail on pasta SIGTERM AppArmor denial + if: always() + run: | + set -euo pipefail + denials="$(sudo dmesg | grep -E 'profile="pasta".*requested_mask="receive".*signal=term.*peer="podman"' || true)" + if [ -n "${denials}" ]; then + echo "::error::pasta denied Podman's SIGTERM; Podman will use its SIGKILL fallback" + printf '%s\n' "${denials}" + exit 1 + fi diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml deleted file mode 100644 index d3e4230636..0000000000 --- a/.github/workflows/e2e-test.yml +++ /dev/null @@ -1,234 +0,0 @@ -name: E2E Test - -on: - workflow_call: - inputs: - image-tag: - description: "Image tag to test (typically the commit SHA)" - required: true - type: string - runner: - description: "GitHub Actions runner label" - required: false - type: string - default: "linux-amd64-cpu8" - checkout-ref: - description: "Git ref to check out for test inputs (defaults to the workflow SHA)" - required: false - type: string - default: "" - cli-artifact-prefix: - description: "Optional prebuilt CLI artifact prefix (artifact suffix is linux-)" - required: false - type: string - default: "" - gateway-artifact-prefix: - description: "Optional prebuilt gateway artifact prefix (artifact suffix is linux-)" - required: false - type: string - default: "" - vm-driver-artifact-name: - description: "Optional prebuilt VM driver artifact name" - required: false - type: string - default: "" - -permissions: - actions: read - contents: read - packages: read - -jobs: - e2e: - name: "E2E (${{ matrix.suite }})" - runs-on: ${{ inputs.runner }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - - suite: python - cmd: "mise run --no-deps --skip-deps e2e:python" - apt_packages: "" - - suite: oidc-python - cmd: "mise run --no-deps --skip-deps e2e:oidc-python:docker" - apt_packages: "" - - suite: oidc-pkce-docker - cmd: "mise run --no-deps --skip-deps e2e:oidc-pkce:docker" - apt_packages: "openssh-client" - - suite: rust-docker - cmd: "mise run --no-deps --skip-deps e2e:rust" - apt_packages: "openssh-client" - - suite: rust-docker-external-driver - cmd: "env -u OPENSHELL_GATEWAY_BIN mise run --no-deps --skip-deps e2e:docker:external-driver" - apt_packages: "openssh-client" - - suite: mcp - cmd: "mise run --no-deps --skip-deps e2e:mcp" - apt_packages: "" - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - /home/runner/_work:/home/runner/_work - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - IMAGE_TAG: ${{ inputs.image-tag }} - OPENSHELL_REGISTRY: ghcr.io/nvidia/openshell - OPENSHELL_REGISTRY_HOST: ghcr.io - OPENSHELL_REGISTRY_NAMESPACE: nvidia/openshell - OPENSHELL_REGISTRY_USERNAME: ${{ github.actor }} - OPENSHELL_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs['checkout-ref'] || github.sha }} - persist-credentials: false - - - name: Use prebuilt OpenShell CLI - if: inputs.cli-artifact-prefix != '' - uses: ./.github/actions/setup-e2e-cli - with: - artifact-prefix: ${{ inputs.cli-artifact-prefix }} - - - name: Use prebuilt OpenShell gateway - if: inputs.gateway-artifact-prefix != '' - uses: ./.github/actions/setup-e2e-gateway - with: - artifact-prefix: ${{ inputs.gateway-artifact-prefix }} - - - name: Check out MCP conformance tests - if: matrix.suite == 'mcp' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: modelcontextprotocol/conformance - # Pin after v0.1.16 to include the tools_call client scenario fix. - ref: b9041ea41b0188581803459dbae71bc7e02fd995 - path: .cache/mcp-conformance - persist-credentials: false - - - name: Install OS test dependencies - if: matrix.apt_packages != '' - env: - APT_PACKAGES: ${{ matrix.apt_packages }} - run: apt-get update && apt-get install -y ${APT_PACKAGES} && rm -rf /var/lib/apt/lists/* - - - name: Log in to GHCR with Docker - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Install Python dependencies and generate protobuf stubs - if: matrix.suite == 'python' || matrix.suite == 'oidc-python' - run: uv sync --frozen && mise run --no-deps python:proto - - - name: Run tests - env: - OPENSHELL_SUPERVISOR_IMAGE: ${{ format('ghcr.io/nvidia/openshell/supervisor:{0}', inputs.image-tag) }} - OPENSHELL_MCP_CONFORMANCE_CLIENT_IMAGE: ${{ format('openshell-mcp-conformance-client:{0}', inputs.image-tag) }} - run: ${{ matrix.cmd }} - - e2e-vm: - name: E2E (rust-vm-${{ matrix.suite }}) - # libkrun needs KVM, so this job must run directly on a GitHub-hosted - # Linux VM. GitHub-hosted macOS runners do not support nested - # virtualization, and a job container would hide the host KVM device. - runs-on: ubuntu-24.04 - timeout-minutes: 90 - strategy: - fail-fast: false - matrix: - include: - - suite: managed - cmd: "mise run --no-deps --skip-deps e2e:vm" - - suite: external-driver - cmd: "env -u OPENSHELL_GATEWAY_BIN mise run --no-deps --skip-deps e2e:vm:external-driver" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs['checkout-ref'] || github.sha }} - persist-credentials: false - - - name: Use prebuilt OpenShell CLI - if: inputs.cli-artifact-prefix != '' - uses: ./.github/actions/setup-e2e-cli - with: - artifact-prefix: ${{ inputs.cli-artifact-prefix }} - - - name: Use prebuilt OpenShell gateway - if: inputs.gateway-artifact-prefix != '' - uses: ./.github/actions/setup-e2e-gateway - with: - artifact-prefix: ${{ inputs.gateway-artifact-prefix }} - - - name: Use prebuilt OpenShell VM driver - if: inputs.vm-driver-artifact-name != '' - uses: ./.github/actions/setup-e2e-vm-driver - with: - artifact-name: ${{ inputs.vm-driver-artifact-name }} - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential \ - clang \ - cmake \ - libclang-dev \ - libssl-dev \ - libz3-dev \ - openssh-client \ - pkg-config \ - socat \ - zstd - - - name: Enable KVM access - run: | - set -euo pipefail - if [[ ! -c /dev/kvm ]]; then - echo "::error::The GitHub-hosted runner did not expose /dev/kvm" - lscpu - grep -m1 -E '^(flags|Features)' /proc/cpuinfo || true - ls -la /dev - exit 1 - fi - - # Package installation can restart systemd-udevd, which reapplies - # the default root:kvm 0660 mode. Install a persistent rule after - # dependencies so later udev events preserve runner access. - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ - | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --settle --name-match=kvm - - ls -l /dev/kvm - exec 3<>/dev/kvm - exec 3>&- - - - name: Validate VM host tools - run: | - command -v mke2fs - command -v mkfs.ext4 - command -v debugfs - - - name: Install mise - run: | - curl https://mise.run | MISE_VERSION=v2026.4.25 sh - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - echo "$HOME/.local/share/mise/shims" >> "$GITHUB_PATH" - - - name: Install tools - run: mise install --locked - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - with: - shared-key: e2e-vm-linux-amd64 - cache-on-failure: "true" - - - name: Run VM E2E - run: ${{ matrix.cmd }} diff --git a/.github/workflows/e2e-vm-test.yml b/.github/workflows/e2e-vm-test.yml new file mode 100644 index 0000000000..57e04c32b3 --- /dev/null +++ b/.github/workflows/e2e-vm-test.yml @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: VM E2E Test + +on: + workflow_call: + inputs: + checkout-ref: + required: false + type: string + default: "" + gateway-artifact: + required: false + type: string + default: "" + suite-name: + required: false + type: string + default: managed + e2e-task: + required: false + type: string + default: e2e:vm + +permissions: + actions: read + contents: read + packages: read + +jobs: + e2e: + name: E2E (rust-vm-${{ inputs.suite-name }}) + runs-on: ubuntu-24.04 + timeout-minutes: 90 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.checkout-ref || github.sha }} + persist-credentials: false + + - uses: ./.github/actions/setup-e2e-cli + + - uses: ./.github/actions/setup-e2e-gateway + with: + artifact-name: ${{ inputs.gateway-artifact }} + + - uses: ./.github/actions/setup-e2e-vm-driver + + - uses: ./.github/actions/setup-e2e-vm + + - name: Run tests + env: + E2E_TASK: ${{ inputs.e2e-task }} + run: mise run --no-deps --skip-deps "$E2E_TASK" diff --git a/.github/workflows/package-release-binaries.yml b/.github/workflows/package-release-binaries.yml new file mode 100644 index 0000000000..f1900f3c35 --- /dev/null +++ b/.github/workflows/package-release-binaries.yml @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Package Release Binaries + +on: + workflow_call: + +permissions: + actions: read + contents: read + +jobs: + package: + name: Package ${{ matrix.artifact }} + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - artifact: openshell-x86_64-unknown-linux-musl + binary: openshell + package: cli-linux-amd64 + - artifact: openshell-aarch64-unknown-linux-musl + binary: openshell + package: cli-linux-arm64 + - artifact: openshell-aarch64-apple-darwin + binary: openshell + package: cli-macos + - artifact: openshell-gateway-x86_64-unknown-linux-gnu + binary: openshell-gateway + package: gateway-binary-linux-amd64 + - artifact: openshell-gateway-aarch64-unknown-linux-gnu + binary: openshell-gateway + package: gateway-binary-linux-arm64 + - artifact: openshell-gateway-aarch64-apple-darwin + binary: openshell-gateway + package: gateway-binary-macos + - artifact: openshell-sandbox-x86_64-unknown-linux-musl + binary: openshell-sandbox + package: supervisor-binary-linux-amd64 + - artifact: openshell-sandbox-aarch64-unknown-linux-musl + binary: openshell-sandbox + package: supervisor-binary-linux-arm64 + - artifact: openshell-driver-vm-x86_64-unknown-linux-gnu + binary: openshell-driver-vm + package: driver-vm-linux-amd64 + - artifact: openshell-driver-vm-aarch64-unknown-linux-gnu + binary: openshell-driver-vm + package: driver-vm-linux-arm64 + - artifact: openshell-driver-vm-aarch64-apple-darwin + binary: openshell-driver-vm + package: driver-vm-macos + steps: + - name: Download ${{ matrix.artifact }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ matrix.artifact }} + path: raw + + - name: Create archive + env: + ARTIFACT: ${{ matrix.artifact }} + BINARY: ${{ matrix.binary }} + run: | + set -euo pipefail + chmod +x "raw/${BINARY}" + tar -czf "${ARTIFACT}.tar.gz" -C raw "${BINARY}" + + - name: Upload ${{ matrix.package }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ${{ matrix.package }} + path: ${{ matrix.artifact }}.tar.gz + retention-days: 5 + if-no-files-found: error diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 500fcb4be6..7982a81643 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -53,41 +53,114 @@ jobs: 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" + build-cli: + needs: compute-versions + permissions: + contents: read + uses: ./.github/workflows/build-cli-binaries.yml + with: + cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + image-tag: dev + secrets: inherit + build-gateway: - needs: [compute-versions] + needs: compute-versions + permissions: + contents: read + uses: ./.github/workflows/build-gateway-binaries.yml + with: + cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + image-tag: dev + secrets: inherit + + build-sandbox: + needs: compute-versions + permissions: + contents: read + uses: ./.github/workflows/build-sandbox-binaries.yml + with: + cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + image-tag: dev + secrets: inherit + + build-vm-driver: + needs: [compute-versions, build-sandbox] + permissions: + contents: read + uses: ./.github/workflows/build-vm-driver.yml + with: + cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + image-tag: dev + secrets: inherit + + package-binaries: + needs: [build-cli, build-gateway, build-sandbox, build-vm-driver] + permissions: + actions: read + contents: read + uses: ./.github/workflows/package-release-binaries.yml + + build-gateway-image: + needs: build-gateway + permissions: + contents: read + packages: write uses: ./.github/workflows/docker-build.yml with: component: gateway - cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} - auditable: true + binary: openshell-gateway + target-suffix: unknown-linux-gnu + secrets: inherit - build-supervisor: - needs: [compute-versions] + build-supervisor-image: + needs: build-sandbox + permissions: + contents: read + packages: write uses: ./.github/workflows/docker-build.yml with: component: supervisor - cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} - auditable: true + binary: openshell-sandbox + target-suffix: unknown-linux-musl + secrets: inherit - e2e: - needs: [build-gateway, build-supervisor] + docker-e2e: + needs: [build-cli, build-gateway, build-supervisor-image] permissions: actions: read contents: read packages: read - uses: ./.github/workflows/e2e-test.yml + uses: ./.github/workflows/e2e-docker-test.yml with: image-tag: ${{ github.sha }} runner: linux-arm64-cpu8 + podman-e2e: + needs: [build-cli, build-gateway, build-supervisor-image] + permissions: + actions: read + contents: read + packages: read + uses: ./.github/workflows/e2e-podman-test.yml + with: + image-tag: ${{ github.sha }} + + vm-e2e: + needs: [build-cli, build-gateway, build-vm-driver] + permissions: + actions: read + contents: read + packages: read + uses: ./.github/workflows/e2e-vm-test.yml + tag-ghcr-dev: name: Tag GHCR Images as Dev - needs: [build-gateway, build-supervisor, release-dev] + needs: [build-gateway-image, build-supervisor-image, release-dev] runs-on: linux-amd64-cpu8 timeout-minutes: 10 steps: - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin - name: Tag images as dev run: | @@ -138,456 +211,10 @@ jobs: path: target/wheels/*.whl retention-days: 5 - # --------------------------------------------------------------------------- - # Build CLI binaries (Linux musl — static, native on each arch) - # - # Builds run directly on the CI host (glibc Ubuntu). Zig provides musl - # C/C++ toolchains for bundled-z3 and ring, and is also used as the linker. - # --------------------------------------------------------------------------- - build-cli-linux: - name: Build CLI (Linux ${{ matrix.arch }}) - needs: [compute-versions] - strategy: - matrix: - include: - - arch: amd64 - runner: linux-amd64-cpu8 - target: x86_64-unknown-linux-musl - zig_target: x86_64-linux-musl - - arch: arm64 - runner: linux-arm64-cpu8 - target: aarch64-unknown-linux-musl - zig_target: aarch64-linux-musl - runs-on: ${{ matrix.runner }} - timeout-minutes: 60 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENSHELL_IMAGE_TAG: dev - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Install tools - run: mise install --locked - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - with: - shared-key: cli-musl-${{ matrix.arch }} - cache-directories: .cache/sccache - cache-targets: "true" - - - name: Add Rust musl target - run: mise x -- rustup target add ${{ matrix.target }} - - - name: Set up zig musl wrappers - run: | - set -euo pipefail - ZIG="$(mise which zig)" - ZIG_TARGET="${{ matrix.zig_target }}" - mkdir -p /tmp/zig-musl - - # cc-rs injects --target= (for example - # aarch64-unknown-linux-musl), which zig does not parse. Strip any - # caller-provided --target and use the wrapper's zig-native target. - for tool in cc c++; do - printf '#!/bin/bash\nargs=()\nfor arg in "$@"; do\n case "$arg" in\n --target=*) ;;\n *) args+=("$arg") ;;\n esac\ndone\nexec "%s" %s --target=%s "${args[@]}"\n' \ - "$ZIG" "$tool" "$ZIG_TARGET" > "/tmp/zig-musl/${tool}" - chmod +x "/tmp/zig-musl/${tool}" - done - - TARGET_ENV=$(echo "${{ matrix.target }}" | tr '-' '_') - TARGET_ENV_UPPER=${TARGET_ENV^^} - - # Use zig for C/C++ compilation and final linking. - echo "CC_${TARGET_ENV}=/tmp/zig-musl/cc" >> "$GITHUB_ENV" - echo "CXX_${TARGET_ENV}=/tmp/zig-musl/c++" >> "$GITHUB_ENV" - echo "CARGO_TARGET_${TARGET_ENV_UPPER}_LINKER=/tmp/zig-musl/cc" >> "$GITHUB_ENV" - - # Let zig own CRT/startfiles to avoid duplicate _start symbols. - echo "CARGO_TARGET_${TARGET_ENV_UPPER}_RUSTFLAGS=-Clink-self-contained=no" >> "$GITHUB_ENV" - - # z3 built with zig c++ uses libc++ symbols (std::__1::*). - # Override z3-sys default (stdc++) so Rust links the matching runtime. - echo "CXXSTDLIB=c++" >> "$GITHUB_ENV" - - - name: Patch workspace version - if: needs.compute-versions.outputs.cargo_version != '' - run: | - set -euo pipefail - sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ needs.compute-versions.outputs.cargo_version }}"'"/}' Cargo.toml - - - name: Build ${{ matrix.target }} - run: mise x -- cargo build --release --target ${{ matrix.target }} -p openshell-cli - - - name: sccache stats - if: always() - run: mise x -- sccache --show-stats - - - name: Package binary - run: | - set -euo pipefail - mkdir -p artifacts - tar -czf artifacts/openshell-${{ matrix.target }}.tar.gz \ - -C target/${{ matrix.target }}/release openshell - ls -lh artifacts/ - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: cli-linux-${{ matrix.arch }} - path: artifacts/*.tar.gz - retention-days: 5 - - # --------------------------------------------------------------------------- - # Build CLI binary (macOS aarch64 via osxcross) - # --------------------------------------------------------------------------- - build-cli-macos: - name: Build CLI (macOS) - needs: [compute-versions] - runs-on: linux-amd64-cpu8 - timeout-minutes: 60 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - volumes: - - /var/run/docker.sock:/var/run/docker.sock - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Set up Docker Buildx - uses: ./.github/actions/setup-buildx - - - name: Build macOS binary via Docker - run: | - set -euo pipefail - docker buildx build \ - --file deploy/docker/Dockerfile.cli-macos \ - --build-arg OPENSHELL_CARGO_VERSION="${{ needs.compute-versions.outputs.cargo_version }}" \ - --build-arg OPENSHELL_IMAGE_TAG=dev \ - --build-arg CARGO_TARGET_CACHE_SCOPE="${{ github.sha }}" \ - --target binary \ - --output type=local,dest=out/ \ - . - - - name: Package binary - run: | - set -euo pipefail - mkdir -p artifacts - tar -czf artifacts/openshell-aarch64-apple-darwin.tar.gz \ - -C out openshell - ls -lh artifacts/ - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: cli-macos - path: artifacts/*.tar.gz - retention-days: 5 - - # --------------------------------------------------------------------------- - # Build standalone gateway binaries (Linux GNU — glibc 2.28 floor) - # --------------------------------------------------------------------------- - build-gateway-binary-linux: - name: Build Gateway Binary (Linux ${{ matrix.arch }}) - needs: [compute-versions] - strategy: - matrix: - include: - - arch: amd64 - runner: linux-amd64-cpu8 - target: x86_64-unknown-linux-gnu - zig_target: x86_64-unknown-linux-gnu.2.28 - - arch: arm64 - runner: linux-arm64-cpu8 - target: aarch64-unknown-linux-gnu - zig_target: aarch64-unknown-linux-gnu.2.28 - runs-on: ${{ matrix.runner }} - timeout-minutes: 60 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Install tools - run: mise install --locked - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - with: - shared-key: gateway-binary-gnu-${{ matrix.arch }}-zig-wrapper-${{ hashFiles('tasks/scripts/setup-zig-cc-wrapper.sh') }} - cache-directories: .cache/sccache - cache-targets: "true" - - - name: Patch workspace version - if: needs.compute-versions.outputs.cargo_version != '' - run: | - set -euo pipefail - sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ needs.compute-versions.outputs.cargo_version }}"'"/}' Cargo.toml - - - name: Set up Zig C/C++ wrappers - run: tasks/scripts/setup-zig-cc-wrapper.sh ${{ matrix.zig_target }} ${{ matrix.zig_target }} /tmp/zig-gnu - - - name: Build ${{ matrix.zig_target }} - env: - OPENSHELL_IMAGE_TAG: ${{ github.sha }} - run: | - set -euo pipefail - mise x -- rustup target add ${{ matrix.target }} - mise x -- cargo zigbuild --release --target ${{ matrix.zig_target }} -p openshell-server --bin openshell-gateway --features bundled-z3 - mkdir -p artifacts/bin - install -m 0755 target/${{ matrix.target }}/release/openshell-gateway artifacts/bin/openshell-gateway - - - name: Verify packaged binary - run: | - set -euo pipefail - OUTPUT="$(artifacts/bin/openshell-gateway --version)" - echo "$OUTPUT" - grep -q '^openshell-gateway ' <<<"$OUTPUT" - ldd artifacts/bin/openshell-gateway || true - if ldd artifacts/bin/openshell-gateway | grep -q 'libz3'; then - echo "gateway binary must not depend on shared libz3; build with bundled-z3" >&2 - exit 1 - fi - - - name: Verify glibc symbol floor - run: tasks/scripts/verify-glibc-symbols.sh 2.28 artifacts/bin/openshell-gateway - - - name: sccache stats - if: always() - run: mise x -- sccache --show-stats - - - name: Package binary - run: | - set -euo pipefail - mkdir -p artifacts - tar -czf artifacts/openshell-gateway-${{ matrix.target }}.tar.gz \ - -C artifacts/bin openshell-gateway - ls -lh artifacts/ - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: gateway-binary-linux-${{ matrix.arch }} - path: artifacts/*.tar.gz - retention-days: 5 - - # --------------------------------------------------------------------------- - # Build standalone gateway binary (macOS aarch64 via osxcross) - # --------------------------------------------------------------------------- - build-gateway-binary-macos: - name: Build Gateway Binary (macOS) - needs: [compute-versions] - runs-on: linux-amd64-cpu8 - timeout-minutes: 60 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - volumes: - - /var/run/docker.sock:/var/run/docker.sock - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Set up Docker Buildx - uses: ./.github/actions/setup-buildx - - - name: Build macOS binary via Docker - run: | - set -euo pipefail - docker buildx build \ - --file deploy/docker/Dockerfile.gateway-macos \ - --build-arg OPENSHELL_CARGO_VERSION="${{ needs.compute-versions.outputs.cargo_version }}" \ - --build-arg OPENSHELL_IMAGE_TAG=dev \ - --build-arg CARGO_TARGET_CACHE_SCOPE="${{ github.sha }}" \ - --target binary \ - --output type=local,dest=out/ \ - . - - - name: Verify packaged binary shape - run: | - set -euo pipefail - test -x out/openshell-gateway - - - name: Package binary - run: | - set -euo pipefail - mkdir -p artifacts - tar -czf artifacts/openshell-gateway-aarch64-apple-darwin.tar.gz \ - -C out openshell-gateway - ls -lh artifacts/ - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: gateway-binary-macos - path: artifacts/*.tar.gz - retention-days: 5 - - # --------------------------------------------------------------------------- - # Build standalone supervisor binaries (Linux GNU — native on each arch) - # --------------------------------------------------------------------------- - build-supervisor-binary-linux: - name: Build Supervisor Binary (Linux ${{ matrix.arch }}) - needs: [compute-versions] - strategy: - matrix: - include: - - arch: amd64 - runner: linux-amd64-cpu8 - target: x86_64-unknown-linux-gnu - - arch: arm64 - runner: linux-arm64-cpu8 - target: aarch64-unknown-linux-gnu - runs-on: ${{ matrix.runner }} - timeout-minutes: 60 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Install tools - run: mise install --locked - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - with: - shared-key: supervisor-binary-gnu-${{ matrix.arch }} - cache-directories: .cache/sccache - cache-targets: "true" - - - name: Patch workspace version - if: needs.compute-versions.outputs.cargo_version != '' - run: | - set -euo pipefail - sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ needs.compute-versions.outputs.cargo_version }}"'"/}' Cargo.toml - - - name: Build ${{ matrix.target }} - run: | - set -euo pipefail - mise x -- cargo build --release --target ${{ matrix.target }} -p openshell-sandbox --bin openshell-sandbox - - - name: Verify packaged binary - run: | - set -euo pipefail - OUTPUT="$(target/${{ matrix.target }}/release/openshell-sandbox --version)" - echo "$OUTPUT" - grep -q '^openshell-sandbox ' <<<"$OUTPUT" - - - name: sccache stats - if: always() - run: mise x -- sccache --show-stats - - - name: Package binary - run: | - set -euo pipefail - mkdir -p artifacts - tar -czf artifacts/openshell-sandbox-${{ matrix.target }}.tar.gz \ - -C target/${{ matrix.target }}/release openshell-sandbox - ls -lh artifacts/ - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: supervisor-binary-linux-${{ matrix.arch }} - path: artifacts/*.tar.gz - retention-days: 5 - - build-driver-vm-linux: - name: Build Driver VM Linux - needs: [compute-versions] - uses: ./.github/workflows/driver-vm-linux.yml - with: - cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} - image-tag: dev - checkout-ref: ${{ github.sha }} - secrets: inherit - - build-driver-vm-macos: - name: Build Driver VM macOS - needs: [compute-versions] - uses: ./.github/workflows/driver-vm-macos.yml - with: - cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} - image-tag: dev - checkout-ref: ${{ github.sha }} - secrets: inherit build-deb: name: Build Debian Packages - needs: [compute-versions, build-cli-linux, build-gateway-binary-linux, build-driver-vm-linux] + needs: [compute-versions, build-cli, build-gateway, build-vm-driver] uses: ./.github/workflows/deb-package.yml with: deb-version: ${{ needs.compute-versions.outputs.deb_version }} @@ -596,7 +223,7 @@ jobs: build-snap: name: Build Snap - needs: [compute-versions, build-cli-linux, build-gateway-binary-linux, build-supervisor-binary-linux] + needs: [compute-versions, build-cli, build-gateway, build-sandbox] uses: ./.github/workflows/snap-package.yml with: checkout-ref: ${{ github.sha }} @@ -607,7 +234,7 @@ jobs: build-rpm: name: Build RPM Packages - needs: [compute-versions, build-cli-linux, build-gateway-binary-linux] + needs: [compute-versions, build-cli, build-gateway] uses: ./.github/workflows/rpm-package.yml with: checkout-ref: ${{ github.sha }} @@ -618,7 +245,7 @@ jobs: smoke-linux-dev-artifacts: name: Smoke Linux Dev Artifacts (${{ matrix.name }}) - needs: [build-gateway-binary-linux, build-driver-vm-linux, build-deb, build-rpm, build-python-wheel] + needs: [package-binaries, build-deb, build-rpm, build-python-wheel] timeout-minutes: 20 strategy: fail-fast: false @@ -712,7 +339,7 @@ jobs: # --------------------------------------------------------------------------- release-dev: name: Release Dev - needs: [compute-versions, build-cli-linux, build-cli-macos, build-gateway-binary-linux, build-gateway-binary-macos, build-supervisor-binary-linux, build-python-wheel, e2e, build-driver-vm-linux, build-driver-vm-macos, build-deb, build-rpm, build-snap, smoke-linux-dev-artifacts] + needs: [compute-versions, package-binaries, build-python-wheel, docker-e2e, podman-e2e, vm-e2e, build-deb, build-rpm, build-snap, smoke-linux-dev-artifacts] runs-on: linux-amd64-cpu8 timeout-minutes: 10 permissions: @@ -839,8 +466,8 @@ jobs: openshell-gateway-aarch64-apple-darwin.tar.gz > openshell-gateway-checksums-sha256.txt cat openshell-gateway-checksums-sha256.txt sha256sum \ - openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz \ - openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz > openshell-sandbox-checksums-sha256.txt + openshell-sandbox-x86_64-unknown-linux-musl.tar.gz \ + openshell-sandbox-aarch64-unknown-linux-musl.tar.gz > openshell-sandbox-checksums-sha256.txt cat openshell-sandbox-checksums-sha256.txt - name: Generate Homebrew formula @@ -945,8 +572,8 @@ jobs: release/openshell-gateway-x86_64-unknown-linux-gnu.tar.gz release/openshell-gateway-aarch64-unknown-linux-gnu.tar.gz release/openshell-gateway-aarch64-apple-darwin.tar.gz - release/openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz - release/openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz + release/openshell-sandbox-x86_64-unknown-linux-musl.tar.gz + release/openshell-sandbox-aarch64-unknown-linux-musl.tar.gz release/openshell-driver-vm-x86_64-unknown-linux-gnu.tar.gz release/openshell-driver-vm-aarch64-unknown-linux-gnu.tar.gz release/openshell-driver-vm-aarch64-apple-darwin.tar.gz diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 74be300b0c..92eb4adf14 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -73,46 +73,126 @@ jobs: echo "semver=${RELEASE_TAG#v}" >> "$GITHUB_OUTPUT" echo "source_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + build-cli: + needs: compute-versions + permissions: + contents: read + uses: ./.github/workflows/build-cli-binaries.yml + with: + cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + image-tag: ${{ needs.compute-versions.outputs.semver }} + checkout-ref: ${{ inputs.tag || github.ref }} + secrets: inherit + build-gateway: - needs: [compute-versions] + needs: compute-versions + permissions: + contents: read + uses: ./.github/workflows/build-gateway-binaries.yml + with: + cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + image-tag: ${{ needs.compute-versions.outputs.semver }} + checkout-ref: ${{ inputs.tag || github.ref }} + secrets: inherit + + build-sandbox: + needs: compute-versions + permissions: + contents: read + uses: ./.github/workflows/build-sandbox-binaries.yml + with: + cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + image-tag: ${{ needs.compute-versions.outputs.semver }} + checkout-ref: ${{ inputs.tag || github.ref }} + secrets: inherit + + build-vm-driver: + needs: [compute-versions, build-sandbox] + permissions: + contents: read + uses: ./.github/workflows/build-vm-driver.yml + with: + cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + image-tag: ${{ needs.compute-versions.outputs.semver }} + checkout-ref: ${{ inputs.tag || github.ref }} + secrets: inherit + + package-binaries: + needs: [build-cli, build-gateway, build-sandbox, build-vm-driver] + permissions: + actions: read + contents: read + uses: ./.github/workflows/package-release-binaries.yml + + build-gateway-image: + needs: [compute-versions, build-gateway] + permissions: + contents: read + packages: write uses: ./.github/workflows/docker-build.yml with: component: gateway - cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + binary: openshell-gateway + target-suffix: unknown-linux-gnu image-tag: ${{ needs.compute-versions.outputs.source_sha }} checkout-ref: ${{ inputs.tag || github.ref }} - auditable: true + secrets: inherit - build-supervisor: - needs: [compute-versions] + build-supervisor-image: + needs: [compute-versions, build-sandbox] + permissions: + contents: read + packages: write uses: ./.github/workflows/docker-build.yml with: component: supervisor - cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + binary: openshell-sandbox + target-suffix: unknown-linux-musl image-tag: ${{ needs.compute-versions.outputs.source_sha }} checkout-ref: ${{ inputs.tag || github.ref }} - auditable: true + secrets: inherit - e2e: - needs: [compute-versions, build-gateway, build-supervisor] + docker-e2e: + needs: [compute-versions, build-cli, build-gateway, build-supervisor-image] permissions: actions: read contents: read packages: read - uses: ./.github/workflows/e2e-test.yml + uses: ./.github/workflows/e2e-docker-test.yml with: image-tag: ${{ needs.compute-versions.outputs.source_sha }} checkout-ref: ${{ inputs.tag || github.ref }} runner: linux-arm64-cpu8 + podman-e2e: + needs: [compute-versions, build-cli, build-gateway, build-supervisor-image] + permissions: + actions: read + contents: read + packages: read + uses: ./.github/workflows/e2e-podman-test.yml + with: + image-tag: ${{ needs.compute-versions.outputs.source_sha }} + checkout-ref: ${{ inputs.tag || github.ref }} + + vm-e2e: + needs: [compute-versions, build-cli, build-gateway, build-vm-driver] + permissions: + actions: read + contents: read + packages: read + uses: ./.github/workflows/e2e-vm-test.yml + with: + checkout-ref: ${{ inputs.tag || github.ref }} + tag-ghcr-release: name: Tag GHCR Images for Release - needs: [compute-versions, build-gateway, build-supervisor, release] + needs: [compute-versions, build-gateway-image, build-supervisor-image, release] runs-on: linux-amd64-cpu8 timeout-minutes: 10 steps: - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin - name: Tag images with version and latest run: | @@ -170,461 +250,9 @@ jobs: path: target/wheels/*.whl retention-days: 5 - # --------------------------------------------------------------------------- - # Build CLI binaries (Linux musl — static, native on each arch) - # - # Builds run directly on the CI host (glibc Ubuntu). Zig provides musl - # C/C++ toolchains for bundled-z3 and ring, and is also used as the linker. - # --------------------------------------------------------------------------- - build-cli-linux: - name: Build CLI (Linux ${{ matrix.arch }}) - needs: [compute-versions] - strategy: - matrix: - include: - - arch: amd64 - runner: linux-amd64-cpu8 - target: x86_64-unknown-linux-musl - zig_target: x86_64-linux-musl - - arch: arm64 - runner: linux-arm64-cpu8 - target: aarch64-unknown-linux-musl - zig_target: aarch64-linux-musl - runs-on: ${{ matrix.runner }} - timeout-minutes: 60 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENSHELL_IMAGE_TAG: ${{ needs.compute-versions.outputs.semver }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.tag || github.ref }} - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Install tools - run: mise install --locked - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - with: - shared-key: cli-musl-${{ matrix.arch }} - cache-directories: .cache/sccache - cache-targets: "true" - - - name: Add Rust musl target - run: mise x -- rustup target add ${{ matrix.target }} - - - name: Set up zig musl wrappers - run: | - set -euo pipefail - ZIG="$(mise which zig)" - ZIG_TARGET="${{ matrix.zig_target }}" - mkdir -p /tmp/zig-musl - - # cc-rs injects --target= (for example - # aarch64-unknown-linux-musl), which zig does not parse. Strip any - # caller-provided --target and use the wrapper's zig-native target. - for tool in cc c++; do - printf '#!/bin/bash\nargs=()\nfor arg in "$@"; do\n case "$arg" in\n --target=*) ;;\n *) args+=("$arg") ;;\n esac\ndone\nexec "%s" %s --target=%s "${args[@]}"\n' \ - "$ZIG" "$tool" "$ZIG_TARGET" > "/tmp/zig-musl/${tool}" - chmod +x "/tmp/zig-musl/${tool}" - done - - TARGET_ENV=$(echo "${{ matrix.target }}" | tr '-' '_') - TARGET_ENV_UPPER=${TARGET_ENV^^} - - # Use zig for C/C++ compilation and final linking. - echo "CC_${TARGET_ENV}=/tmp/zig-musl/cc" >> "$GITHUB_ENV" - echo "CXX_${TARGET_ENV}=/tmp/zig-musl/c++" >> "$GITHUB_ENV" - echo "CARGO_TARGET_${TARGET_ENV_UPPER}_LINKER=/tmp/zig-musl/cc" >> "$GITHUB_ENV" - - # Let zig own CRT/startfiles to avoid duplicate _start symbols. - echo "CARGO_TARGET_${TARGET_ENV_UPPER}_RUSTFLAGS=-Clink-self-contained=no" >> "$GITHUB_ENV" - - # z3 built with zig c++ uses libc++ symbols (std::__1::*). - # Override z3-sys default (stdc++) so Rust links the matching runtime. - echo "CXXSTDLIB=c++" >> "$GITHUB_ENV" - - - name: Patch workspace version - if: needs.compute-versions.outputs.cargo_version != '' - run: | - set -euo pipefail - sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ needs.compute-versions.outputs.cargo_version }}"'"/}' Cargo.toml - - - name: Build ${{ matrix.target }} - run: mise x -- cargo build --release --target ${{ matrix.target }} -p openshell-cli - - - name: sccache stats - if: always() - run: mise x -- sccache --show-stats - - - name: Package binary - run: | - set -euo pipefail - mkdir -p artifacts - tar -czf artifacts/openshell-${{ matrix.target }}.tar.gz \ - -C target/${{ matrix.target }}/release openshell - ls -lh artifacts/ - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: cli-linux-${{ matrix.arch }} - path: artifacts/*.tar.gz - retention-days: 5 - - # --------------------------------------------------------------------------- - # Build CLI binary (macOS aarch64 via osxcross) - # --------------------------------------------------------------------------- - build-cli-macos: - name: Build CLI (macOS) - needs: [compute-versions] - runs-on: linux-amd64-cpu8 - timeout-minutes: 60 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - volumes: - - /var/run/docker.sock:/var/run/docker.sock - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.tag || github.ref }} - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Set up Docker Buildx - uses: ./.github/actions/setup-buildx - - - name: Build macOS binary via Docker - run: | - set -euo pipefail - docker buildx build \ - --file deploy/docker/Dockerfile.cli-macos \ - --build-arg OPENSHELL_CARGO_VERSION="${{ needs.compute-versions.outputs.cargo_version }}" \ - --build-arg OPENSHELL_IMAGE_TAG="${{ needs.compute-versions.outputs.semver }}" \ - --build-arg CARGO_TARGET_CACHE_SCOPE="${{ github.sha }}" \ - --target binary \ - --output type=local,dest=out/ \ - . - - - name: Package binary - run: | - set -euo pipefail - mkdir -p artifacts - tar -czf artifacts/openshell-aarch64-apple-darwin.tar.gz \ - -C out openshell - ls -lh artifacts/ - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: cli-macos - path: artifacts/*.tar.gz - retention-days: 5 - - # --------------------------------------------------------------------------- - # Build standalone gateway binaries (Linux GNU — glibc 2.28 floor) - # --------------------------------------------------------------------------- - build-gateway-binary-linux: - name: Build Gateway Binary (Linux ${{ matrix.arch }}) - needs: [compute-versions] - strategy: - matrix: - include: - - arch: amd64 - runner: linux-amd64-cpu8 - target: x86_64-unknown-linux-gnu - zig_target: x86_64-unknown-linux-gnu.2.28 - - arch: arm64 - runner: linux-arm64-cpu8 - target: aarch64-unknown-linux-gnu - zig_target: aarch64-unknown-linux-gnu.2.28 - runs-on: ${{ matrix.runner }} - timeout-minutes: 60 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.tag || github.ref }} - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Install tools - run: mise install --locked - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - with: - shared-key: gateway-binary-gnu-${{ matrix.arch }}-zig-wrapper-${{ hashFiles('tasks/scripts/setup-zig-cc-wrapper.sh') }} - cache-directories: .cache/sccache - cache-targets: "true" - - - name: Patch workspace version - if: needs.compute-versions.outputs.cargo_version != '' - run: | - set -euo pipefail - sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ needs.compute-versions.outputs.cargo_version }}"'"/}' Cargo.toml - - - name: Set up Zig C/C++ wrappers - run: tasks/scripts/setup-zig-cc-wrapper.sh ${{ matrix.zig_target }} ${{ matrix.zig_target }} /tmp/zig-gnu - - - name: Build ${{ matrix.zig_target }} - env: - OPENSHELL_IMAGE_TAG: ${{ needs.compute-versions.outputs.source_sha }} - run: | - set -euo pipefail - mise x -- rustup target add ${{ matrix.target }} - mise x -- cargo zigbuild --release --target ${{ matrix.zig_target }} -p openshell-server --bin openshell-gateway --features bundled-z3 - mkdir -p artifacts/bin - install -m 0755 target/${{ matrix.target }}/release/openshell-gateway artifacts/bin/openshell-gateway - - - name: Verify packaged binary - run: | - set -euo pipefail - OUTPUT="$(artifacts/bin/openshell-gateway --version)" - echo "$OUTPUT" - grep -q '^openshell-gateway ' <<<"$OUTPUT" - ldd artifacts/bin/openshell-gateway || true - if ldd artifacts/bin/openshell-gateway | grep -q 'libz3'; then - echo "gateway binary must not require shared libz3; keep z3 bundled for portable release artifacts" >&2 - exit 1 - fi - - - name: Verify glibc symbol floor - run: tasks/scripts/verify-glibc-symbols.sh 2.28 artifacts/bin/openshell-gateway - - - name: sccache stats - if: always() - run: mise x -- sccache --show-stats - - - name: Package binary - run: | - set -euo pipefail - mkdir -p artifacts - tar -czf artifacts/openshell-gateway-${{ matrix.target }}.tar.gz \ - -C artifacts/bin openshell-gateway - ls -lh artifacts/ - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: gateway-binary-linux-${{ matrix.arch }} - path: artifacts/*.tar.gz - retention-days: 5 - - # --------------------------------------------------------------------------- - # Build standalone supervisor binaries (Linux GNU — native on each arch) - # --------------------------------------------------------------------------- - build-supervisor-binary-linux: - name: Build Supervisor Binary (Linux ${{ matrix.arch }}) - needs: [compute-versions] - strategy: - matrix: - include: - - arch: amd64 - runner: linux-amd64-cpu8 - target: x86_64-unknown-linux-gnu - - arch: arm64 - runner: linux-arm64-cpu8 - target: aarch64-unknown-linux-gnu - runs-on: ${{ matrix.runner }} - timeout-minutes: 60 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.tag || github.ref }} - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Install tools - run: mise install --locked - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - with: - shared-key: supervisor-binary-gnu-${{ matrix.arch }} - cache-directories: .cache/sccache - cache-targets: "true" - - - name: Patch workspace version - if: needs.compute-versions.outputs.cargo_version != '' - run: | - set -euo pipefail - sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ needs.compute-versions.outputs.cargo_version }}"'"/}' Cargo.toml - - - name: Build ${{ matrix.target }} - run: | - set -euo pipefail - mise x -- cargo build --release --target ${{ matrix.target }} -p openshell-sandbox --bin openshell-sandbox - - - name: Verify packaged binary - run: | - set -euo pipefail - OUTPUT="$(target/${{ matrix.target }}/release/openshell-sandbox --version)" - echo "$OUTPUT" - grep -q '^openshell-sandbox ' <<<"$OUTPUT" - - - name: sccache stats - if: always() - run: mise x -- sccache --show-stats - - - name: Package binary - run: | - set -euo pipefail - mkdir -p artifacts - tar -czf artifacts/openshell-sandbox-${{ matrix.target }}.tar.gz \ - -C target/${{ matrix.target }}/release openshell-sandbox - ls -lh artifacts/ - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: supervisor-binary-linux-${{ matrix.arch }} - path: artifacts/*.tar.gz - retention-days: 5 - - # --------------------------------------------------------------------------- - # Build standalone gateway binary (macOS aarch64 via osxcross) - # --------------------------------------------------------------------------- - build-gateway-binary-macos: - name: Build Gateway Binary (macOS) - needs: [compute-versions] - runs-on: linux-amd64-cpu8 - timeout-minutes: 60 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - volumes: - - /var/run/docker.sock:/var/run/docker.sock - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.tag || github.ref }} - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Set up Docker Buildx - uses: ./.github/actions/setup-buildx - - - name: Build macOS binary via Docker - run: | - set -euo pipefail - docker buildx build \ - --file deploy/docker/Dockerfile.gateway-macos \ - --build-arg OPENSHELL_CARGO_VERSION="${{ needs.compute-versions.outputs.cargo_version }}" \ - --build-arg OPENSHELL_IMAGE_TAG="${{ needs.compute-versions.outputs.semver }}" \ - --build-arg CARGO_TARGET_CACHE_SCOPE="${{ github.sha }}" \ - --target binary \ - --output type=local,dest=out/ \ - . - - - name: Verify packaged binary shape - run: | - set -euo pipefail - test -x out/openshell-gateway - - - name: Package binary - run: | - set -euo pipefail - mkdir -p artifacts - tar -czf artifacts/openshell-gateway-aarch64-apple-darwin.tar.gz \ - -C out openshell-gateway - ls -lh artifacts/ - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: gateway-binary-macos - path: artifacts/*.tar.gz - retention-days: 5 - - build-driver-vm-linux: - name: Build Driver VM Linux - needs: [compute-versions] - uses: ./.github/workflows/driver-vm-linux.yml - with: - cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} - image-tag: ${{ needs.compute-versions.outputs.semver }} - checkout-ref: ${{ inputs.tag || github.ref }} - secrets: inherit - - build-driver-vm-macos: - name: Build Driver VM macOS - needs: [compute-versions] - uses: ./.github/workflows/driver-vm-macos.yml - with: - cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} - image-tag: ${{ needs.compute-versions.outputs.semver }} - checkout-ref: ${{ inputs.tag || github.ref }} - secrets: inherit - build-deb: name: Build Debian Packages - needs: [compute-versions, build-cli-linux, build-gateway-binary-linux, build-driver-vm-linux] + needs: [compute-versions, build-cli, build-gateway, build-vm-driver] uses: ./.github/workflows/deb-package.yml with: deb-version: ${{ needs.compute-versions.outputs.deb_version }} @@ -633,7 +261,7 @@ jobs: build-snap: name: Build Snap - needs: [compute-versions, build-cli-linux, build-gateway-binary-linux, build-supervisor-binary-linux] + needs: [compute-versions, build-cli, build-gateway, build-sandbox] uses: ./.github/workflows/snap-package.yml with: checkout-ref: ${{ inputs.tag || github.ref }} @@ -644,7 +272,7 @@ jobs: build-rpm: name: Build RPM Packages - needs: [compute-versions, build-cli-linux, build-gateway-binary-linux] + needs: [compute-versions, build-cli, build-gateway] uses: ./.github/workflows/rpm-package.yml with: checkout-ref: ${{ inputs.tag || github.ref }} @@ -655,7 +283,7 @@ jobs: smoke-linux-release-artifacts: name: Smoke Linux Release Artifacts (${{ matrix.name }}) - needs: [build-gateway-binary-linux, build-driver-vm-linux, build-deb, build-rpm, build-python-wheel] + needs: [package-binaries, build-deb, build-rpm, build-python-wheel] timeout-minutes: 20 strategy: fail-fast: false @@ -792,7 +420,7 @@ jobs: # --------------------------------------------------------------------------- release: name: Release - needs: [compute-versions, build-cli-linux, build-cli-macos, build-gateway-binary-linux, build-gateway-binary-macos, build-supervisor-binary-linux, build-python-wheel, e2e, build-driver-vm-linux, build-driver-vm-macos, build-deb, build-rpm, build-snap, smoke-linux-release-artifacts] + needs: [compute-versions, package-binaries, build-python-wheel, docker-e2e, podman-e2e, vm-e2e, build-deb, build-rpm, build-snap, smoke-linux-release-artifacts] runs-on: linux-amd64-cpu8 timeout-minutes: 10 permissions: @@ -896,8 +524,8 @@ jobs: openshell-gateway-aarch64-apple-darwin.tar.gz > openshell-gateway-checksums-sha256.txt cat openshell-gateway-checksums-sha256.txt sha256sum \ - openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz \ - openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz > openshell-sandbox-checksums-sha256.txt + openshell-sandbox-x86_64-unknown-linux-musl.tar.gz \ + openshell-sandbox-aarch64-unknown-linux-musl.tar.gz > openshell-sandbox-checksums-sha256.txt cat openshell-sandbox-checksums-sha256.txt - name: Generate Homebrew formula @@ -966,8 +594,8 @@ jobs: release/openshell-gateway-x86_64-unknown-linux-gnu.tar.gz release/openshell-gateway-aarch64-unknown-linux-gnu.tar.gz release/openshell-gateway-aarch64-apple-darwin.tar.gz - release/openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz - release/openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz + release/openshell-sandbox-x86_64-unknown-linux-musl.tar.gz + release/openshell-sandbox-aarch64-unknown-linux-musl.tar.gz release/openshell-driver-vm-x86_64-unknown-linux-gnu.tar.gz release/openshell-driver-vm-aarch64-unknown-linux-gnu.tar.gz release/openshell-driver-vm-aarch64-apple-darwin.tar.gz diff --git a/.github/workflows/rpm-package.yml b/.github/workflows/rpm-package.yml index 591d10e619..8701644585 100644 --- a/.github/workflows/rpm-package.yml +++ b/.github/workflows/rpm-package.yml @@ -68,21 +68,19 @@ jobs: - name: Download CLI artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: cli-linux-${{ matrix.artifact_arch }} - path: package-input/ + name: openshell-${{ matrix.cli_target }} + path: package-binaries/ - name: Download gateway artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: gateway-binary-linux-${{ matrix.artifact_arch }} - path: package-input/ + name: openshell-gateway-${{ matrix.gnu_target }} + path: package-binaries/ - - name: Extract package inputs + - name: Configure package inputs run: | set -euo pipefail - mkdir -p package-binaries - tar -xzf "package-input/openshell-${{ matrix.cli_target }}.tar.gz" -C package-binaries - tar -xzf "package-input/openshell-gateway-${{ matrix.gnu_target }}.tar.gz" -C package-binaries + chmod +x package-binaries/openshell{,-gateway} ls -lah package-binaries - name: Mark workspace safe for git diff --git a/.github/workflows/rust-native-build.yml b/.github/workflows/rust-native-build.yml deleted file mode 100644 index 24124d0ae4..0000000000 --- a/.github/workflows/rust-native-build.yml +++ /dev/null @@ -1,383 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -name: Rust Image Binary Build (openshell-gateway / openshell-sandbox / openshell-cli) - -# Build Rust binaries per Linux architecture before the Docker image build -# consumes them as prebuilt artifacts. Gateway images use GNU-linked binaries -# for the NVIDIA distroless C/C++ runtime; supervisor and cli images use static -# binaries so the final image can remain scratch. Gateway GNU binaries are -# built with an explicit glibc 2.28 floor so image, package, and tarball -# artifacts share the same host portability contract. -# -# The supervisor libc is selectable via the `supervisor-libc` input (musl or -# glibc-static). Both variants are fully static and are verified as such, -# because the supervisor is executed from inside arbitrary sandbox images. - -on: - workflow_call: - inputs: - component: - description: "Binary component to build (gateway, sandbox, or cli)" - required: true - type: string - arch: - description: "Linux architecture to build (amd64 or arm64)" - required: true - type: string - supervisor-libc: - description: "libc variant for the sandbox component (musl or glibc-static)" - required: false - type: string - default: "musl" - cargo-version: - description: "Pre-computed cargo version (skips internal git-based computation)" - required: false - type: string - default: "" - features: - description: "Cargo features to enable" - required: false - type: string - default: "" - # Branch E2E and release gateway/supervisor image builds opt in. - # CLI images and standalone glibc-static supervisor validation keep false. - auditable: - description: "Embed cargo-auditable dependency metadata in the binary" - required: false - type: boolean - default: false - retention-days: - description: "Artifact retention period" - required: false - type: number - default: 5 - artifact-name: - description: "Artifact name override" - required: false - type: string - default: "" - checkout-ref: - description: "Git ref to check out for build inputs (defaults to the workflow SHA)" - required: false - type: string - default: "" - image-tag: - description: "Supervisor image tag to bake into gateway binaries" - required: false - type: string - default: "" -permissions: - contents: read - packages: read - -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: "0" - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Route sccache (already RUSTC_WRAPPER in mise.toml) to the GHA cache - # backend instead of the EKS memcached used by ARC. - SCCACHE_GHA_ENABLED: "true" - -defaults: - run: - shell: bash - -jobs: - rust-native-build: - name: ${{ inputs.component }} (${{ inputs.arch }}) - runs-on: ${{ inputs.arch == 'arm64' && 'linux-arm64-cpu8' || 'linux-amd64-cpu8' }} - timeout-minutes: 60 - env: - COMPONENT: ${{ inputs.component }} - ARCH: ${{ inputs.arch }} - FEATURES: ${{ inputs.features }} - SUPERVISOR_LIBC: ${{ inputs['supervisor-libc'] }} - # Partition the GHA sccache cache per (component, arch). Without this, - # concurrent jobs collide on the same cache key and later-starting - # writers hit 409 Conflict. The sandbox component also partitions per - # libc variant so musl and glibc-static builds do not evict each other. - SCCACHE_GHA_VERSION: ${{ inputs.component }}-${{ inputs.arch }}${{ inputs.component == 'sandbox' && format('-{0}', inputs['supervisor-libc']) || '' }} - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs['checkout-ref'] || github.sha }} - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Configure GHA sccache backend - # Exposes ACTIONS_CACHE_URL / ACTIONS_RUNTIME_TOKEN before `mise install` - # compiles cargo-installed tools through RUSTC_WRAPPER=sccache. - uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10 - - - name: Install tools - run: mise install --locked - - - name: Resolve build target - id: target - run: | - set -euo pipefail - - case "$COMPONENT" in - gateway) - crate=openshell-server - binary=openshell-gateway - zig_target= - ;; - sandbox) - crate=openshell-sandbox - binary=openshell-sandbox - zig_target= - ;; - cli) - crate=openshell-cli - binary=openshell - zig_target= - ;; - *) - echo "unsupported component: $COMPONENT" >&2 - exit 1 - ;; - esac - - # The sandbox binary must stay fully static. musl gets there via the - # musl target; glibc-static uses the GNU target with +crt-static and - # relies on this job running natively on the target architecture, - # because zig cannot statically link glibc. - static_libc=musl - if [[ "$COMPONENT" == "sandbox" ]]; then - case "$SUPERVISOR_LIBC" in - musl) static_libc=musl ;; - glibc-static) static_libc=gnu ;; - *) - echo "unsupported supervisor-libc: $SUPERVISOR_LIBC (expected musl or glibc-static)" >&2 - exit 1 - ;; - esac - fi - - case "$ARCH" in - amd64) - if [[ "$COMPONENT" == "sandbox" && "$static_libc" == "gnu" ]]; then - target=x86_64-unknown-linux-gnu - zig_target= - elif [[ "$COMPONENT" == "sandbox" || "$COMPONENT" == "cli" ]]; then - target=x86_64-unknown-linux-musl - zig_target=x86_64-linux-musl - else - target=x86_64-unknown-linux-gnu - zig_target=x86_64-unknown-linux-gnu.2.28 - fi - ;; - arm64) - if [[ "$COMPONENT" == "sandbox" && "$static_libc" == "gnu" ]]; then - target=aarch64-unknown-linux-gnu - zig_target= - elif [[ "$COMPONENT" == "sandbox" || "$COMPONENT" == "cli" ]]; then - target=aarch64-unknown-linux-musl - zig_target=aarch64-linux-musl - else - target=aarch64-unknown-linux-gnu - zig_target=aarch64-unknown-linux-gnu.2.28 - fi - ;; - *) - echo "unsupported arch: $ARCH" >&2 - exit 1 - ;; - esac - - { - echo "crate=$crate" - echo "binary=$binary" - echo "target=$target" - echo "zig_target=$zig_target" - } >> "$GITHUB_OUTPUT" - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - with: - shared-key: rust-native-${{ inputs.component }}-${{ inputs.arch }}${{ inputs.component == 'sandbox' && format('-{0}', inputs['supervisor-libc']) || '' }}-zig-wrapper-${{ hashFiles('tasks/scripts/setup-zig-cc-wrapper.sh') }} - cache-directories: .cache/sccache - cache-targets: "true" - - - name: Compute cargo version - id: version - run: | - set -euo pipefail - if [[ -n "${{ inputs['cargo-version'] }}" ]]; then - echo "cargo_version=${{ inputs['cargo-version'] }}" >> "$GITHUB_OUTPUT" - else - echo "cargo_version=$(uv run python tasks/scripts/release.py get-version --cargo)" >> "$GITHUB_OUTPUT" - fi - - - name: Patch workspace version - if: steps.version.outputs.cargo_version != '' - run: | - set -euo pipefail - sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ steps.version.outputs.cargo_version }}"'"/}' Cargo.toml - - - name: Set up zig musl wrappers - if: contains(steps.target.outputs.target, 'musl') - run: | - set -euo pipefail - ZIG="$(mise which zig)" - ZIG_TARGET="${{ steps.target.outputs.zig_target }}" - mkdir -p /tmp/zig-musl - - # cc-rs injects --target=, which zig does not parse. - # Strip caller-provided --target and use the wrapper's zig target. - for tool in cc c++; do - printf '#!/bin/bash\nargs=()\nfor arg in "$@"; do\n case "$arg" in\n --target=*) ;;\n *) args+=("$arg") ;;\n esac\ndone\nexec "%s" %s --target=%s "${args[@]}"\n' \ - "$ZIG" "$tool" "$ZIG_TARGET" > "/tmp/zig-musl/${tool}" - chmod +x "/tmp/zig-musl/${tool}" - done - - TARGET_ENV=$(echo "${{ steps.target.outputs.target }}" | tr '-' '_') - TARGET_ENV_UPPER=${TARGET_ENV^^} - - echo "CC_${TARGET_ENV}=/tmp/zig-musl/cc" >> "$GITHUB_ENV" - echo "CXX_${TARGET_ENV}=/tmp/zig-musl/c++" >> "$GITHUB_ENV" - echo "CARGO_TARGET_${TARGET_ENV_UPPER}_LINKER=/tmp/zig-musl/cc" >> "$GITHUB_ENV" - echo "CARGO_TARGET_${TARGET_ENV_UPPER}_RUSTFLAGS=-Clink-self-contained=no" >> "$GITHUB_ENV" - - - name: Set up zig glibc wrappers - if: inputs.component == 'gateway' - run: tasks/scripts/setup-zig-cc-wrapper.sh "${{ steps.target.outputs.zig_target }}" "${{ steps.target.outputs.zig_target }}" /tmp/zig-gnu - - - name: Build ${{ steps.target.outputs.binary }} (${{ steps.target.outputs.zig_target || steps.target.outputs.target }}) - env: - # Preserve the release-codegen setting used by the old Dockerfile - # Rust build path so image artifacts keep the same release profile. - CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "1" - OPENSHELL_IMAGE_TAG: ${{ inputs['image-tag'] }} - run: | - set -euo pipefail - # z3 built with zig c++ uses libc++ symbols (std::__1::*). - # Override z3-sys default (stdc++) so Rust links the matching runtime. - if [[ "${{ inputs.component }}" == "cli" ]]; then - echo "CXXSTDLIB=c++" >> "$GITHUB_ENV" - fi - - mise x -- rustup target add "${{ steps.target.outputs.target }}" - - cargo_cmd=(cargo build) - cargo_env=() - build_target="${{ steps.target.outputs.target }}" - args=() - - if [[ "${{ inputs.component }}" == "gateway" ]]; then - cargo_cmd=(cargo zigbuild) - build_target="${{ steps.target.outputs.zig_target }}" - args+=(--features bundled-z3) - elif [[ "${{ inputs.component }}" == "sandbox" && "$SUPERVISOR_LIBC" == "glibc-static" ]]; then - # Static glibc requires the native toolchain's libc.a (build-essential - # in the CI image); cargo-zigbuild is not usable here because zig - # accepts -static for *-linux-gnu and links dynamically anyway. - export RUSTFLAGS="${RUSTFLAGS:-} -C target-feature=+crt-static" - fi - if [[ "${{ inputs.auditable }}" == "true" ]]; then - cargo_cmd=("${cargo_cmd[0]}" auditable "${cargo_cmd[@]:1}") - # mise.toml injects RUSTC_WRAPPER=sccache. Unset it after mise has - # constructed the environment so it cannot wrap cargo-auditable's - # RUSTC_WORKSPACE_WRAPPER and misidentify that wrapper as rustc. - cargo_env=(env -u RUSTC_WRAPPER) - fi - args+=( - --release - --target "$build_target" - -p "${{ steps.target.outputs.crate }}" - --bin "${{ steps.target.outputs.binary }}" - ) - if [[ -n "$FEATURES" ]]; then - args+=(--features "$FEATURES") - fi - if [[ -n "${{ steps.version.outputs.cargo_version }}" ]]; then - export GIT_DIR=/nonexistent - fi - mise x -- "${cargo_env[@]}" "${cargo_cmd[@]}" "${args[@]}" - - - name: Verify packaged binary - run: | - set -euo pipefail - BIN="target/${{ steps.target.outputs.target }}/release/${{ steps.target.outputs.binary }}" - OUTPUT="$("$BIN" --version)" - echo "$OUTPUT" - grep -q "^${{ steps.target.outputs.binary }} " <<<"$OUTPUT" - # Record linkage so image runtime drift is visible in logs. - ldd --version - ldd "$BIN" || true - if [[ "${{ inputs.component }}" == "gateway" ]] && ldd "$BIN" | grep -q 'libz3'; then - echo "gateway binary must not depend on shared libz3; enable bundled-z3 for image artifacts" >&2 - exit 1 - fi - - - name: Verify auditable dependency metadata - if: inputs.auditable - run: | - set -euo pipefail - BIN="target/${{ steps.target.outputs.target }}/release/${{ steps.target.outputs.binary }}" - cargo_packages="$( - SYFT_CHECK_FOR_APP_UPDATE=false \ - mise x -- syft "file:$BIN" -o cyclonedx-json | - jq '[.components[]? | select((.purl // "") | startswith("pkg:cargo/"))] | length' - )" - if [[ "$cargo_packages" -eq 0 ]]; then - echo "Syft did not decode any Cargo packages from $BIN" >&2 - exit 1 - fi - echo "auditable dependency metadata: $cargo_packages Cargo packages" - - - name: Verify glibc symbol floor - if: inputs.component == 'gateway' - run: | - set -euo pipefail - BIN="target/${{ steps.target.outputs.target }}/release/${{ steps.target.outputs.binary }}" - tasks/scripts/verify-glibc-symbols.sh 2.28 "$BIN" - - - name: Verify static linkage - if: inputs.component == 'sandbox' - run: | - set -euo pipefail - BIN="target/${{ steps.target.outputs.target }}/release/${{ steps.target.outputs.binary }}" - tasks/scripts/verify-static-binary.sh "$BIN" - - - name: Stage binary for prebuilt layout - run: | - set -euo pipefail - STAGE="prebuilt-binaries/$ARCH" - mkdir -p "$STAGE" - install -m 0755 \ - "target/${{ steps.target.outputs.target }}/release/${{ steps.target.outputs.binary }}" \ - "$STAGE/${{ steps.target.outputs.binary }}" - ls -lh "$STAGE/" - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: ${{ inputs['artifact-name'] != '' && inputs['artifact-name'] || format('rust-binary-{0}-linux-{1}', inputs.component, inputs.arch) }} - path: prebuilt-binaries/${{ inputs.arch }}/${{ steps.target.outputs.binary }} - retention-days: ${{ inputs['retention-days'] }} - if-no-files-found: error - - - 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 diff --git a/.github/workflows/snap-package.yml b/.github/workflows/snap-package.yml index ed248b79e5..6de2a587f2 100644 --- a/.github/workflows/snap-package.yml +++ b/.github/workflows/snap-package.yml @@ -37,8 +37,10 @@ jobs: matrix: include: - arch: amd64 + rust_arch: x86_64 runner: linux-amd64-cpu8 - arch: arm64 + rust_arch: aarch64 runner: linux-arm64-cpu8 runs-on: ${{ matrix.runner }} timeout-minutes: 60 @@ -79,35 +81,27 @@ jobs: - name: Download prebuilt CLI binary uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: cli-linux-${{ matrix.arch }} + name: openshell-${{ matrix.rust_arch }}-unknown-linux-musl path: prebuilt/cli - name: Download prebuilt gateway binary uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: gateway-binary-linux-${{ matrix.arch }} + name: openshell-gateway-${{ matrix.rust_arch }}-unknown-linux-gnu path: prebuilt/gateway - name: Download prebuilt sandbox binary uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: supervisor-binary-linux-${{ matrix.arch }} + name: openshell-sandbox-${{ matrix.rust_arch }}-unknown-linux-musl path: prebuilt/sandbox - - name: Extract prebuilt binaries + - name: Configure prebuilt binaries run: | set -euo pipefail - mkdir -p prebuilt/{cli,gateway,sandbox} - - for d in cli gateway sandbox; do - for tarball in prebuilt/$d/*.tar.gz; do - if [ -f "$tarball" ]; then - tar -xzf "$tarball" -C "prebuilt/$d" - else - echo "WARNING: no tarball found in prebuilt/$d/" >&2 - fi - done - done + chmod +x prebuilt/cli/openshell + chmod +x prebuilt/gateway/openshell-gateway + chmod +x prebuilt/sandbox/openshell-sandbox ls -laR prebuilt/ - name: Prepare snap build directory @@ -169,6 +163,7 @@ jobs: - name: Upload snap to Snap Store env: SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.publish-credentials }} + INPUTS_UPLOAD_CHANNEL: ${{ inputs.upload-channel }} run: | set -euo pipefail SNAP_FILE="${{ steps.capture.outputs.snap-file }}" @@ -182,5 +177,5 @@ jobs: COMPONENT_ARGS+=(--component "$comp") done - echo "Uploading $SNAP_FILE to ${{ inputs.upload-channel }}" - snapcraft upload --release "${{ inputs.upload-channel }}" "$SNAP_FILE" "${COMPONENT_ARGS[@]}" + echo "Uploading $SNAP_FILE to ${INPUTS_UPLOAD_CHANNEL}" + snapcraft upload --release "${INPUTS_UPLOAD_CHANNEL}" "$SNAP_FILE" "${COMPONENT_ARGS[@]}" diff --git a/.github/workflows/supervisor-static-validate.yml b/.github/workflows/supervisor-static-validate.yml deleted file mode 100644 index 3b460a4588..0000000000 --- a/.github/workflows/supervisor-static-validate.yml +++ /dev/null @@ -1,70 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -name: Supervisor Static Linkage Validation - -# The glibc-static supervisor variant (SUPERVISOR_LIBC=glibc-static) has no -# other CI caller: docker-build.yml builds the default musl variant, so the -# GNU + crt-static build branch and its native-only, per-arch requirements are -# never exercised by image or release CI. Build the variant here on both -# architectures so it cannot regress unnoticed. rust-native-build.yml runs -# verify-static-binary.sh for the sandbox component, which fails the job on any -# dynamic linkage. -# -# Linkage can change from a new/updated dependency or a source change, not just -# from the build scripts, so the push path filters cover the workspace manifests -# and crate sources in addition to the build tooling. A nightly schedule is the -# unfiltered backstop for anything the filters miss. -# -# rust-native-build.yml runs on NVIDIA self-hosted runners, which reject jobs -# triggered by `pull_request`. This workflow therefore follows the repo's -# self-hosted convention (see branch-checks.yml / branch-e2e.yml): validate in -# the merge queue (pre-merge), on push to main (post-merge), nightly, and on -# demand — never on `pull_request`. - -on: - merge_group: - types: [checks_requested] - push: - branches: [main] - paths: - - "Cargo.toml" - - "Cargo.lock" - - "crates/**" - - "rust-toolchain.toml" - - "mise.toml" - - "mise.lock" - - ".cargo/config.toml" - - "tasks/scripts/stage-prebuilt-binaries.sh" - - "tasks/scripts/verify-static-binary.sh" - - ".github/workflows/rust-native-build.yml" - - ".github/workflows/supervisor-static-validate.yml" - schedule: - # Nightly (04:17 UTC) unfiltered run so a linkage regression cannot slip - # through the path filters unnoticed. Schedules run only on the default branch. - - cron: "17 4 * * *" - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - -permissions: - contents: read - packages: read - -jobs: - glibc-static: - name: glibc-static supervisor (${{ matrix.arch }}) - strategy: - fail-fast: false - matrix: - arch: [amd64, arm64] - uses: ./.github/workflows/rust-native-build.yml - with: - component: sandbox - arch: ${{ matrix.arch }} - supervisor-libc: glibc-static - artifact-name: supervisor-glibc-static-${{ matrix.arch }} - retention-days: 1 - secrets: inherit diff --git a/CI.md b/CI.md index c3d33622f7..2120fa546b 100644 --- a/CI.md +++ b/CI.md @@ -14,14 +14,14 @@ Merge queue validation is a second integration gate for `main`. After a PR has p Three opt-in labels enable the long-running E2E suites: -- `test:e2e` runs the standard Docker, Kubernetes, and VM E2E - suites in `Branch E2E Checks` +- `test:e2e` runs the Docker, rootless Podman, Kubernetes, and VM E2E suites + with both managed and standalone compute drivers in `Branch E2E Checks` - `test:e2e-gpu` runs GPU E2E in `Branch E2E Checks` - `test:e2e-kubernetes` runs Kubernetes E2E with the HA Helm overlay (`replicaCount: 2` and bundled PostgreSQL) and the credential-driver suite (Kubernetes Secrets plus Vault) in `Branch E2E Checks` -When multiple labels are present, `Branch E2E Checks` builds the shared gateway and supervisor images once, builds one CLI artifact per runner architecture, builds the Linux VM driver artifact once, and fans out all enabled suites in parallel. Docker, Podman, GPU, Rust, Python, MCP, and VM E2E jobs reuse the matching prebuilt gateway and CLI binaries instead of compiling additional debug binaries in each job; Kubernetes E2E consumes the gateway image directly and reuses the prebuilt CLI. VM E2E also reuses the prebuilt VM driver artifact and falls back to local VM-driver/runtime preparation for local runs or workflow invocations that omit the artifact. +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. @@ -172,6 +172,10 @@ The bot's full administrator documentation is internal to NVIDIA. The only comma |---|---| | `.github/workflows/branch-checks.yml` | Required non-E2E checks. Triggers on `push: pull-request/[0-9]+` for PR mirrors and `merge_group` for queued merges. | | `.github/workflows/branch-e2e.yml` | Standard, GPU, Kubernetes HA, and Kubernetes credential-driver E2E. PR mirror pushes use `test:e2e`, `test:e2e-gpu`, and `test:e2e-kubernetes` labels; merge groups run core and GPU E2E. | +| `.github/workflows/build-{cli,gateway,sandbox}-binaries.yml` | Independent target matrices used by branch and release workflows without creating skipped jobs. | +| `.github/workflows/package-release-binaries.yml` | Packages raw build artifacts into release tarballs without rebuilding them. | +| `.github/workflows/e2e-docker-test.yml`, `e2e-podman-test.yml`, `e2e-vm-test.yml`, `e2e-kubernetes-test.yml` | Reusable runtime lanes called directly by branch and release workflows. Callers select suites and declare only the artifacts each runtime consumes. | +| `.github/actions/setup-e2e-*` | Shared artifact, Podman, KVM, and kind setup used by the runtime lanes. | | `.github/workflows/helm-lint.yml` | Helm chart validation. PR mirror pushes skip lint jobs unless Helm inputs changed; merge groups always validate Helm because they represent the final integration state. | | `.github/actions/setup-nix/action.yml` | Installs Nix and configures the OpenShell Cachix cache, using read-only cache access when no authentication token is available. | | `.github/actions/pr-gate/action.yml` | Composite action that resolves PR metadata and verifies the required label is set for PR mirror pushes. Non-push events are allowed through. | diff --git a/architecture/build.md b/architecture/build.md index ff8dc424cc..d4a58165fc 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -104,7 +104,7 @@ and the supervisor image from `deploy/docker/Dockerfile.supervisor`. Neither Dockerfile compiles Rust — both copy a staged binary out of `deploy/docker/.build/prebuilt-binaries//` into the final image. -Binary staging is driven by `tasks/scripts/stage-prebuilt-binaries.sh`. Because +Local binary staging is driven by `tasks/scripts/stage-prebuilt-binaries.sh`. Because staging cross-compiles on the host, it sources `tasks/scripts/build-env.sh` and raises the per-process open-file limit before invoking `cargo zigbuild` on macOS — the static musl link opens hundreds of `.rlib` files at once and would @@ -126,9 +126,9 @@ host GNU libc target. The `glibc-static` variant uses plain `cargo build` with target architecture from `DOCKER_PLATFORM` when set. Otherwise, they require valid container engine host metadata and fail when the engine query is unavailable or reports an unsupported architecture, avoiding host-kernel -fallbacks that can target the wrong architecture. CI invokes the same staging -step via the `rust-native-build.yml` workflow (per-architecture, per-component) -and uploads the result as an artifact that the image build job downloads back +fallbacks that can target the wrong architecture. CI instead compiles binaries +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. Gateway and supervisor binaries staged into branch E2E, Release Dev, and Release @@ -141,17 +141,12 @@ 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. -`docker-build.yml` and `rust-native-build.yml` take an `auditable` input that -defaults to false. The branch E2E gateway and supervisor image builds set it to -true so E2E exercises release-parity image binaries; other PR image builds and -standalone release artifacts stay non-auditable. The CI image gains the pinned -`cargo-auditable` tool through `mise install --locked` but ships no auditable -OpenShell binary of its own. Local staging opts in with -`OPENSHELL_AUDITABLE=1`. sccache's `RUSTC_WRAPPER` is unset only around auditable -builds, because it would otherwise wrap `cargo-auditable`'s workspace wrapper and -be misidentified as `rustc`. Auditable builds are verified by scanning the built -binary with Syft and requiring at least one decoded Cargo package; the check runs -only for those builds. +The shared binary build action compiles release artifacts with `cargo auditable`. +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. The CI image gains the pinned `cargo-auditable` tool through +`mise install --locked` but ships no auditable OpenShell binary of its own. Pushed Docker images carry minimal SLSA provenance and a per-platform SPDX SBOM generated by BuildKit's default Syft scanner. The registry exporter uses OCI diff --git a/deploy/docker/Dockerfile.gateway b/deploy/docker/Dockerfile.gateway index 62a55334ba..cf68dc967a 100644 --- a/deploy/docker/Dockerfile.gateway +++ b/deploy/docker/Dockerfile.gateway @@ -3,21 +3,10 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Gateway image build. -# -# The Rust binary is built natively before this image build runs and staged at: +# The GNU-linked gateway binary is staged at: # deploy/docker/.build/prebuilt-binaries//openshell-gateway # -# Use tasks/scripts/docker-build-image.sh gateway (or `mise run build:docker:gateway`) -# to stage the binary and build the image in one step. CI builds the binary -# per-architecture via the `rust-native-build.yml` workflow and uploads it as -# an artifact, which is downloaded into the same staging directory before the -# image build job runs. -# -# The runtime is distroless Debian 13, which provides glibc and the dynamic -# loader needed by the GNU-linked gateway binary while keeping the attack -# surface small. The default digest currently carries Debian glibc -# 2.41-12+deb13u3. +# Distroless Debian provides the glibc runtime required by the binary. ARG GATEWAY_BASE_IMAGE=gcr.io/distroless/cc-debian13:nonroot@sha256:d97bc0a941b8d4be647dc0ee75b264ddbb772f1ac5ba690a4309c00723b23775 FROM ${GATEWAY_BASE_IMAGE} AS gateway diff --git a/deploy/docker/Dockerfile.supervisor b/deploy/docker/Dockerfile.supervisor index c77c5c0aff..d515fd70b1 100644 --- a/deploy/docker/Dockerfile.supervisor +++ b/deploy/docker/Dockerfile.supervisor @@ -3,24 +3,10 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Supervisor image build. -# -# The final image carries the static `openshell-sandbox` binary used by Docker -# extraction, Podman image volumes, and the Kubernetes init container copy-self -# path. It also includes nftables so the Kubernetes supervisor sidecar can -# install pod-namespace egress enforcement rules. -# -# The Rust binary is built natively before this image build runs and staged at: +# The static sandbox binary is staged at: # deploy/docker/.build/prebuilt-binaries//openshell-sandbox # -# Use tasks/scripts/docker-build-image.sh supervisor (or `mise run build:docker:supervisor`) -# to stage the binary and build the image in one step. CI builds the binary -# per-architecture via the `rust-native-build.yml` workflow and uploads it as an -# artifact, which is downloaded into the same staging directory before the image -# build job runs. -# -# The binary is static under either supported libc variant (`SUPERVISOR_LIBC`: -# musl by default, or glibc-static), so this Alpine base runs it unchanged. +# Alpine supplies nftables and iptables for pod-namespace egress enforcement. FROM alpine:3.22 AS supervisor @@ -28,10 +14,8 @@ ARG TARGETARCH RUN apk add --no-cache nftables iptables iptables-legacy -# --chmod=0555 restores execute bits after the actions/upload-artifact + -# download-artifact roundtrip strips them. Ownership stays root (0:0) for -# Podman image-volume mounts, while world-execute lets the Kubernetes -# network sidecar run this binary as the dedicated non-root proxy UID. +# Keep the binary root-owned for Podman image-volume mounts and executable by +# the Kubernetes network sidecar's non-root proxy UID. COPY --chmod=0555 deploy/docker/.build/prebuilt-binaries/${TARGETARCH}/openshell-sandbox /openshell-sandbox ENTRYPOINT ["/openshell-sandbox"] diff --git a/e2e/rust/e2e-vm.sh b/e2e/rust/e2e-vm.sh index 1960f83588..4acb7256bb 100755 --- a/e2e/rust/e2e-vm.sh +++ b/e2e/rust/e2e-vm.sh @@ -102,7 +102,7 @@ if [ -z "${OPENSHELL_GATEWAY_BIN:-}" ]; then echo "==> Building driver-free openshell-gateway" cargo build \ -p openshell-server --bin openshell-gateway \ - --no-default-features --features telemetry + --no-default-features else build_packages+=(-p openshell-server) fi diff --git a/e2e/support/gateway-common.sh b/e2e/support/gateway-common.sh index 1b2b8e4145..c512dab60a 100644 --- a/e2e/support/gateway-common.sh +++ b/e2e/support/gateway-common.sh @@ -220,7 +220,7 @@ e2e_build_gateway_binaries() { if [ "${OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER:-0}" = "1" ]; then cargo build "${jobs[@]}" \ -p openshell-server --bin openshell-gateway \ - --no-default-features --features telemetry + --no-default-features else cargo build "${jobs[@]}" \ -p openshell-server --bin openshell-gateway @@ -259,9 +259,14 @@ e2e_build_external_driver() { jobs=(-j "${CARGO_BUILD_JOBS}") fi target_dir="$(e2e_cargo_target_dir "${root}")" - printf -v "${output_var}" '%s' "${target_dir}/debug/${binary}" - echo "Building external ${binary}..." - cargo build "${jobs[@]}" -p "${package}" --bin "${binary}" + if [ -n "${OPENSHELL_EXTERNAL_DRIVER_BIN:-}" ]; then + printf -v "${output_var}" '%s' "${OPENSHELL_EXTERNAL_DRIVER_BIN}" + echo "Using prebuilt external driver at ${OPENSHELL_EXTERNAL_DRIVER_BIN}" + else + printf -v "${output_var}" '%s' "${target_dir}/debug/${binary}" + echo "Building external ${binary}..." + cargo build "${jobs[@]}" -p "${package}" --bin "${binary}" + fi if [ ! -x "${!output_var}" ]; then echo "ERROR: expected external driver binary at ${!output_var}" >&2 exit 1 diff --git a/e2e/with-kube-gateway.sh b/e2e/with-kube-gateway.sh index f83c8bafe1..08dd08113e 100755 --- a/e2e/with-kube-gateway.sh +++ b/e2e/with-kube-gateway.sh @@ -640,9 +640,15 @@ if [ "${OPENSHELL_E2E_KUBE_BUILD_IMAGES}" = "1" ]; then echo "ERROR: external Kubernetes driver image composition currently requires a Linux build host." >&2 exit 2 fi - cargo build -p openshell-server --bin openshell-gateway \ - --no-default-features --features telemetry,bundled-z3 - cargo build -p openshell-driver-kubernetes --bin openshell-driver-kubernetes + external_gateway="${OPENSHELL_GATEWAY_BIN:-${ROOT}/target/debug/openshell-gateway}" + external_driver="${OPENSHELL_EXTERNAL_DRIVER_BIN:-${ROOT}/target/debug/openshell-driver-kubernetes}" + if [ -z "${OPENSHELL_GATEWAY_BIN:-}" ]; then + cargo build -p openshell-server --bin openshell-gateway \ + --no-default-features --features bundled-z3 + fi + if [ -z "${OPENSHELL_EXTERNAL_DRIVER_BIN:-}" ]; then + cargo build -p openshell-driver-kubernetes --bin openshell-driver-kubernetes + fi case "$(uname -m)" in x86_64) external_arch=amd64 ;; aarch64|arm64) external_arch=arm64 ;; @@ -650,9 +656,8 @@ if [ "${OPENSHELL_E2E_KUBE_BUILD_IMAGES}" = "1" ]; then esac external_stage="${ROOT}/deploy/docker/.build/prebuilt-binaries/${external_arch}" mkdir -p "${external_stage}" - cp "${ROOT}/target/debug/openshell-gateway" "${external_stage}/openshell-gateway" - cp "${ROOT}/target/debug/openshell-driver-kubernetes" \ - "${external_stage}/openshell-driver-kubernetes" + cp "${external_gateway}" "${external_stage}/openshell-gateway" + cp "${external_driver}" "${external_stage}/openshell-driver-kubernetes" docker build \ --build-arg "TARGETARCH=${external_arch}" \ --build-arg "SUPERVISOR_IMAGE=${REGISTRY_VALUE}/supervisor:${IMAGE_TAG_VALUE}" \ diff --git a/flake.nix b/flake.nix index 68e6156e3b..e361fd1597 100644 --- a/flake.nix +++ b/flake.nix @@ -47,16 +47,20 @@ testGuestPkgs = import nixpkgs-test-guest { inherit system; }; commonDevShellPackages = with pkgs; [ actionlint + cargo-auditable cargo-deny cargo-nextest # Assemble Debian artifacts on macOS and Linux. dpkg + git # Required to find packages. pkg-config # Coverage. lcov + syft uv zizmor + zstd ]; treefmtEval = treefmt-nix.lib.evalModule pkgs { projectRootFile = "flake.nix"; @@ -65,6 +69,7 @@ rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; z3-static = pkgs.callPackage ./nix/pkgs/z3-static.nix { }; aws-lc-static = pkgs.callPackage ./nix/pkgs/aws-lc-static.nix { }; + vmRuntime = pkgs.callPackage ./nix/pkgs/vm-runtime.nix { }; testGuest = import ./nix/test-guest { inherit pkgs; qemuPkgs = testGuestPkgs; @@ -75,6 +80,8 @@ apps.test-guest = testGuest.app; apps.test-guest-cache = testGuest.cacheApp; + packages.vm-runtime = vmRuntime; + devShells = { default = (pkgs.mkShell.override { diff --git a/nix/devShells/musl.nix b/nix/devShells/musl.nix index ba7c06bf22..d0dbda93c1 100644 --- a/nix/devShells/musl.nix +++ b/nix/devShells/musl.nix @@ -9,7 +9,7 @@ let muslPkgs = pkgs.pkgsMusl; - stdenv = pkgs.stdenvAdapters.useMoldLinker muslPkgs.stdenv; + stdenv = muslPkgs.stdenv; rust-bin = rust-overlay.lib.mkRustBin { } muslPkgs; rustToolchain = (rust-bin.fromRustupToolchainFile ../../rust-toolchain.toml).override { enableLibsecret = false; diff --git a/nix/pkgs/vm-runtime.nix b/nix/pkgs/vm-runtime.nix new file mode 100644 index 0000000000..49a051e276 --- /dev/null +++ b/nix/pkgs/vm-runtime.nix @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +{ + fetchurl, + stdenv, + zstd, +}: + +let + runtime = + { + x86_64-linux = { + platform = "linux-x86_64"; + hash = "sha256-dw3Lc7IapCyNeE7j6dnlgd/b8Yc91/7IOi3XJORyILQ="; + }; + aarch64-linux = { + platform = "linux-aarch64"; + hash = "sha256-aJDuDb7AsuH9R+AyXA/JIxE9fJmZ5kP0Lkhg6F0Ot5A="; + }; + aarch64-darwin = { + platform = "darwin-aarch64"; + hash = "sha256-BDSeY5XGDozaBZzHTiQQX90jzsSc6shJZs5zdzludX0="; + }; + } + .${stdenv.hostPlatform.system}; + archive = fetchurl { + url = "https://github.com/NVIDIA/OpenShell/releases/download/vm-runtime/vm-runtime-${runtime.platform}.tar.zst"; + inherit (runtime) hash; + }; +in +stdenv.mkDerivation { + name = "openshell-vm-runtime-${runtime.platform}"; + + nativeBuildInputs = [ zstd ]; + dontUnpack = true; + + installPhase = '' + runHook preInstall + + mkdir -p "$out" + tar --extract --file ${archive} --directory "$out" + + runHook postInstall + ''; +} diff --git a/nix/toolchains/linux-gnu-2.28/default.nix b/nix/toolchains/linux-gnu-2.28/default.nix index 3e911f1ced..4b8b0ab453 100644 --- a/nix/toolchains/linux-gnu-2.28/default.nix +++ b/nix/toolchains/linux-gnu-2.28/default.nix @@ -31,7 +31,10 @@ let pkgs.overrideCC pkgs.stdenv ( pkgs.wrapCCWith { cc = pkgs.gccNGPackages.gcc-unwrapped.overrideAttrs (old: { - configureFlags = old.configureFlags ++ [ "--disable-fixincludes" ]; + configureFlags = old.configureFlags ++ [ + "--disable-fixincludes" + "--with-native-system-header-dir=/include" + ]; }); bintools = pkgs.wrapBintoolsWith { bintools = pkgs.binutils-unwrapped; diff --git a/scripts/baseline_workflow_metrics.py b/scripts/baseline_workflow_metrics.py index 5a15b70801..ffd5054679 100755 --- a/scripts/baseline_workflow_metrics.py +++ b/scripts/baseline_workflow_metrics.py @@ -41,9 +41,15 @@ WORKFLOWS: list[str] = [ "branch-checks.yml", "branch-e2e.yml", + "build-cli-binaries.yml", + "build-gateway-binaries.yml", + "build-sandbox-binaries.yml", "ci-image.yml", "docker-build.yml", - "e2e-test.yml", + "e2e-docker-test.yml", + "e2e-podman-test.yml", + "e2e-vm-test.yml", + "package-release-binaries.yml", "release-canary.yml", "release-dev.yml", "release-tag.yml", @@ -55,8 +61,14 @@ # workflow_run. For these, we scan all repo runs in the window and attribute # via `referenced_workflows`. REUSABLE_WORKFLOWS: set[str] = { + "build-cli-binaries.yml", + "build-gateway-binaries.yml", + "build-sandbox-binaries.yml", "docker-build.yml", - "e2e-test.yml", + "e2e-docker-test.yml", + "e2e-podman-test.yml", + "e2e-vm-test.yml", + "package-release-binaries.yml", } # Conclusions that represent a real execution on a runner. Percentile math From f68867b8691e72ba25e0fa7d9ee4f32adbc4701c Mon Sep 17 00:00:00 2001 From: krishicks Date: Thu, 27 Aug 2026 17:27:34 +0000 Subject: [PATCH 039/505] feat(gateway): identify gateways in exported traces (#2647) * feat(gateway): add installation name configuration Add a first-class operator-assigned gateway name with TOML, CLI, environment, and Helm configuration surfaces. Local gateways default to openshell, while Helm defaults to the chart fullname; operators sharing a collector across namespaces or clusters can set a globally distinct name. Signed-off-by: Kris Hicks * feat(gateway): identify gateways in exported traces Attach the configured gateway installation name and compute driver to the gateway OpenTelemetry resource so operators can filter traces from multiple installations that share a collector. Forward the gateway name and OTLP endpoint to managed external drivers so their distinct service resources carry the same installation identity. Keep service.name stable per process type, omit blank resource values, and leave per-span operation names and request attributes unchanged. Refs #2507 Signed-off-by: Kris Hicks --------- Signed-off-by: Kris Hicks --- architecture/gateway.md | 23 +++- crates/openshell-core/src/config.rs | 23 ++++ crates/openshell-driver-docker/src/main.rs | 9 +- .../src/otel_tracing.rs | 32 +++-- crates/openshell-driver-kubernetes/Cargo.toml | 2 +- crates/openshell-driver-kubernetes/README.md | 4 +- .../openshell-driver-kubernetes/src/main.rs | 14 +- .../src/otel_tracing.rs | 33 +++-- crates/openshell-driver-podman/src/main.rs | 16 ++- .../src/otel_tracing.rs | 33 +++-- crates/openshell-driver-vm/README.md | 7 +- crates/openshell-driver-vm/src/main.rs | 13 +- .../openshell-driver-vm/src/otel_tracing.rs | 34 +++-- crates/openshell-otel-test-support/src/lib.rs | 29 +++-- crates/openshell-server/src/cli.rs | 37 +++++- crates/openshell-server/src/compute/vm.rs | 18 ++- crates/openshell-server/src/config_file.rs | 5 + crates/openshell-server/src/lib.rs | 25 ++-- crates/openshell-server/src/otel_tracing.rs | 120 +++++++++++++++--- crates/openshell-server/src/tracing_setup.rs | 15 ++- deploy/helm/openshell/README.md | 1 + .../openshell/templates/gateway-config.yaml | 1 + .../openshell/tests/gateway_config_test.yaml | 16 +++ deploy/helm/openshell/values.yaml | 4 + docs/reference/gateway-config.mdx | 12 +- tasks/scripts/gateway-docker.sh | 1 + tasks/scripts/gateway-podman.sh | 3 +- tasks/scripts/gateway-vm.sh | 3 +- tasks/scripts/gateway.sh | 1 + 29 files changed, 425 insertions(+), 109 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index f86f855845..dcb1dd5414 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -673,6 +673,15 @@ Driver implementation settings live in the TOML driver tables. See `docs/reference/gateway-config.mdx` for worked per-driver examples and RFC 0003 for the full schema. +Each installation has an operator-assigned gateway name. Configure it with +`[openshell.gateway].name`, `--name`, or `OPENSHELL_GATEWAY_NAME`. +The built-in default is `openshell`; the Helm chart defaults it to the chart +fullname so every replica in one installation reports the same identity. +Operators must set a globally distinct name when one telemetry collector serves +installations in multiple Kubernetes namespaces or clusters. +The name identifies the gateway installation independently of client-side +aliases, network names, and the sandbox JWT issuer. + `database_url` is env-only and rejected when present in the file (`OPENSHELL_DB_URL` / `--db-url`). @@ -721,12 +730,14 @@ between a trace and its log lines. Store and compute-driver spans become children of the request span. Reconciliation, provider refresh, and driver-watch loops create their own operation spans because they have no inbound request to provide a parent. gRPC status is recorded when response -trailers arrive. - -The gateway forwards OTLP configuration 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 exports to the configured -collector under its own service name. +trailers arrive. Gateway spans carry resource attributes for the gateway +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 +exports to the configured collector under its own service name and carries the +gateway name as a resource attribute. Two invariants shape the failure behavior. Telemetry is diagnostic, so no OTLP failure stops the gateway from serving: a malformed endpoint is logged at diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index ce613974a4..e04e056033 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -29,6 +29,9 @@ pub const DEFAULT_SSH_PORT: u16 = 2222; /// Default gateway server port. pub const DEFAULT_SERVER_PORT: u16 = 17670; +/// Default operator-facing name for a gateway installation. +pub const DEFAULT_GATEWAY_NAME: &str = "openshell"; + /// Default container stop timeout in seconds (SIGTERM → SIGKILL). pub const DEFAULT_STOP_TIMEOUT_SECS: u32 = 10; @@ -761,6 +764,9 @@ fn docker_socket_responds(path: &Path) -> bool { /// `Deserialize` impls for that purpose). #[derive(Debug, Clone)] pub struct Config { + /// Operator-assigned name for this gateway installation. + pub name: String, + /// Address to bind the server to. pub bind_address: SocketAddr, @@ -1168,6 +1174,7 @@ impl Config { /// Create a new config with optional TLS. pub fn new(tls: Option) -> Self { Self { + name: DEFAULT_GATEWAY_NAME.to_string(), bind_address: default_bind_address(), health_bind_address: None, metrics_bind_address: None, @@ -1195,6 +1202,13 @@ impl Config { } } + /// Create a new configuration with the gateway installation name. + #[must_use] + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = name.into(); + self + } + /// Create a new configuration with the given bind address. #[must_use] pub const fn with_bind_address(mut self, addr: SocketAddr) -> Self { @@ -1573,6 +1587,15 @@ mod tests { assert_eq!(cfg.ttl_secs, 0); } + #[test] + fn name_defaults_and_can_be_overridden() { + assert_eq!(Config::new(None).name, "openshell"); + assert_eq!( + Config::new(None).with_name("production-us-west").name, + "production-us-west" + ); + } + #[test] fn gateway_interceptor_failure_policy_rejects_ignore() { let err = diff --git a/crates/openshell-driver-docker/src/main.rs b/crates/openshell-driver-docker/src/main.rs index 80a6353d31..2c6dbfd4c1 100644 --- a/crates/openshell-driver-docker/src/main.rs +++ b/crates/openshell-driver-docker/src/main.rs @@ -38,13 +38,18 @@ struct Args { #[arg(long, env = "OPENSHELL_OTLP_ENDPOINT")] otlp_endpoint: Option, + + #[arg(long, env = "OPENSHELL_GATEWAY_NAME")] + gateway_name: Option, } #[tokio::main] async fn main() -> Result<()> { let args = Args::parse(); - let (tracer_provider, setup_error) = - openshell_driver_docker::otel_tracing::provider_for(args.otlp_endpoint.as_deref()); + let (tracer_provider, setup_error) = openshell_driver_docker::otel_tracing::provider_for( + args.otlp_endpoint.as_deref(), + args.gateway_name.as_deref(), + ); tracing_subscriber::registry() .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level))) .with(tracing_subscriber::fmt::layer()) diff --git a/crates/openshell-driver-docker/src/otel_tracing.rs b/crates/openshell-driver-docker/src/otel_tracing.rs index f232fc1e7b..7623279c16 100644 --- a/crates/openshell-driver-docker/src/otel_tracing.rs +++ b/crates/openshell-driver-docker/src/otel_tracing.rs @@ -90,13 +90,28 @@ pub(crate) fn compute_driver_rpc_operation(path: &str) -> (&'static str, &'stati } } +/// Build a tracer provider for the configured OTLP/gRPC endpoint and gateway. #[must_use] -pub fn provider_for(endpoint: Option<&str>) -> (Option, Option) { - openshell_otel::provider_for(endpoint.map(|endpoint| OtlpTraceConfig { - endpoint, - service_name: ServiceName::Fixed(SERVICE_NAME), - service_version: Some(openshell_core::VERSION), - resource_attributes: Vec::new(), +pub fn provider_for( + endpoint: Option<&str>, + gateway_name: Option<&str>, +) -> (Option, Option) { + openshell_otel::provider_for(endpoint.map(|endpoint| { + OtlpTraceConfig { + endpoint, + service_name: ServiceName::Fixed(SERVICE_NAME), + service_version: Some(openshell_core::VERSION), + resource_attributes: gateway_name + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(|name| { + vec![opentelemetry::KeyValue::new( + "openshell.gateway.name", + name.to_string(), + )] + }) + .unwrap_or_default(), + } })) } @@ -151,11 +166,11 @@ mod tests { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn tracing_docker_driver_spans_reach_otlp_collector_with_distinct_service_name() { + async fn tracing_docker_driver_spans_reach_otlp_collector_with_resource_identity() { let _tracing_lock = super::test_lock().await; let collector = OtlpTestServer::start().await; - let (provider, error) = super::provider_for(Some(collector.endpoint())); + let (provider, error) = super::provider_for(Some(collector.endpoint()), Some("docker-dev")); assert!(error.is_none()); let provider = provider.expect("provider"); let subscriber = tracing_subscriber::registry().with(super::layer(&provider)); @@ -175,6 +190,7 @@ mod tests { .iter() .any(|span| span.name == "docker.schedule_sandbox") ); + assert_eq!(received.gateway_names, ["docker-dev"]); assert!( received .service_names diff --git a/crates/openshell-driver-kubernetes/Cargo.toml b/crates/openshell-driver-kubernetes/Cargo.toml index d7fcf19a17..6e821317c3 100644 --- a/crates/openshell-driver-kubernetes/Cargo.toml +++ b/crates/openshell-driver-kubernetes/Cargo.toml @@ -19,6 +19,7 @@ openshell-core = { path = "../openshell-core", default-features = false } openshell-otel = { path = "../openshell-otel" } openshell-policy = { path = "../openshell-policy" } +opentelemetry = { workspace = true } tokio = { workspace = true } tonic = { workspace = true, features = ["transport"] } prost = { workspace = true } @@ -39,7 +40,6 @@ notify = "8" [dev-dependencies] openshell-otel-test-support = { path = "../openshell-otel-test-support" } -opentelemetry = { workspace = true } opentelemetry_sdk = { workspace = true, features = ["testing"] } bytes = { workspace = true } http = { workspace = true } diff --git a/crates/openshell-driver-kubernetes/README.md b/crates/openshell-driver-kubernetes/README.md index b352f6efe9..6d6e26654c 100644 --- a/crates/openshell-driver-kubernetes/README.md +++ b/crates/openshell-driver-kubernetes/README.md @@ -23,7 +23,9 @@ When the gateway configures `[openshell.gateway.otlp]`, Kubernetes compute-driver spans export to the same OTLP/gRPC collector with the service name `openshell-driver-kubernetes`. The driver preserves the gateway trace context and uses the same compute-driver RPC span names in its in-process and -standalone forms. +standalone forms. Standalone deployments set `--gateway-name` or +`OPENSHELL_GATEWAY_NAME` so exported spans carry the same +`openshell.gateway.name` resource attribute as gateway spans. When it creates an Agent Sandbox resource, the driver serializes the active W3C trace context into the controller-reserved `opentelemetry.io/trace-context` diff --git a/crates/openshell-driver-kubernetes/src/main.rs b/crates/openshell-driver-kubernetes/src/main.rs index 712cc1cbda..7690ccee85 100644 --- a/crates/openshell-driver-kubernetes/src/main.rs +++ b/crates/openshell-driver-kubernetes/src/main.rs @@ -42,6 +42,9 @@ struct Args { #[arg(long, env = "OPENSHELL_OTLP_ENDPOINT")] otlp_endpoint: Option, + #[arg(long, env = "OPENSHELL_GATEWAY_NAME")] + gateway_name: Option, + #[arg(long, env = "OPENSHELL_WORKSPACE_MODE", default_value = "shared")] workspace_mode: WorkspaceMode, @@ -215,8 +218,10 @@ async fn shutdown_signal() { #[tokio::main] async fn main() -> Result<()> { let args = Args::parse(); - let (tracer_provider, setup_error) = - openshell_driver_kubernetes::otel_tracing::provider_for(args.otlp_endpoint.as_deref()); + let (tracer_provider, setup_error) = openshell_driver_kubernetes::otel_tracing::provider_for( + args.otlp_endpoint.as_deref(), + args.gateway_name.as_deref(), + ); tracing_subscriber::registry() .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level))) .with(tracing_subscriber::fmt::layer()) @@ -349,11 +354,13 @@ mod tests { use super::*; #[test] - fn accepts_gateway_otlp_endpoint() { + fn accepts_gateway_otlp_configuration() { let args = Args::try_parse_from([ "openshell-driver-kubernetes", "--otlp-endpoint", "http://collector.example:4317", + "--gateway-name", + "kubernetes-dev", ]) .expect("OTLP endpoint should parse"); @@ -361,5 +368,6 @@ mod tests { args.otlp_endpoint.as_deref(), Some("http://collector.example:4317") ); + assert_eq!(args.gateway_name.as_deref(), Some("kubernetes-dev")); } } diff --git a/crates/openshell-driver-kubernetes/src/otel_tracing.rs b/crates/openshell-driver-kubernetes/src/otel_tracing.rs index 1a8d40071d..02501651f8 100644 --- a/crates/openshell-driver-kubernetes/src/otel_tracing.rs +++ b/crates/openshell-driver-kubernetes/src/otel_tracing.rs @@ -12,13 +12,28 @@ const SERVICE_NAME: &str = "openshell-driver-kubernetes"; const INSTRUMENTATION_SCOPE: &str = "openshell-driver-kubernetes"; pub const IN_PROCESS_TARGET_PREFIX: &str = "openshell_driver_kubernetes"; +/// Build a tracer provider for the configured OTLP/gRPC endpoint and gateway. #[must_use] -pub fn provider_for(endpoint: Option<&str>) -> (Option, Option) { - openshell_otel::provider_for(endpoint.map(|endpoint| OtlpTraceConfig { - endpoint, - service_name: ServiceName::Fixed(SERVICE_NAME), - service_version: Some(openshell_core::VERSION), - resource_attributes: Vec::new(), +pub fn provider_for( + endpoint: Option<&str>, + gateway_name: Option<&str>, +) -> (Option, Option) { + openshell_otel::provider_for(endpoint.map(|endpoint| { + OtlpTraceConfig { + endpoint, + service_name: ServiceName::Fixed(SERVICE_NAME), + service_version: Some(openshell_core::VERSION), + resource_attributes: gateway_name + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(|name| { + vec![opentelemetry::KeyValue::new( + "openshell.gateway.name", + name.to_string(), + )] + }) + .unwrap_or_default(), + } })) } @@ -75,10 +90,11 @@ mod tests { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn tracing_kubernetes_driver_spans_reach_otlp_collector_with_distinct_service_name() { + async fn tracing_kubernetes_driver_spans_reach_otlp_collector_with_resource_identity() { let _tracing_lock = super::test_lock().await; let collector = OtlpTestServer::start().await; - let (provider, error) = super::provider_for(Some(collector.endpoint())); + let (provider, error) = + super::provider_for(Some(collector.endpoint()), Some("kubernetes-dev")); assert!(error.is_none()); let provider = provider.expect("provider"); let subscriber = tracing_subscriber::registry().with(super::layer(&provider)); @@ -98,6 +114,7 @@ mod tests { .iter() .any(|span| span.name == "kubernetes.create_sandbox") ); + assert_eq!(received.gateway_names, ["kubernetes-dev"]); assert!( received .service_names diff --git a/crates/openshell-driver-podman/src/main.rs b/crates/openshell-driver-podman/src/main.rs index 7055cb089a..a0fa85d018 100644 --- a/crates/openshell-driver-podman/src/main.rs +++ b/crates/openshell-driver-podman/src/main.rs @@ -40,6 +40,9 @@ struct Args { #[arg(long, env = "OPENSHELL_OTLP_ENDPOINT")] otlp_endpoint: Option, + #[arg(long, env = "OPENSHELL_GATEWAY_NAME")] + gateway_name: Option, + /// Path to the Podman API Unix socket. #[arg(long, env = "OPENSHELL_PODMAN_SOCKET")] podman_socket: Option, @@ -174,8 +177,10 @@ struct Args { #[tokio::main] async fn main() -> Result<()> { let args = Args::parse(); - let (tracer_provider, setup_error) = - openshell_driver_podman::otel_tracing::provider_for(args.otlp_endpoint.as_deref()); + let (tracer_provider, setup_error) = openshell_driver_podman::otel_tracing::provider_for( + args.otlp_endpoint.as_deref(), + args.gateway_name.as_deref(), + ); tracing_subscriber::registry() .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level))) .with(tracing_subscriber::fmt::layer()) @@ -306,17 +311,20 @@ mod tests { } #[test] - fn accepts_gateway_otlp_endpoint() { + fn accepts_gateway_otlp_configuration() { let args = Args::try_parse_from([ "openshell-driver-podman", "--otlp-endpoint", "http://collector.internal:4317", + "--gateway-name", + "production-us-west", ]) - .expect("OTLP endpoint should be accepted"); + .expect("OTLP configuration should be accepted"); assert_eq!( args.otlp_endpoint.as_deref(), Some("http://collector.internal:4317") ); + assert_eq!(args.gateway_name.as_deref(), Some("production-us-west")); } } diff --git a/crates/openshell-driver-podman/src/otel_tracing.rs b/crates/openshell-driver-podman/src/otel_tracing.rs index 00163c206e..646472c8ed 100644 --- a/crates/openshell-driver-podman/src/otel_tracing.rs +++ b/crates/openshell-driver-podman/src/otel_tracing.rs @@ -12,13 +12,28 @@ const SERVICE_NAME: &str = "openshell-driver-podman"; const INSTRUMENTATION_SCOPE: &str = "openshell-driver-podman"; pub const IN_PROCESS_TARGET_PREFIX: &str = "openshell_driver_podman"; +/// Build a tracer provider for the configured OTLP/gRPC endpoint and gateway. #[must_use] -pub fn provider_for(endpoint: Option<&str>) -> (Option, Option) { - openshell_otel::provider_for(endpoint.map(|endpoint| OtlpTraceConfig { - endpoint, - service_name: ServiceName::Fixed(SERVICE_NAME), - service_version: Some(openshell_core::VERSION), - resource_attributes: Vec::new(), +pub fn provider_for( + endpoint: Option<&str>, + gateway_name: Option<&str>, +) -> (Option, Option) { + openshell_otel::provider_for(endpoint.map(|endpoint| { + OtlpTraceConfig { + endpoint, + service_name: ServiceName::Fixed(SERVICE_NAME), + service_version: Some(openshell_core::VERSION), + resource_attributes: gateway_name + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(|name| { + vec![opentelemetry::KeyValue::new( + "openshell.gateway.name", + name.to_string(), + )] + }) + .unwrap_or_default(), + } })) } @@ -114,11 +129,12 @@ mod tests { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn podman_driver_spans_reach_otlp_collector_with_distinct_service_name() { + async fn podman_driver_spans_reach_otlp_collector_with_resource_identity() { let _tracing_lock = super::test_lock().await; let collector = OtlpTestServer::start().await; - let (provider, error) = super::provider_for(Some(collector.endpoint())); + let (provider, error) = + super::provider_for(Some(collector.endpoint()), Some("production-us-west")); assert!(error.is_none()); let provider = provider.expect("provider"); let subscriber = tracing_subscriber::registry().with(super::layer(&provider)); @@ -138,6 +154,7 @@ mod tests { .iter() .any(|span| span.name == "podman.create") ); + assert_eq!(received.gateway_names, ["production-us-west"]); assert!( received .service_names diff --git a/crates/openshell-driver-vm/README.md b/crates/openshell-driver-vm/README.md index 19ac66c3f9..67666be735 100644 --- a/crates/openshell-driver-vm/README.md +++ b/crates/openshell-driver-vm/README.md @@ -40,7 +40,10 @@ First run takes a few minutes while `mise run vm:setup` stages libkrun/libkrunfw By default `mise run gateway:vm`: - Listens on plaintext HTTP at `127.0.0.1:18081`. -- Registers the CLI gateway `vm-dev` by writing `~/.config/openshell/gateways/vm-dev/metadata.json`. It does not modify the workspace `.env`. +- Configures the gateway installation name as `vm-dev` and registers the same + name with the CLI by writing + `~/.config/openshell/gateways/vm-dev/metadata.json`. It does not modify the + workspace `.env`. - Persists the gateway SQLite DB under `.cache/gateway-vm/gateway.db`. - Places the VM driver state (per-sandbox `overlay.ext4`, image cache, and `run/compute-driver.sock`) under `/tmp/openshell-vm-driver-$USER-vm-dev/` so the AF_UNIX socket path stays under macOS `SUN_LEN`. - Writes `.cache/gateway-vm/gateway.toml` with `[openshell.drivers.vm].driver_dir = "$PWD/target/debug"` so the freshly built `openshell-driver-vm` is used instead of an older installed copy from `~/.local/libexec/openshell`, `/usr/libexec/openshell`, or `/usr/local/libexec`. @@ -68,7 +71,7 @@ Override defaults via environment: # custom port (fails fast if in use) OPENSHELL_SERVER_PORT=18091 mise run gateway:vm -# custom CLI gateway name + namespace +# custom gateway installation/CLI name + namespace OPENSHELL_VM_GATEWAY_NAME=vm-feature-a \ OPENSHELL_SANDBOX_NAMESPACE=vm-feature-a \ mise run gateway:vm diff --git a/crates/openshell-driver-vm/src/main.rs b/crates/openshell-driver-vm/src/main.rs index 949d4ce05c..95ebf0f8b2 100644 --- a/crates/openshell-driver-vm/src/main.rs +++ b/crates/openshell-driver-vm/src/main.rs @@ -91,6 +91,9 @@ struct Args { #[arg(long, env = "OPENSHELL_OTLP_ENDPOINT")] otlp_endpoint: Option, + #[arg(long, env = "OPENSHELL_GATEWAY_NAME")] + gateway_name: Option, + #[arg(long, env = "OPENSHELL_GRPC_ENDPOINT")] openshell_endpoint: Option, @@ -186,8 +189,10 @@ async fn main() -> Result<()> { return Ok(()); } - let (tracer_provider, setup_error) = - openshell_driver_vm::otel_tracing::provider_for(args.otlp_endpoint.as_deref()); + let (tracer_provider, setup_error) = openshell_driver_vm::otel_tracing::provider_for( + args.otlp_endpoint.as_deref(), + args.gateway_name.as_deref(), + ); tracing_subscriber::registry() .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level))) .with(tracing_subscriber::fmt::layer()) @@ -691,11 +696,13 @@ mod tests { } #[test] - fn accepts_gateway_otlp_endpoint() { + fn accepts_gateway_otlp_configuration() { let args = Args::try_parse_from([ "openshell-driver-vm", "--otlp-endpoint", "http://127.0.0.1:4317", + "--gateway-name", + "production-us-west", ]); assert!( args.is_ok(), diff --git a/crates/openshell-driver-vm/src/otel_tracing.rs b/crates/openshell-driver-vm/src/otel_tracing.rs index f63066bb88..47e2dd5f73 100644 --- a/crates/openshell-driver-vm/src/otel_tracing.rs +++ b/crates/openshell-driver-vm/src/otel_tracing.rs @@ -11,14 +11,28 @@ use tracing_subscriber::registry::LookupSpan; const SERVICE_NAME: &str = "openshell-driver-vm"; const INSTRUMENTATION_SCOPE: &str = "openshell-driver-vm"; -/// Build a tracer provider for the configured OTLP/gRPC endpoint. +/// Build a tracer provider for the configured OTLP/gRPC endpoint and gateway. #[must_use] -pub fn provider_for(endpoint: Option<&str>) -> (Option, Option) { - openshell_otel::provider_for(endpoint.map(|endpoint| OtlpTraceConfig { - endpoint, - service_name: ServiceName::Fixed(SERVICE_NAME), - service_version: Some(openshell_core::VERSION), - resource_attributes: Vec::new(), +pub fn provider_for( + endpoint: Option<&str>, + gateway_name: Option<&str>, +) -> (Option, Option) { + openshell_otel::provider_for(endpoint.map(|endpoint| { + OtlpTraceConfig { + endpoint, + service_name: ServiceName::Fixed(SERVICE_NAME), + service_version: Some(openshell_core::VERSION), + resource_attributes: gateway_name + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(|name| { + vec![opentelemetry::KeyValue::new( + "openshell.gateway.name", + name.to_string(), + )] + }) + .unwrap_or_default(), + } })) } @@ -83,10 +97,11 @@ mod tests { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn vm_driver_spans_reach_otlp_collector_with_distinct_service_name() { + async fn vm_driver_spans_reach_otlp_collector_with_resource_identity() { let collector = OtlpTestServer::start().await; - let (provider, error) = super::provider_for(Some(collector.endpoint())); + let (provider, error) = + super::provider_for(Some(collector.endpoint()), Some("production-us-west")); assert!(error.is_none(), "valid OTLP endpoint should configure"); let provider = provider.expect("provider"); let subscriber = tracing_subscriber::registry().with(super::layer(&provider)); @@ -113,5 +128,6 @@ mod tests { "VM spans should use a distinct service name, got {:?}", received.service_names ); + assert_eq!(received.gateway_names, ["production-us-west"]); } } diff --git a/crates/openshell-otel-test-support/src/lib.rs b/crates/openshell-otel-test-support/src/lib.rs index 3f40d4b74a..4edbe8b7cc 100644 --- a/crates/openshell-otel-test-support/src/lib.rs +++ b/crates/openshell-otel-test-support/src/lib.rs @@ -16,6 +16,7 @@ use opentelemetry_proto::tonic::trace::v1::Span; pub struct ReceivedTraces { pub spans: Vec, pub service_names: Vec, + pub gateway_names: Vec, } #[derive(Clone)] @@ -37,18 +38,22 @@ impl TraceService for Collector { .unwrap_or_else(std::sync::PoisonError::into_inner); for resource_span in request.into_inner().resource_spans { if let Some(resource) = resource_span.resource { - received.service_names.extend( - resource - .attributes - .into_iter() - .filter(|attribute| attribute.key == "service.name") - .filter_map(|attribute| attribute.value) - .filter_map(|value| value.value) - .filter_map(|value| match value { - opentelemetry_proto::tonic::common::v1::any_value::Value::StringValue(value) => Some(value), - _ => None, - }), - ); + for attribute in resource.attributes { + let Some(value) = attribute.value.and_then(|value| value.value) else { + continue; + }; + let opentelemetry_proto::tonic::common::v1::any_value::Value::StringValue( + value, + ) = value + else { + continue; + }; + match attribute.key.as_str() { + "service.name" => received.service_names.push(value), + "openshell.gateway.name" => received.gateway_names.push(value), + _ => {} + } + } } for scope_span in resource_span.scope_spans { received.spans.extend(scope_span.spans); diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 1ca68895f0..f22e1355e4 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -7,7 +7,7 @@ use clap::parser::ValueSource; use clap::{ArgAction, ArgMatches, Command, CommandFactory, FromArgMatches, Parser}; use miette::{IntoDiagnostic, Result}; use openshell_core::ComputeDriverKind; -use openshell_core::config::DEFAULT_SERVER_PORT; +use openshell_core::config::{DEFAULT_GATEWAY_NAME, DEFAULT_SERVER_PORT}; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use tracing::{error, info, warn}; @@ -55,6 +55,14 @@ struct RunArgs { #[arg(long, env = "OPENSHELL_GATEWAY_CONFIG")] config: Option, + /// Operator-assigned name for this gateway installation. + #[arg( + long = "name", + default_value = DEFAULT_GATEWAY_NAME, + env = "OPENSHELL_GATEWAY_NAME" + )] + name: String, + /// IP address to bind the server, health, and metrics listeners to. #[arg(long, default_value = "127.0.0.1", env = "OPENSHELL_BIND_ADDRESS")] bind_address: IpAddr, @@ -336,7 +344,13 @@ fn prepare_server_config( .clone() .expect("runtime defaults populate db_url"); + let name = args.name.trim(); + if name.is_empty() { + return Err(miette::miette!("gateway name must not be empty")); + } + let mut config = openshell_core::Config::new(tls) + .with_name(name) .with_bind_address(bind) .with_log_level(&args.log_level); if let Some(auth) = file.as_ref().and_then(|f| f.openshell.gateway.auth.clone()) { @@ -495,12 +509,17 @@ async fn run_from_args( .config_file .as_ref() .and_then(|f| f.openshell.gateway.otlp.as_ref()); + let gateway_resource = crate::otel_tracing::GatewayResourceAttributes::new( + Some(prepared.config.name.as_str()), + Some(compute_driver.name()), + ); let (tracing_handle, setup_error) = crate::tracing_setup::install( EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new(&prepared.config.log_level)), &tracing_log_bus, otlp_config, &compute_driver, + gateway_resource, ); let has_client_ca = prepared @@ -651,6 +670,11 @@ fn resolve_aux_listener( /// The function intentionally does not touch `database_url` — that secret is /// env-only and the loader already rejected it when it appears in the file. fn merge_file_into_args(args: &mut RunArgs, file: &GatewayFileSection, matches: &ArgMatches) { + if let Some(name) = &file.name + && arg_defaulted(matches, "name") + { + args.name.clone_from(name); + } if let Some(addr) = file.bind_address { if arg_defaulted(matches, "bind_address") { args.bind_address = addr.ip(); @@ -1452,12 +1476,14 @@ enabled = false let _g1 = EnvVarGuard::remove("OPENSHELL_BIND_ADDRESS"); let _g2 = EnvVarGuard::remove("OPENSHELL_SERVER_PORT"); let _g3 = EnvVarGuard::remove("OPENSHELL_LOG_LEVEL"); + let _g4 = EnvVarGuard::remove("OPENSHELL_GATEWAY_NAME"); let (mut args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); let file = config_file_from_toml( r#" [openshell.gateway] +name = "production-us-west" bind_address = "0.0.0.0:9090" log_level = "debug" "#, @@ -1467,6 +1493,7 @@ log_level = "debug" assert_eq!(args.bind_address, IpAddr::V4(Ipv4Addr::UNSPECIFIED)); assert_eq!(args.port, 9090); assert_eq!(args.log_level, "debug"); + assert_eq!(args.name, "production-us-west"); } #[test] @@ -1476,6 +1503,7 @@ log_level = "debug" .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_BIND_ADDRESS"); let _g2 = EnvVarGuard::remove("OPENSHELL_LOG_LEVEL"); + let _g3 = EnvVarGuard::remove("OPENSHELL_GATEWAY_NAME"); let (mut args, matches) = parse_with_args(&[ "openshell-gateway", @@ -1483,16 +1511,20 @@ log_level = "debug" "sqlite::memory:", "--log-level", "warn", + "--name", + "cli-gateway", ]); let file = config_file_from_toml( r#" [openshell.gateway] +name = "file-gateway" log_level = "debug" "#, ); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert_eq!(args.log_level, "warn", "CLI flag must win over file"); + assert_eq!(args.name, "cli-gateway"); } #[test] @@ -1501,18 +1533,21 @@ log_level = "debug" .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g = EnvVarGuard::set("OPENSHELL_LOG_LEVEL", "trace"); + let _g2 = EnvVarGuard::set("OPENSHELL_GATEWAY_NAME", "env-gateway"); let (mut args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); let file = config_file_from_toml( r#" [openshell.gateway] +name = "file-gateway" log_level = "debug" "#, ); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert_eq!(args.log_level, "trace", "env var must win over file"); + assert_eq!(args.name, "env-gateway"); } #[test] diff --git a/crates/openshell-server/src/compute/vm.rs b/crates/openshell-server/src/compute/vm.rs index 80b445d201..6a66fc8aa5 100644 --- a/crates/openshell-server/src/compute/vm.rs +++ b/crates/openshell-server/src/compute/vm.rs @@ -478,7 +478,7 @@ pub async fn spawn( .arg("--expected-peer-pid") .arg(std::process::id().to_string()); command.arg("--log-level").arg(&config.log_level); - append_otlp_args(&mut command, otlp_config); + append_otlp_args(&mut command, otlp_config, &config.name); command .arg("--openshell-endpoint") .arg(&vm_config.grpc_endpoint); @@ -521,9 +521,10 @@ pub async fn spawn( } #[cfg(unix)] -fn append_otlp_args(command: &mut Command, otlp_config: Option<&OtlpConfig>) { +fn append_otlp_args(command: &mut Command, otlp_config: Option<&OtlpConfig>, gateway_name: &str) { if let Some(config) = otlp_config { command.arg("--otlp-endpoint").arg(&config.endpoint); + command.arg("--gateway-name").arg(gateway_name); } } @@ -621,7 +622,7 @@ mod tests { use tempfile::tempdir; #[test] - fn vm_driver_command_includes_gateway_otlp_endpoint() { + fn vm_driver_command_includes_gateway_otlp_configuration() { let mut command = tokio::process::Command::new("openshell-driver-vm"); append_otlp_args( &mut command, @@ -629,6 +630,7 @@ mod tests { endpoint: "http://collector.internal:4317".to_string(), service_name: Some("custom-gateway".to_string()), }), + "production-us-west", ); let args = command @@ -636,7 +638,15 @@ mod tests { .get_args() .map(|arg| arg.to_string_lossy().into_owned()) .collect::>(); - assert_eq!(args, ["--otlp-endpoint", "http://collector.internal:4317"]); + assert_eq!( + args, + [ + "--otlp-endpoint", + "http://collector.internal:4317", + "--gateway-name", + "production-us-west" + ] + ); } #[tokio::test] diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index f960a45d8d..74b6aad01b 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -87,6 +87,11 @@ pub struct OpenShellRoot { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct GatewayFileSection { + // ── Identity ───────────────────────────────────────────────────────── + /// Operator-assigned name for this gateway installation. + #[serde(default)] + pub name: Option, + // ── Listeners ──────────────────────────────────────────────────────── #[serde(default)] pub bind_address: Option, diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index cad48ebc3c..093be3752d 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -583,13 +583,8 @@ pub(crate) async fn run_server( let sandbox_index = SandboxIndex::new(); let sandbox_watch_bus = SandboxWatchBus::new(); let supervisor_sessions = Arc::new(supervisor_session::SupervisorSessionRegistry::new()); - let driver_startup = compute::driver_config::DriverStartupContext { - file: config_file.as_ref(), - guest_tls: guest_tls.as_ref(), - gateway_port: config.bind_address.port(), - gateway_tls_enabled: config.tls.is_some(), - endpoint_overrides: &config.compute_driver_endpoints, - }; + let driver_startup = + compute_driver_startup_context(&config, config_file.as_ref(), guest_tls.as_ref()); let (compute, operator_allowlist) = build_compute_runtime( &config, driver_startup, @@ -1668,6 +1663,20 @@ async fn build_compute_runtime( Ok((runtime, operator_allowlist)) } +fn compute_driver_startup_context<'a>( + config: &'a Config, + config_file: Option<&'a config_file::ConfigFile>, + guest_tls: Option<&'a compute::driver_config::GuestTlsPaths>, +) -> compute::driver_config::DriverStartupContext<'a> { + compute::driver_config::DriverStartupContext { + file: config_file, + guest_tls, + gateway_port: config.bind_address.port(), + gateway_tls_enabled: config.tls.is_some(), + endpoint_overrides: &config.compute_driver_endpoints, + } +} + #[derive(Debug, Clone)] pub(crate) enum ConfiguredComputeDriver { Registered(ComputeDriverRegistration), @@ -1675,7 +1684,7 @@ pub(crate) enum ConfiguredComputeDriver { } impl ConfiguredComputeDriver { - fn name(&self) -> &str { + pub(crate) fn name(&self) -> &str { match self { Self::Registered(registration) => ®istration.name, Self::Remote { name } => name, diff --git a/crates/openshell-server/src/otel_tracing.rs b/crates/openshell-server/src/otel_tracing.rs index 6a88b2f564..b4e8d9e506 100644 --- a/crates/openshell-server/src/otel_tracing.rs +++ b/crates/openshell-server/src/otel_tracing.rs @@ -37,7 +37,31 @@ const DEFAULT_SERVICE_NAME: &str = "openshell-gateway"; /// Instrumentation scope recorded on spans this gateway emits. const INSTRUMENTATION_SCOPE: &str = "openshell-gateway"; -fn trace_config(cfg: &OtlpConfig) -> OtlpTraceConfig<'_> { +/// Gateway identity recorded on every exported span. +#[derive(Debug, Clone, Copy, Default)] +pub struct GatewayResourceAttributes<'a> { + name: Option<&'a str>, + compute_driver: Option<&'a str>, +} + +impl<'a> GatewayResourceAttributes<'a> { + pub fn new(name: Option<&'a str>, compute_driver: Option<&'a str>) -> Self { + Self { + name, + compute_driver, + } + } + + /// The configured gateway installation name, if any. + pub fn name(&self) -> Option<&'a str> { + self.name + } +} + +fn trace_config<'cfg>( + cfg: &'cfg OtlpConfig, + gateway: GatewayResourceAttributes<'_>, +) -> OtlpTraceConfig<'cfg> { let service_name = cfg .service_name .as_deref() @@ -48,17 +72,35 @@ fn trace_config(cfg: &OtlpConfig) -> OtlpTraceConfig<'_> { ServiceName::Fixed, ); + let mut resource_attributes = Vec::new(); + if let Some(name) = gateway.name.map(str::trim).filter(|s| !s.is_empty()) { + resource_attributes.push(opentelemetry::KeyValue::new( + "openshell.gateway.name", + name.to_string(), + )); + } + if let Some(compute_driver) = gateway + .compute_driver + .map(str::trim) + .filter(|s| !s.is_empty()) + { + resource_attributes.push(opentelemetry::KeyValue::new( + "openshell.gateway.compute_driver", + compute_driver.to_string(), + )); + } + OtlpTraceConfig { endpoint: &cfg.endpoint, service_name, service_version: Some(openshell_core::VERSION), - resource_attributes: Vec::new(), + resource_attributes, } } #[cfg(test)] -fn build_resource(cfg: &OtlpConfig) -> Resource { - openshell_otel::resource_for(&trace_config(cfg)) +fn build_resource(cfg: &OtlpConfig, gateway: GatewayResourceAttributes<'_>) -> Resource { + openshell_otel::resource_for(&trace_config(cfg, gateway)) } /// Build a tracer provider exporting over OTLP/gRPC to the configured endpoint. @@ -70,8 +112,11 @@ fn build_resource(cfg: &OtlpConfig) -> Resource { /// The sampler and span limits are left at the SDK's defaults, which are /// themselves resolved from `OTEL_*` env vars (see the module docs). #[cfg(test)] -fn build_provider(cfg: &OtlpConfig) -> Result { - openshell_otel::build_provider(&trace_config(cfg)) +fn build_provider( + cfg: &OtlpConfig, + gateway: GatewayResourceAttributes<'_>, +) -> Result { + openshell_otel::build_provider(&trace_config(cfg, gateway)) } /// Resolve the tracer provider for a gateway config file's optional @@ -82,8 +127,11 @@ fn build_provider(cfg: &OtlpConfig) -> Result { /// /// The error is returned rather than logged because the provider is built /// before the subscriber it attaches to, so logging here would go nowhere. -pub fn provider_for(cfg: Option<&OtlpConfig>) -> (Option, Option) { - openshell_otel::provider_for(cfg.map(trace_config)) +pub fn provider_for( + cfg: Option<&OtlpConfig>, + gateway: GatewayResourceAttributes<'_>, +) -> (Option, Option) { + openshell_otel::provider_for(cfg.map(|cfg| trace_config(cfg, gateway))) } /// Build the gateway layer while routing one selected in-process driver to @@ -259,6 +307,10 @@ mod tests { } } + fn build_test_resource(cfg: &OtlpConfig) -> Resource { + build_resource(cfg, GatewayResourceAttributes::default()) + } + #[test] fn resource_defaults_the_service_name() { let _lock = crate::TEST_ENV_LOCK @@ -267,7 +319,7 @@ mod tests { let _env = EnvVarGuard::remove("OTEL_SERVICE_NAME"); assert_eq!( - service_name_of(&build_resource(&config())), + service_name_of(&build_test_resource(&config())), Some(DEFAULT_SERVICE_NAME.to_string()) ); } @@ -276,7 +328,7 @@ mod tests { fn resource_honors_configured_service_name_and_carries_version() { let mut cfg = config(); cfg.service_name = Some("gateway-staging".into()); - let resource = build_resource(&cfg); + let resource = build_test_resource(&cfg); assert_eq!( resource @@ -292,6 +344,31 @@ mod tests { ); } + #[test] + fn resource_carries_gateway_name_and_compute_driver() { + let resource = build_resource( + &config(), + GatewayResourceAttributes::new(Some("vm-dev"), Some("vm")), + ); + + assert_eq!( + resource + .get(&opentelemetry::Key::from_static_str( + "openshell.gateway.name", + )) + .map(|v| v.to_string()), + Some("vm-dev".to_string()) + ); + assert_eq!( + resource + .get(&opentelemetry::Key::from_static_str( + "openshell.gateway.compute_driver", + )) + .map(|v| v.to_string()), + Some("vm".to_string()) + ); + } + struct EnvVarGuard { key: &'static str, original: Option, @@ -346,7 +423,7 @@ mod tests { cfg.service_name = Some("from-config".into()); assert_eq!( - service_name_of(&build_resource(&cfg)), + service_name_of(&build_test_resource(&cfg)), Some("from-config".to_string()) ); } @@ -361,7 +438,7 @@ mod tests { let _env = EnvVarGuard::set("OTEL_SERVICE_NAME", "from-env"); assert_eq!( - service_name_of(&build_resource(&config())), + service_name_of(&build_test_resource(&config())), Some("from-env".to_string()) ); } @@ -376,7 +453,7 @@ mod tests { let mut cfg = config(); cfg.service_name = Some(" ".into()); assert_eq!( - service_name_of(&build_resource(&cfg)), + service_name_of(&build_test_resource(&cfg)), Some(DEFAULT_SERVICE_NAME.to_string()) ); } @@ -385,7 +462,8 @@ mod tests { fn provider_rejects_a_malformed_endpoint() { let mut cfg = config(); cfg.endpoint = "definitely not a url".into(); - let err = build_provider(&cfg).expect_err("malformed endpoint"); + let err = build_provider(&cfg, GatewayResourceAttributes::default()) + .expect_err("malformed endpoint"); assert!( err.to_string().contains("definitely not a url"), "error names the offending endpoint: {err}" @@ -396,7 +474,10 @@ mod tests { fn provider_rejects_an_empty_endpoint() { let mut cfg = config(); cfg.endpoint = " ".into(); - assert!(build_provider(&cfg).is_err(), "empty endpoint is rejected"); + assert!( + build_provider(&cfg, GatewayResourceAttributes::default()).is_err(), + "empty endpoint is rejected" + ); } #[tokio::test] @@ -404,7 +485,8 @@ mod tests { // The OTLP batch exporter connects lazily, so a valid endpoint must // build even when nothing is listening — the gateway must not fail to // start because its collector is down. - let provider = build_provider(&config()).expect("provider builds"); + let provider = build_provider(&config(), GatewayResourceAttributes::default()) + .expect("provider builds"); provider.shutdown().ok(); } @@ -413,7 +495,7 @@ mod tests { /// error for the caller to log — see the misconfigured-endpoint test. #[tokio::test] async fn absent_otlp_table_disables_export() { - let (provider, err) = provider_for(None); + let (provider, err) = provider_for(None, GatewayResourceAttributes::default()); assert!(provider.is_none(), "export is off"); assert!( err.is_none(), @@ -423,7 +505,7 @@ mod tests { #[tokio::test] async fn present_otlp_table_enables_export() { - let (provider, err) = provider_for(Some(&config())); + let (provider, err) = provider_for(Some(&config()), GatewayResourceAttributes::default()); assert!(err.is_none()); provider.expect("provider is present").shutdown().ok(); } @@ -436,7 +518,7 @@ mod tests { let mut cfg = config(); cfg.endpoint = "definitely not a url".into(); - let (provider, err) = provider_for(Some(&cfg)); + let (provider, err) = provider_for(Some(&cfg), GatewayResourceAttributes::default()); assert!( provider.is_none(), "a bad endpoint degrades to no export rather than failing startup" diff --git a/crates/openshell-server/src/tracing_setup.rs b/crates/openshell-server/src/tracing_setup.rs index 968a1c6a05..edcf303072 100644 --- a/crates/openshell-server/src/tracing_setup.rs +++ b/crates/openshell-server/src/tracing_setup.rs @@ -13,7 +13,7 @@ use tracing_subscriber::prelude::*; use crate::ConfiguredComputeDriver; use crate::config_file::OtlpConfig; -use crate::otel_tracing::SetupError; +use crate::otel_tracing::{GatewayResourceAttributes, SetupError}; use crate::tracing_bus::TracingLogBus; pub struct TracingHandle { @@ -94,16 +94,17 @@ fn in_process_driver_target_prefix(driver: Option) -> Op fn in_process_driver_provider( driver: Option, endpoint: Option<&str>, + gateway_name: Option<&str>, ) -> (Option, Option) { match driver { Some(InProcessDriverTracing::Docker) => { - openshell_driver_docker::otel_tracing::provider_for(endpoint) + openshell_driver_docker::otel_tracing::provider_for(endpoint, gateway_name) } Some(InProcessDriverTracing::Kubernetes) => { - openshell_driver_kubernetes::otel_tracing::provider_for(endpoint) + openshell_driver_kubernetes::otel_tracing::provider_for(endpoint, gateway_name) } Some(InProcessDriverTracing::Podman) => { - openshell_driver_podman::otel_tracing::provider_for(endpoint) + openshell_driver_podman::otel_tracing::provider_for(endpoint, gateway_name) } None => (None, None), } @@ -113,6 +114,7 @@ fn in_process_driver_provider( fn in_process_driver_provider( _driver: Option, _endpoint: Option<&str>, + _gateway_name: Option<&str>, ) -> (Option, Option) { (None, None) } @@ -155,8 +157,9 @@ pub fn install( tracing_log_bus: &TracingLogBus, otlp_config: Option<&OtlpConfig>, driver: &ConfiguredComputeDriver, + gateway: GatewayResourceAttributes<'_>, ) -> (TracingHandle, Option) { - let (tracer_provider, setup_error) = crate::otel_tracing::provider_for(otlp_config); + let (tracer_provider, setup_error) = crate::otel_tracing::provider_for(otlp_config, gateway); let selected_driver = in_process_driver_tracing(driver); let driver_endpoint = selected_driver .is_some() @@ -164,7 +167,7 @@ pub fn install( .flatten() .map(|config| config.endpoint.as_str()); let (driver_tracer_provider, driver_setup_error) = - in_process_driver_provider(selected_driver, driver_endpoint); + in_process_driver_provider(selected_driver, driver_endpoint, gateway.name()); tracing_subscriber::registry() .with(env_filter) diff --git a/deploy/helm/openshell/README.md b/deploy/helm/openshell/README.md index 25550f3cab..1786a46f63 100644 --- a/deploy/helm/openshell/README.md +++ b/deploy/helm/openshell/README.md @@ -253,6 +253,7 @@ discovery endpoint or its TLS CA. | server.grpcRateLimit.windowSeconds | int | `0` | gRPC rate-limit window length in seconds. Must be positive (alongside requests) to enable rate limiting; 0 (default) disables it. | | server.hostGatewayIP | string | `""` | Host gateway IP for sandbox pod hostAliases. When set, sandbox pods get hostAliases entries mapping host.docker.internal and host.openshell.internal to this IP, allowing them to reach services running on the Docker host. Auto-detected by the cluster entrypoint script. | | server.logLevel | string | `"info"` | Gateway log level. | +| server.name | string | `""` | Operator-facing gateway name. Defaults to the chart fullname so all replicas in one installation share an identity. Set explicitly when one telemetry collector receives spans from multiple namespaces or clusters. | | server.oidc.adminRole | string | `""` | Role name for admin access. Leave empty (with userRole also empty) for authentication-only mode. Both must be set or both empty. | | server.oidc.audience | string | `"openshell-cli"` | Expected audience claim for the API resource server. This should match the server's --oidc-audience, NOT the CLI client ID. | | server.oidc.caConfigMapName | string | `""` | Name of a ConfigMap containing a CA certificate bundle (key: ca.crt) for verifying the OIDC issuer's TLS certificate. Required when the issuer uses a non-public CA (e.g. OpenShift ingress, private PKI). | diff --git a/deploy/helm/openshell/templates/gateway-config.yaml b/deploy/helm/openshell/templates/gateway-config.yaml index 60420cc535..d7f3cb9a83 100644 --- a/deploy/helm/openshell/templates/gateway-config.yaml +++ b/deploy/helm/openshell/templates/gateway-config.yaml @@ -35,6 +35,7 @@ data: version = 1 [openshell.gateway] + name = {{ .Values.server.name | default (include "openshell.fullname" .) | quote }} bind_address = "0.0.0.0:{{ .Values.service.port }}" {{- if .Values.service.healthPort }} health_bind_address = "0.0.0.0:{{ .Values.service.healthPort }}" diff --git a/deploy/helm/openshell/tests/gateway_config_test.yaml b/deploy/helm/openshell/tests/gateway_config_test.yaml index 10e14d6554..b54b036c0f 100644 --- a/deploy/helm/openshell/tests/gateway_config_test.yaml +++ b/deploy/helm/openshell/tests/gateway_config_test.yaml @@ -11,6 +11,22 @@ release: namespace: my-namespace tests: + - it: identifies the gateway by chart fullname by default + template: templates/gateway-config.yaml + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?m)^name\s*=\s*"openshell"$' + + - it: renders an explicit gateway name + template: templates/gateway-config.yaml + set: + server.name: production-us-west + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?m)^name\s*=\s*"production-us-west"$' + # Regression for Drew's P2: a ConfigMap-only mutation in `helm upgrade` # must roll the StatefulSet, otherwise pods keep running with stale config. - it: annotates the StatefulSet pod template with a ConfigMap checksum diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index 6c0889773c..6b0b6242b0 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -189,6 +189,10 @@ affinity: {} # Server configuration server: + # -- Operator-facing gateway name. Defaults to the chart fullname so all + # replicas in one installation share an identity. Set explicitly when one + # telemetry collector receives spans from multiple namespaces or clusters. + name: "" # -- Gateway log level. logLevel: info # OpenTelemetry trace export over OTLP/gRPC. Leave endpoint empty to disable. diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index a066b14f01..32d93aa2e6 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -18,6 +18,8 @@ Gateway CLI flag > gateway OPENSHELL_* env var > TOML file > built-in defa `database_url` is env-only. The loader rejects it when it appears in the file. When `OPENSHELL_DB_URL` is unset, the gateway stores its SQLite database under `$XDG_STATE_HOME/openshell/gateway/openshell.db`. +`name` assigns an operator-facing identity to the gateway installation. Set it with `[openshell.gateway].name`, `--name`, or `OPENSHELL_GATEWAY_NAME`. It defaults to `openshell`; the Helm chart defaults it to the chart fullname so all replicas in one installation share a name. Chart fullnames are only unique within their Kubernetes namespace, so set `server.name` explicitly when one collector receives telemetry from multiple namespaces or clusters. This identity is independent of client-side gateway aliases, TLS names, and `gateway_jwt.gateway_id`. + ## Package-Managed Locations Package-managed gateways do not require a TOML file. Create one at the package's optional config location when you need to override built-in defaults. Set `OPENSHELL_GATEWAY_CONFIG` in the launch environment to use a different file. @@ -69,6 +71,7 @@ A complete gateway configuration covering every section. Trim to the fields you version = 1 [openshell.gateway] +name = "production-us-west" bind_address = "0.0.0.0:8080" health_bind_address = "0.0.0.0:8081" metrics_bind_address = "0.0.0.0:9090" @@ -228,11 +231,11 @@ The transport is **OTLP over gRPC only**. HTTP/protobuf and HTTP/JSON are not su The OpenTelemetry SDK logs export failures after startup. Spans in a failed batch are dropped rather than retried. -`service_name` sets the gateway's `service.name` resource attribute and defaults to `openshell-gateway`. The gateway also reports `service.version`. +`service_name` sets the gateway's `service.name` resource attribute and defaults to `openshell-gateway`. The gateway also reports `service.version`, `openshell.gateway.name` from the gateway's configured `name`, and `openshell.gateway.compute_driver`. Only OpenTelemetry traces are exported. Inbound gRPC and HTTP requests produce server spans named for the RPC or HTTP method. Store and compute-driver operations appear as child spans. Internal reconciliation, credential-refresh, and driver-watch loops create operation roots for their store work because no inbound request supplies a parent. The gateway continues valid W3C `traceparent` context and starts a new trace when none is supplied. Request spans carry `method`, `path`, and the `request_id` that also appears in gateway logs. Health endpoint spans use DEBUG level and are not exported by the default INFO filter. -The gateway forwards the OTLP configuration and W3C trace context to managed external drivers. Built-in Docker and Podman drivers also export their spans to the same collector through dedicated in-process providers. Driver spans retain the gateway trace context and use a distinct service name such as `openshell-driver-docker` or `openshell-driver-podman`. +The gateway forwards the OTLP configuration, configured gateway name, and W3C trace context to managed external drivers. Built-in drivers also export their spans to the same collector through dedicated in-process providers. Driver spans retain the gateway trace context, use a distinct service name such as `openshell-driver-docker` or `openshell-driver-podman`, and carry the gateway name as the `openshell.gateway.name` resource attribute. Operator-run external drivers own their own telemetry configuration. For Helm deployments, set `server.otlp.endpoint` to render this table. The optional `server.otlp.serviceName` value overrides the gateway service name; @@ -242,6 +245,11 @@ The local `mise run helm:k3s:create` workflow installs a trace collector and UI, enables Agent Sandbox controller tracing on supported releases, and configures both the controller and the existing Skaffold deployment to export traces to it automatically. +The local `gateway`, `gateway:docker`, `gateway:podman`, and `gateway:vm` tasks +also set the gateway installation name in their generated configuration. Their +defaults are driver-specific (`kubernetes-dev`, `docker-dev`, `podman-dev`, and +`vm-dev`), and their documented `OPENSHELL_*_GATEWAY_NAME` overrides update both +CLI registration and exported telemetry identity. Run `mise run helm:k3s:forward` to forward OTLP/gRPC to local port `4317` and the trace UI to local port `18888`. When Skaffold has deployed a Kubernetes gateway, the task also forwards it to local port `8090`; otherwise it continues diff --git a/tasks/scripts/gateway-docker.sh b/tasks/scripts/gateway-docker.sh index 6e7b85634a..a1f9e93e6a 100644 --- a/tasks/scripts/gateway-docker.sh +++ b/tasks/scripts/gateway-docker.sh @@ -199,6 +199,7 @@ cat >"${CONFIG_PATH}" <"${CONFIG_PATH}" <- so the AF_UNIX socket @@ -324,6 +324,7 @@ cat >"${CONFIG_PATH}" <"${CONFIG_PATH}" < Date: Thu, 27 Aug 2026 17:55:01 +0000 Subject: [PATCH 040/505] fix(dev): harden local cluster and gateway startup (#2993) * fix(helm): refresh kubeconfig for existing k3d clusters Docker can recreate the k3d load balancer on a new API port. Start existing clusters and prefer fresh k3d entries so create does not retain a stale endpoint. Signed-off-by: Kris Hicks * fix(dev): conditionally enable local OTLP export Probe port 4317 before adding OTLP configuration for the VM, Docker, and Podman gateway tasks. Document the startup behavior and troubleshooting for local collector availability. Signed-off-by: Kris Hicks --------- Signed-off-by: Kris Hicks --- .../skills/debug-openshell-cluster/SKILL.md | 1 + .agents/skills/helm-dev-environment/SKILL.md | 7 +++++-- crates/openshell-driver-docker/README.md | 4 ++++ crates/openshell-driver-podman/README.md | 4 ++++ crates/openshell-driver-vm/README.md | 1 + tasks/scripts/gateway-docker.sh | 20 ++++++++++++++++--- tasks/scripts/gateway-podman.sh | 20 ++++++++++++++++--- tasks/scripts/gateway-vm.sh | 20 ++++++++++++++++--- tasks/scripts/helm-k3s-local.sh | 10 +++++++--- 9 files changed, 73 insertions(+), 14 deletions(-) diff --git a/.agents/skills/debug-openshell-cluster/SKILL.md b/.agents/skills/debug-openshell-cluster/SKILL.md index 80d5057b17..90b9a38409 100644 --- a/.agents/skills/debug-openshell-cluster/SKILL.md +++ b/.agents/skills/debug-openshell-cluster/SKILL.md @@ -600,6 +600,7 @@ openshell logs | Symptom | Likely cause | Check | |---|---|---| | `openshell status` fails | Gateway endpoint unreachable or auth mismatch | `openshell gateway info`, gateway logs | +| `BatchSpanProcessor.ExportError` repeatedly reports connection refused on `127.0.0.1:4317` | The local gateway started with OTLP configured but the collector forwarding task later stopped, or the config was created manually | Restart `gateway:docker`, `gateway:podman`, or `gateway:vm` so it re-detects the listener; inspect the generated `gateway.toml` for `[openshell.gateway.otlp]` | | Gateway starts but sandbox create fails | Compute driver cannot reach runtime | Docker/Podman/Kubernetes/VM driver logs | | Gateway exits while resolving compute-driver listener requirements | Callback alias topology is unsupported, the Podman network cannot be inspected, or the selected address is not private/authorized | Gateway startup error, `podman info --debug`, Podman network inspection, host IPv4 default route | | Admin, health, reflection, or HTTP request is denied on an additional Docker/Podman callback-only listener | Additional callback listeners intentionally expose only sandbox-callable gRPC methods | Retry through the gateway's primary endpoint; inspect the listener-purpose startup log if the address was unexpected | diff --git a/.agents/skills/helm-dev-environment/SKILL.md b/.agents/skills/helm-dev-environment/SKILL.md index ebd9a01595..c60733e954 100644 --- a/.agents/skills/helm-dev-environment/SKILL.md +++ b/.agents/skills/helm-dev-environment/SKILL.md @@ -26,6 +26,8 @@ mise run helm:k3s:create ``` Creates a k3d cluster and merges its kubeconfig into the worktree-local `kubeconfig` file. +When the named cluster already exists, the task starts any stopped containers and refreshes +same-named kubeconfig entries so a recreated load balancer's current API port takes effect. Also applies the upstream agent-sandbox CRDs/controller (pinned via `AGENT_SANDBOX_VERSION` in `tasks/scripts/helm-k3s-local.sh`, fetched from `github.com/kubernetes-sigs/agent-sandbox` releases), enables its OTLP tracing on v0.5 and later, installs an OTLP trace @@ -118,8 +120,9 @@ Kubernetes compute-driver spans under their distinct service names, along with Agent Sandbox controller reconciliation spans linked through the Sandbox trace-context annotation. The same command exposes OTLP/gRPC on `http://127.0.0.1:4317` and, when deployed, the Kubernetes gateway on -`http://127.0.0.1:8090`; the local `gateway`, `gateway:docker`, and `gateway:vm` -tasks export to the collector endpoint automatically. +`http://127.0.0.1:8090`. The local `gateway:docker`, `gateway:podman`, and +`gateway:vm` tasks detect the collector listener at startup and enable trace +export only while it is reachable. **HA test deploy** (two gateway replicas + external PostgreSQL Secret): uncomment `#- ci/values-high-availability.yaml` in `deploy/helm/openshell/skaffold.yaml`, diff --git a/crates/openshell-driver-docker/README.md b/crates/openshell-driver-docker/README.md index 9b9657e94d..bbd7e69b88 100644 --- a/crates/openshell-driver-docker/README.md +++ b/crates/openshell-driver-docker/README.md @@ -8,6 +8,10 @@ spans export to the same OTLP/gRPC collector with the service name context and emits the compute-driver RPC boundary that a standalone driver would expose. +`mise run gateway:docker` enables this export only when a local collector is +listening on `127.0.0.1:4317`. Otherwise, it omits the gateway OTLP configuration +so the development gateway does not repeatedly report export failures. + The standalone `openshell-driver-docker` binary accepts `OPENSHELL_OTLP_ENDPOINT`. When set, it exports Docker driver spans to that collector, continues W3C trace context from gateway RPC metadata, and flushes diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index b0436634ee..d55d20ec93 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -13,6 +13,10 @@ spans export to the same OTLP/gRPC collector with the service name uses the same compute-driver RPC span names in its in-process and standalone forms. +`mise run gateway:podman` enables this export only when a local collector is +listening on `127.0.0.1:4317`. Otherwise, it omits the gateway OTLP configuration +so the development gateway does not repeatedly report export failures. + Before creating the container, the driver inspects the final sandbox image and captures its immutable image ID and raw OCI `Config.User`. Container creation uses that image ID with pulling disabled, preventing a mutable tag from changing diff --git a/crates/openshell-driver-vm/README.md b/crates/openshell-driver-vm/README.md index 67666be735..f455beaeb5 100644 --- a/crates/openshell-driver-vm/README.md +++ b/crates/openshell-driver-vm/README.md @@ -47,6 +47,7 @@ By default `mise run gateway:vm`: - Persists the gateway SQLite DB under `.cache/gateway-vm/gateway.db`. - Places the VM driver state (per-sandbox `overlay.ext4`, image cache, and `run/compute-driver.sock`) under `/tmp/openshell-vm-driver-$USER-vm-dev/` so the AF_UNIX socket path stays under macOS `SUN_LEN`. - Writes `.cache/gateway-vm/gateway.toml` with `[openshell.drivers.vm].driver_dir = "$PWD/target/debug"` so the freshly built `openshell-driver-vm` is used instead of an older installed copy from `~/.local/libexec/openshell`, `/usr/libexec/openshell`, or `/usr/local/libexec`. +- Enables OTLP trace export to `http://127.0.0.1:4317` only when a local collector is listening there. Otherwise, it omits the OTLP configuration to avoid repeated export failures. For GPU passthrough (VFIO), pass `-- --gpu` and run with root privileges: diff --git a/tasks/scripts/gateway-docker.sh b/tasks/scripts/gateway-docker.sh index a1f9e93e6a..b38bfb7942 100644 --- a/tasks/scripts/gateway-docker.sh +++ b/tasks/scripts/gateway-docker.sh @@ -65,6 +65,21 @@ port_is_in_use() { (echo >/dev/tcp/127.0.0.1/"${port}") >/dev/null 2>&1 } +append_local_otlp_config_if_available() { + local config_path=$1 + if ! port_is_in_use 4317; then + echo "OTLP collector not detected on 127.0.0.1:4317; trace export disabled." + return + fi + + cat >>"${config_path}" <<'EOF' + +[openshell.gateway.otlp] +endpoint = "http://127.0.0.1:4317" +EOF + echo "OTLP trace export enabled for http://127.0.0.1:4317." +} + register_gateway_metadata() { local name=$1 local endpoint=$2 @@ -203,9 +218,6 @@ name = "${GATEWAY_NAME}" compute_drivers = ["docker"] disable_tls = true -[openshell.gateway.otlp] -endpoint = "http://127.0.0.1:4317" - [openshell.gateway.auth] allow_unauthenticated_users = true @@ -224,6 +236,8 @@ grpc_endpoint = "${GRPC_ENDPOINT}" supervisor_bin = "${SUPERVISOR_BIN}" EOF +append_local_otlp_config_if_available "${CONFIG_PATH}" + GATEWAY_ENDPOINT="http://127.0.0.1:${PORT}" register_gateway_metadata "${GATEWAY_NAME}" "${GATEWAY_ENDPOINT}" "${PORT}" diff --git a/tasks/scripts/gateway-podman.sh b/tasks/scripts/gateway-podman.sh index b27a48df89..ab166865ef 100644 --- a/tasks/scripts/gateway-podman.sh +++ b/tasks/scripts/gateway-podman.sh @@ -129,6 +129,21 @@ port_is_in_use() { (echo >/dev/tcp/127.0.0.1/"${port}") >/dev/null 2>&1 } +append_local_otlp_config_if_available() { + local config_path=$1 + if ! port_is_in_use 4317; then + echo "OTLP collector not detected on 127.0.0.1:4317; trace export disabled." + return + fi + + cat >>"${config_path}" <<'EOF' + +[openshell.gateway.otlp] +endpoint = "http://127.0.0.1:4317" +EOF + echo "OTLP trace export enabled for http://127.0.0.1:4317." +} + register_gateway_metadata() { local name=$1 local endpoint=$2 @@ -208,9 +223,6 @@ compute_drivers = ["podman"] default_image = "${SANDBOX_IMAGE}" disable_tls = true -[openshell.gateway.otlp] -endpoint = "http://127.0.0.1:4317" - [openshell.gateway.auth] allow_unauthenticated_users = true @@ -267,6 +279,8 @@ if [[ -n "${OPENSHELL_SANDBOX_PROXY_CA_BUNDLE+x}" ]]; then printf 'proxy_ca_bundle = "%s"\n' "$(toml_escape "${OPENSHELL_SANDBOX_PROXY_CA_BUNDLE}")" >>"${CONFIG_PATH}" fi +append_local_otlp_config_if_available "${CONFIG_PATH}" + GATEWAY_ENDPOINT="http://${CLI_ENDPOINT_HOST}:${PORT}" register_gateway_metadata "${GATEWAY_NAME}" "${GATEWAY_ENDPOINT}" "${PORT}" diff --git a/tasks/scripts/gateway-vm.sh b/tasks/scripts/gateway-vm.sh index e847b2b8ec..2e8aecc5d0 100755 --- a/tasks/scripts/gateway-vm.sh +++ b/tasks/scripts/gateway-vm.sh @@ -83,6 +83,21 @@ port_is_in_use() { (echo >/dev/tcp/127.0.0.1/"${port}") >/dev/null 2>&1 } +append_local_otlp_config_if_available() { + local config_path=$1 + if ! port_is_in_use 4317; then + echo "OTLP collector not detected on 127.0.0.1:4317; trace export disabled." + return + fi + + cat >>"${config_path}" <<'EOF' + +[openshell.gateway.otlp] +endpoint = "http://127.0.0.1:4317" +EOF + echo "OTLP trace export enabled for http://127.0.0.1:4317." +} + invoking_user() { if [ -n "${SUDO_USER:-}" ] && [ "${SUDO_USER}" != "root" ]; then printf '%s\n' "${SUDO_USER}" @@ -328,9 +343,6 @@ name = "${GATEWAY_NAME}" compute_drivers = ["vm"] disable_tls = ${DISABLE_TLS} -[openshell.gateway.otlp] -endpoint = "http://127.0.0.1:4317" - [openshell.gateway.auth] allow_unauthenticated_users = true @@ -349,6 +361,8 @@ driver_dir = "${DRIVER_DIR}" state_dir = "${VM_DRIVER_STATE_DIR}" EOF +append_local_otlp_config_if_available "${CONFIG_PATH}" + GATEWAY_ENDPOINT="http://127.0.0.1:${PORT}" register_gateway_metadata "${GATEWAY_NAME}" "${GATEWAY_ENDPOINT}" "${PORT}" "${VM_DRIVER_STATE_DIR}" save_active_gateway "${GATEWAY_NAME}" diff --git a/tasks/scripts/helm-k3s-local.sh b/tasks/scripts/helm-k3s-local.sh index 39fed2f4b4..dc8adb9bdf 100755 --- a/tasks/scripts/helm-k3s-local.sh +++ b/tasks/scripts/helm-k3s-local.sh @@ -142,12 +142,15 @@ k3d_cluster_exists() { merge_kubeconfig() { require_kubectl - local tmp k3d_cfg merged_dir + local tmp merged_dir tmp="$(mktemp)" k3d kubeconfig get "${CLUSTER_NAME}" >"${tmp}" if [[ -s "${KUBECONFIG_TARGET}" ]]; then - KUBECONFIG="${KUBECONFIG_TARGET}:${tmp}" kubectl config view --flatten >"${tmp}.out" + # Put the freshly generated k3d config first so its cluster, context, and + # user entries replace stale entries with the same names. The API server's + # random host port can change when Docker recreates the load balancer. + KUBECONFIG="${tmp}:${KUBECONFIG_TARGET}" kubectl config view --flatten >"${tmp}.out" mv "${tmp}.out" "${KUBECONFIG_TARGET}" else merged_dir="$(dirname "${KUBECONFIG_TARGET}")" @@ -394,7 +397,8 @@ EOF local lb_port_map="${HOST_LB_PORT}:80@loadbalancer" if k3d_cluster_exists; then - echo "k3d cluster '${CLUSTER_NAME}' already exists; merging kubeconfig." + echo "k3d cluster '${CLUSTER_NAME}' already exists; ensuring it is running." + k3d cluster start "${CLUSTER_NAME}" else echo "Creating k3d cluster '${CLUSTER_NAME}'..." k3d cluster create "${CLUSTER_NAME}" \ From 1e9ee47127bb8f45034b6c846e761e1d3612d25b Mon Sep 17 00:00:00 2001 From: krishicks Date: Thu, 27 Aug 2026 20:05:56 +0000 Subject: [PATCH 041/505] fix(server): suppress expected session errors on shutdown (#2994) Ctrl-C closes gateway listeners while supervisor sessions are still current, causing benign HTTP/2 broken pipes to be logged as warnings. Track gateway shutdown so recognized transport closes are treated as expected. Signed-off-by: Kris Hicks --- crates/openshell-server/src/lib.rs | 11 +++++++- .../src/supervisor_session.rs | 27 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 093be3752d..3dc2acec06 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -62,7 +62,10 @@ use std::net::SocketAddr; use std::path::Path; #[cfg(test)] use std::sync::LazyLock; -use std::sync::{Arc, Mutex}; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::watch; @@ -295,6 +298,10 @@ pub struct ServerState { /// query session state to surface supervisor readiness. pub supervisor_sessions: Arc, + /// Set once graceful gateway shutdown begins so stream handlers can + /// distinguish expected transport closes from runtime failures. + pub(crate) gateway_shutting_down: AtomicBool, + /// Validated built-in and operator-registered supervisor middleware. pub middleware_registry: Arc, @@ -411,6 +418,7 @@ impl ServerState { ssh_connections_by_sandbox: Mutex::new(HashMap::new()), settings_mutex: tokio::sync::Mutex::new(()), supervisor_sessions, + gateway_shutting_down: AtomicBool::new(false), extension_mint_limiter: auth::extension_mint_limit::ExtensionMintLimiter::default(), middleware_registry: Arc::new(MiddlewareRegistry::default()), oidc_cache, @@ -802,6 +810,7 @@ pub(crate) async fn run_server( shutdown_signal().await; info!("Shutdown signal received; stopping gateway"); + state.gateway_shutting_down.store(true, Ordering::Release); let _ = shutdown_tx.send(true); for task in listener_tasks { diff --git a/crates/openshell-server/src/supervisor_session.rs b/crates/openshell-server/src/supervisor_session.rs index fbff0e276c..ed8dbdb958 100644 --- a/crates/openshell-server/src/supervisor_session.rs +++ b/crates/openshell-server/src/supervisor_session.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use std::pin::Pin; +use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -666,9 +667,23 @@ async fn expected_transport_close_during_session_teardown( let session_no_longer_current = !state .supervisor_sessions .is_current_session(sandbox_id, session_id); + expected_transport_close_during_session_state( + status, + state.gateway_shutting_down.load(Ordering::Acquire), + session_no_longer_current, + sandbox_is_terminating_or_gone(state, sandbox_id).await, + ) +} + +fn expected_transport_close_during_session_state( + status: &Status, + gateway_shutting_down: bool, + session_no_longer_current: bool, + sandbox_terminating_or_gone: bool, +) -> bool { expected_transport_close_during_shutdown( status, - session_no_longer_current || sandbox_is_terminating_or_gone(state, sandbox_id).await, + gateway_shutting_down || session_no_longer_current || sandbox_terminating_or_gone, ) } @@ -1447,6 +1462,16 @@ mod tests { assert!(!expected_transport_close_during_shutdown(&status, true)); } + #[test] + fn gateway_shutdown_makes_session_transport_close_nonfatal() { + let status = + Status::unknown("h2 protocol error: error reading a body from connection: broken pipe"); + + assert!(expected_transport_close_during_session_state( + &status, true, false, false, + )); + } + #[test] fn sandbox_proto_terminating_detects_deleting_phase() { let mut sandbox = sandbox_record("sbx-1", "sandbox-one"); From f795a15992069345635663b75d0a7fe058a64d8f Mon Sep 17 00:00:00 2001 From: krishicks Date: Thu, 27 Aug 2026 21:01:07 +0000 Subject: [PATCH 042/505] test(network): avoid DNS socket bind race (#2996) This changes the DNS resolver test fixture to retry until TCP and UDP can share an ephemeral port. It fixes an issue seen in CI where a port that was successfully assigned for one protocol failed for the other. Signed-off-by: Kris Hicks --- .../src/policy_dns/resolver.rs | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/openshell-supervisor-network/src/policy_dns/resolver.rs b/crates/openshell-supervisor-network/src/policy_dns/resolver.rs index 3209efd14c..1044538ad1 100644 --- a/crates/openshell-supervisor-network/src/policy_dns/resolver.rs +++ b/crates/openshell-supervisor-network/src/policy_dns/resolver.rs @@ -315,6 +315,23 @@ mod tests { use openshell_core::net::set_tcp_nodelay_best_effort; use tokio::net::TcpListener; + async fn bind_dns_test_server() -> (UdpSocket, TcpListener, SocketAddr) { + const MAX_BIND_ATTEMPTS: usize = 100; + + for _ in 0..MAX_BIND_ATTEMPTS { + // Port zero only guarantees availability for the protocol being bound. + let tcp = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let server = tcp.local_addr().unwrap(); + match UdpSocket::bind(server).await { + Ok(udp) => return (udp, tcp, server), + Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => {} + Err(error) => panic!("failed to bind DNS test UDP socket: {error}"), + } + } + + panic!("failed to bind DNS test TCP and UDP sockets to the same port"); + } + #[test] fn resolver_address_deduplication_preserves_answer_order() { let mut addresses = vec![ @@ -383,9 +400,7 @@ mod tests { #[tokio::test] async fn truncated_udp_retries_over_tcp_and_follows_cname() { - let udp = UdpSocket::bind("127.0.0.1:0").await.unwrap(); - let server = udp.local_addr().unwrap(); - let tcp = TcpListener::bind(server).await.unwrap(); + let (udp, tcp, server) = bind_dns_test_server().await; let udp_task = tokio::spawn(async move { let mut wire = [0_u8; MAX_DNS_MESSAGE_BYTES]; From 7eed8da2a532d8772ac4514fc3871a7a4ede7a3d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:33:04 +0000 Subject: [PATCH 043/505] chore(deps): bump jdx/mise-action from 4.2.4 to 4.3.0 (#2970) Bumps [jdx/mise-action](https://github.com/jdx/mise-action) from 4.2.4 to 4.3.0. - [Release notes](https://github.com/jdx/mise-action/releases) - [Changelog](https://github.com/jdx/mise-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/jdx/mise-action/compare/7e36c90d9ab29c415a2384db3006f3ec8a8cc654...c2a87611a18de5b3828c5652fe268e992400cb5c) --- updated-dependencies: - dependency-name: jdx/mise-action dependency-version: 4.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/windows-msvc.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/windows-msvc.yml b/.github/workflows/windows-msvc.yml index 3dda62238a..3ad1cc68f0 100644 --- a/.github/workflows/windows-msvc.yml +++ b/.github/workflows/windows-msvc.yml @@ -6,7 +6,7 @@ jobs: runs-on: windows-2025 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4.2.4 + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 with: install: false experimental: true @@ -31,7 +31,7 @@ jobs: if: false # flip to true once the runner is online steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4.2.4 + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 with: install: false experimental: true From d1155aa70042d3e2ee49dbfa15346b108b7c1d92 Mon Sep 17 00:00:00 2001 From: Simon Scatton <44714756+SDAChess@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:27:17 +0000 Subject: [PATCH 044/505] fix(ci): normalize macOS binary dependencies (#3006) Closes #2997 Signed-off-by: Simon Scatton --- .github/actions/build-rust-binary/action.yml | 30 ++++++++++++++++++++ architecture/build.md | 8 ++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/.github/actions/build-rust-binary/action.yml b/.github/actions/build-rust-binary/action.yml index 98ba51b88e..408219b57c 100644 --- a/.github/actions/build-rust-binary/action.yml +++ b/.github/actions/build-rust-binary/action.yml @@ -71,6 +71,36 @@ runs: 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" + - name: Verify ${{ inputs.binary }} shell: nix develop ${{ inputs.dev-shell }} -c bash -euo pipefail {0} env: diff --git a/architecture/build.md b/architecture/build.md index d4a58165fc..b7fc95c759 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -145,8 +145,12 @@ The shared binary build action compiles release artifacts with `cargo auditable` 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. The CI image gains the pinned `cargo-auditable` tool through -`mise install --locked` but ships no auditable OpenShell binary of its own. +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` +tool through `mise install --locked` but ships no auditable OpenShell binary of +its own. Pushed Docker images carry minimal SLSA provenance and a per-platform SPDX SBOM generated by BuildKit's default Syft scanner. The registry exporter uses OCI From 4c9437b63e5af6476d1b36f2b4357584cea5e6b4 Mon Sep 17 00:00:00 2001 From: alangou Date: Fri, 28 Aug 2026 13:26:39 +0000 Subject: [PATCH 045/505] ci(codeql): run nightly scans on main (#3007) Signed-off-by: Adrien Langou --- .github/codeql/codeql-config.yml | 2 ++ .github/workflows/codeql.yml | 10 ++++---- CI.md | 26 ++++++++++---------- architecture/build.md | 41 +++++++++++++++++++------------- 4 files changed, 45 insertions(+), 34 deletions(-) diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index 3953988b89..5fc0887272 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -11,5 +11,7 @@ paths: - python/openshell paths-ignore: + # Integration-test targets are not removed by the Rust cfg override. + - crates/*/tests - python/openshell/_proto - sdk/typescript/src/gen diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 28057fe18a..bd9aef1d89 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -4,13 +4,8 @@ name: CodeQL on: - pull_request: - merge_group: - types: [checks_requested] - push: - branches: [main] schedule: - - cron: "29 5 * * 6" + - cron: "29 5 * * *" workflow_dispatch: permissions: @@ -26,6 +21,9 @@ jobs: name: CodeQL (${{ matrix.language }}) runs-on: ubuntu-latest timeout-minutes: 90 + env: + # Keep Rust test fixtures out of production-focused security results. + CODEQL_EXTRACTOR_RUST_OPTION_CARGO_CFG_OVERRIDES: "-test" strategy: fail-fast: false matrix: diff --git a/CI.md b/CI.md index 2120fa546b..c0ec57f877 100644 --- a/CI.md +++ b/CI.md @@ -29,12 +29,12 @@ The GitHub ruleset should require the `OpenShell / ...` statuses published by `R ## Informational security reports Security analysis that does not need NVIDIA infrastructure runs directly on -GitHub-hosted runners. These workflows receive no secrets and 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 each scanner uploads results -directly and also retains report artifacts: +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: - `Workflow Security Reports` runs Actionlint and Zizmor. Actionlint reports workflow syntax and expression findings. Zizmor reports only High severity, @@ -46,13 +46,15 @@ directly and also retains report artifacts: a warning, so the workflow remains neutral until the repository feature is available. - `CodeQL` analyzes product Rust code, examples, and the Go, Python, and - TypeScript SDKs. E2E test code is excluded. Results are uploaded to Code - Scanning and always retained as workflow artifacts. + TypeScript SDKs. Rust `cfg(test)` blocks, Rust integration-test targets, and + E2E test code are excluded. It runs nightly on `main`, remains manually + dispatchable for diagnostics, uploads results to Code Scanning, and retains + workflow artifacts. Findings do not fail these workflows. Tool startup, configuration, build, and -analysis failures still fail so a broken scanner cannot appear healthy. These -workflows also run on merge groups, but their checks are not required statuses -and do not gate merges. +analysis failures still fail so a broken scanner cannot appear healthy. The +PR-oriented reports also run on merge groups; CodeQL is not a PR or merge-queue +check. None of these reports are required statuses or gate merges. Run the workflow-definition scanners locally with: @@ -184,7 +186,7 @@ The bot's full administrator documentation is internal to NVIDIA. The only comma | `.github/workflows/e2e-label-help.yml` | When a `test:e2e*` label is applied, posts a PR comment telling the maintainer the next manual step (re-run an existing workflow run, or `/ok to test ` to refresh the mirror). | | `.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 informational CodeQL analysis for Rust and the Go, Python, and TypeScript SDKs and retains SARIF artifacts. | +| `.github/workflows/codeql.yml` | Runs nightly informational CodeQL analysis on `main` for Rust and the Go, Python, and TypeScript SDKs and retains SARIF artifacts. | ## Release workflows diff --git a/architecture/build.md b/architecture/build.md index b7fc95c759..c6810feece 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -284,18 +284,22 @@ 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 they also cover fork pull -requests and consume no 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. -Triggers differ by workflow: `.github/workflows/workflow-security.yml` and -`.github/workflows/codeql.yml` run 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. +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. +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 +`.github/workflows/codeql.yml` runs nightly on the default branch (`main`) via +`schedule`, with `workflow_dispatch` kept for manual diagnostics. 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. - **Actionlint and Zizmor** analyze the workflow definitions themselves. Repository configuration lives in `.github/actionlint.yml` (self-hosted runner @@ -311,10 +315,15 @@ to compare. reporting on its own once the feature is enabled. Reviews run in warn-only mode. - **CodeQL** analyzes product Rust code, examples, and the Go, Python, and - TypeScript SDKs, scoped by `.github/codeql/codeql-config.yml`; E2E test code is - excluded. Only Go requires a build; the other languages use build mode `none`. - Results are uploaded to Code Scanning and always retained as workflow - artifacts. + TypeScript SDKs, scoped by `.github/codeql/codeql-config.yml`. Rust test code + is excluded in two layers: the analyze job sets + `CODEQL_EXTRACTOR_RUST_OPTION_CARGO_CFG_OVERRIDES=-test` so the extractor skips + `#[cfg(test)]` blocks, and `paths-ignore` drops `crates/*/tests`, whose + integration targets the cfg override does not reach. Examples remain in scope, + and E2E test code stays excluded because `e2e/` is not an analyzed path. Only + 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. 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 From 65745a06ef7b4aafc00600fd7f2d16ef7da49c26 Mon Sep 17 00:00:00 2001 From: Simon Scatton <44714756+SDAChess@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:11:58 +0000 Subject: [PATCH 046/505] feat!(ci): remove daily minor release automatic workflow (#3008) Signed-off-by: Simon Scatton --- .github/workflows/release-auto-tag.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-auto-tag.yml b/.github/workflows/release-auto-tag.yml index 8290de4896..b75881b755 100644 --- a/.github/workflows/release-auto-tag.yml +++ b/.github/workflows/release-auto-tag.yml @@ -5,8 +5,8 @@ name: Release Auto-Tag on: workflow_dispatch: {} - schedule: - - cron: "0 14 * * 1-5" # 7 AM PDT, weekdays only +# schedule: +# - cron: "0 14 * * 1-5" # 7 AM PDT, weekdays only permissions: contents: write From 74654ac30dfe7115b6b9dfdf9480342b34290607 Mon Sep 17 00:00:00 2001 From: Jesse Jaggars Date: Fri, 28 Aug 2026 18:15:57 +0000 Subject: [PATCH 047/505] fix(kubernetes): recover compute driver watches (#2842) Signed-off-by: Jesse Jaggars --- crates/openshell-driver-kubernetes/Cargo.toml | 1 + crates/openshell-driver-kubernetes/README.md | 4 +- .../openshell-driver-kubernetes/src/driver.rs | 311 +++++++++++++----- 3 files changed, 240 insertions(+), 76 deletions(-) diff --git a/crates/openshell-driver-kubernetes/Cargo.toml b/crates/openshell-driver-kubernetes/Cargo.toml index 6e821317c3..3a3d843f1a 100644 --- a/crates/openshell-driver-kubernetes/Cargo.toml +++ b/crates/openshell-driver-kubernetes/Cargo.toml @@ -45,6 +45,7 @@ bytes = { workspace = true } http = { workspace = true } http-body-util = { workspace = true } temp-env = "0.3" +tokio = { workspace = true, features = ["test-util"] } toml = { workspace = true } tower = { workspace = true } diff --git a/crates/openshell-driver-kubernetes/README.md b/crates/openshell-driver-kubernetes/README.md index 6d6e26654c..4ebe93c9a9 100644 --- a/crates/openshell-driver-kubernetes/README.md +++ b/crates/openshell-driver-kubernetes/README.md @@ -60,7 +60,9 @@ state and platform events into the shared compute-driver protobuf surface used by the gateway. Kubernetes API calls use explicit timeouts so gRPC handlers do not block -indefinitely when the API server is slow or unavailable. +indefinitely when the API server is slow or unavailable. Resource and Event +watches recover in place with API-friendly backoff after transient watcher +errors, avoiding a gateway-side watch restart and its associated watch gap. ## Workspace Persistence diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index c1885870e8..41a787b427 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -26,6 +26,7 @@ use kube::api::{ }; use kube::core::gvk::GroupVersionKind; use kube::core::{DynamicObject, ObjectMeta}; +use kube::runtime::WatchStreamExt; use kube::runtime::watcher::{self, Event}; use kube::{Client, Error as KubeError}; use openshell_core::driver_mounts; @@ -1861,8 +1862,16 @@ impl KubernetesComputeDriver { .await?; let event_api: Api = Api::namespaced(self.watch_client.clone(), &namespace); let watcher_config = watcher::Config::default().labels(&openshell_sandbox_label_selector()); - let mut sandbox_stream = watcher::watcher(agent_sandbox_api.api, watcher_config).boxed(); - let mut event_stream = watcher::watcher(event_api, watcher::Config::default()).boxed(); + let mut sandbox_stream = recovering_watcher_stream( + watcher::watcher(agent_sandbox_api.api, watcher_config), + "sandbox-resource", + ) + .boxed(); + let mut event_stream = recovering_watcher_stream( + watcher::watcher(event_api, watcher::Config::default()), + "kubernetes-event", + ) + .boxed(); let (tx, rx) = mpsc::channel(256); tokio::spawn(async move { @@ -1871,8 +1880,8 @@ impl KubernetesComputeDriver { loop { tokio::select! { - result = sandbox_stream.try_next() => match result { - Ok(Some(Event::Applied(obj))) => { + event = sandbox_stream.next() => match event { + Some(Event::Applied(obj)) => { if let Ok((kube_name, sandbox)) = sandbox_from_object(&namespace, obj) { update_indexes(&mut sandbox_name_to_id, &mut agent_pod_to_id, &kube_name, &sandbox); let event = WatchSandboxesEvent { @@ -1885,7 +1894,7 @@ impl KubernetesComputeDriver { } } } - Ok(Some(Event::Deleted(obj))) => { + Some(Event::Deleted(obj)) => { if is_openshell_managed(&obj) && let Ok(sandbox_id) = sandbox_id_from_object(&obj) { @@ -1900,7 +1909,7 @@ impl KubernetesComputeDriver { } } } - Ok(Some(Event::Restarted(objs))) => { + Some(Event::Restarted(objs)) => { for obj in objs { if let Ok((kube_name, sandbox)) = sandbox_from_object(&namespace, obj) { update_indexes(&mut sandbox_name_to_id, &mut agent_pod_to_id, &kube_name, &sandbox); @@ -1915,19 +1924,15 @@ impl KubernetesComputeDriver { } } } - Ok(None) => { + None => { let _ = tx.send(Err(KubernetesDriverError::Message( "sandbox watcher stream ended unexpectedly".to_string() ))).await; break; } - Err(err) => { - let _ = tx.send(Err(KubernetesDriverError::Message(err.to_string()))).await; - break; - } }, - result = event_stream.try_next() => match result { - Ok(Some(Event::Applied(obj))) => { + event = event_stream.next() => match event { + Some(Event::Applied(obj)) => { if let Some((sandbox_id, event)) = map_kube_event_to_platform( &sandbox_name_to_id, &agent_pod_to_id, @@ -1943,20 +1948,16 @@ impl KubernetesComputeDriver { } } } - Ok(Some(Event::Deleted(_))) => {} - Ok(Some(Event::Restarted(_))) => { + Some(Event::Deleted(_)) => {} + Some(Event::Restarted(_)) => { debug!(namespace = %namespace, "Kubernetes event watcher restarted"); } - Ok(None) => { + None => { let _ = tx.send(Err(KubernetesDriverError::Message( "kubernetes event watcher stream ended".to_string() ))).await; break; } - Err(err) => { - let _ = tx.send(Err(KubernetesDriverError::Message(err.to_string()))).await; - break; - } }, () = tx.closed() => break, } @@ -1974,15 +1975,59 @@ impl KubernetesComputeDriver { Self::cluster_wide_sandbox_api(self.watch_client.clone(), sandbox_api_version); let selector = self.openshell_sandbox_selector(); let watcher_config = watcher::Config::default().labels(&selector); - let mut sandbox_stream = watcher::watcher(cluster_api.api, watcher_config).boxed(); - let (tx, rx) = mpsc::channel(256); - let default_namespace = self.config.namespace.clone(); + let sandbox_stream = recovering_watcher_stream( + watcher::watcher(cluster_api.api, watcher_config), + "sandbox-resource", + ) + .boxed(); - tokio::spawn(async move { - loop { - tokio::select! { - result = sandbox_stream.try_next() => match result { - Ok(Some(Event::Applied(obj))) => { + Ok(cluster_wide_watch_stream( + sandbox_stream, + self.config.namespace.clone(), + )) + } +} + +fn cluster_wide_watch_stream(mut sandbox_stream: S, default_namespace: String) -> WatchStream +where + S: Stream> + Send + Unpin + 'static, +{ + let (tx, rx) = mpsc::channel(256); + + tokio::spawn(async move { + loop { + tokio::select! { + event = sandbox_stream.next() => match event { + Some(Event::Applied(obj)) => { + let ns = obj.metadata.namespace.clone() + .unwrap_or_else(|| default_namespace.clone()); + if let Ok((_kube_name, sandbox)) = sandbox_from_object(&ns, obj) { + let event = WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Sandbox( + WatchSandboxesSandboxEvent { sandbox: Some(sandbox) } + )), + }; + if tx.send(Ok(event)).await.is_err() { + break; + } + } + } + Some(Event::Deleted(obj)) => { + if is_openshell_managed(&obj) + && let Ok(sandbox_id) = sandbox_id_from_object(&obj) + { + let event = WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Deleted( + WatchSandboxesDeletedEvent { sandbox_id } + )), + }; + if tx.send(Ok(event)).await.is_err() { + break; + } + } + } + Some(Event::Restarted(objs)) => { + for obj in objs { let ns = obj.metadata.namespace.clone() .unwrap_or_else(|| default_namespace.clone()); if let Ok((_kube_name, sandbox)) = sandbox_from_object(&ns, obj) { @@ -1992,58 +2037,61 @@ impl KubernetesComputeDriver { )), }; if tx.send(Ok(event)).await.is_err() { - break; + return; } } } - Ok(Some(Event::Deleted(obj))) => { - if is_openshell_managed(&obj) - && let Ok(sandbox_id) = sandbox_id_from_object(&obj) - { - let event = WatchSandboxesEvent { - payload: Some(watch_sandboxes_event::Payload::Deleted( - WatchSandboxesDeletedEvent { sandbox_id } - )), - }; - if tx.send(Ok(event)).await.is_err() { - break; - } - } - } - Ok(Some(Event::Restarted(objs))) => { - for obj in objs { - let ns = obj.metadata.namespace.clone() - .unwrap_or_else(|| default_namespace.clone()); - if let Ok((_kube_name, sandbox)) = sandbox_from_object(&ns, obj) { - let event = WatchSandboxesEvent { - payload: Some(watch_sandboxes_event::Payload::Sandbox( - WatchSandboxesSandboxEvent { sandbox: Some(sandbox) } - )), - }; - if tx.send(Ok(event)).await.is_err() { - return; - } - } - } - } - Ok(None) => { - let _ = tx.send(Err(KubernetesDriverError::Message( - "sandbox watcher stream ended unexpectedly".to_string() - ))).await; - break; - } - Err(err) => { - let _ = tx.send(Err(KubernetesDriverError::Message(err.to_string()))).await; - break; - } - }, - () = tx.closed() => break, - } + } + None => { + let _ = tx.send(Err(KubernetesDriverError::Message( + "sandbox watcher stream ended unexpectedly".to_string() + ))).await; + break; + } + }, + () = tx.closed() => break, } - }); + } + }); - Ok(Box::pin(ReceiverStream::new(rx))) - } + Box::pin(ReceiverStream::new(rx)) +} + +fn recovering_watcher_stream( + stream: S, + watcher: &'static str, +) -> impl Stream> +where + S: Stream, E>>, + E: std::fmt::Display, +{ + continue_on_watcher_errors(stream.default_backoff(), watcher) +} + +/// Drop kube-runtime watcher errors after logging them so continued polling can +/// drive its built-in relist and recovery state machine. The production adapter +/// above applies backoff first to avoid hot-looping on persistent API failures. +fn continue_on_watcher_errors( + stream: S, + watcher: &'static str, +) -> impl Stream> +where + S: Stream, E>>, + E: std::fmt::Display, +{ + stream.filter_map(move |result| { + futures::future::ready(match result { + Ok(event) => Some(event), + Err(err) => { + warn!( + watcher, + error = %err, + "Kubernetes watcher stream error; waiting for kube-runtime recovery" + ); + None + } + }) + }) } fn add_trace_context_annotation(annotations: &mut BTreeMap) { @@ -4744,6 +4792,119 @@ mod tests { }) } + fn expired_watch_error() -> watcher::Error { + watcher::Error::WatchError(kube::core::ErrorResponse { + status: "Failure".to_string(), + message: "too old resource version".to_string(), + reason: "Expired".to_string(), + code: 410, + }) + } + + #[tokio::test] + async fn sandbox_watcher_error_does_not_hide_restarted_recovery_event() { + let recovered = DynamicObject { + types: None, + metadata: ObjectMeta { + name: Some("recovered-sandbox".to_string()), + ..Default::default() + }, + data: serde_json::json!({}), + }; + let source = futures::stream::iter([ + Err(expired_watch_error()), + Ok(Event::Restarted(vec![recovered])), + ]); + let mut stream = continue_on_watcher_errors(source, "sandbox-resource"); + + let event = stream + .next() + .await + .expect("410 Expired must not terminate the watcher stream"); + let Event::Restarted(objects) = event else { + panic!("expected kube-runtime recovery to emit Restarted"); + }; + assert_eq!(objects.len(), 1); + assert_eq!( + objects[0].metadata.name.as_deref(), + Some("recovered-sandbox") + ); + assert!( + stream.next().await.is_none(), + "source closure must be preserved" + ); + } + + #[tokio::test(start_paused = true)] + async fn outward_watch_stream_survives_expired_error_and_backoff_recovery() { + let recovered = DynamicObject { + types: None, + metadata: ObjectMeta { + name: Some("recovered-sandbox".to_string()), + namespace: Some("recovered-namespace".to_string()), + labels: Some(BTreeMap::from([ + (LABEL_SANDBOX_ID.to_string(), "sandbox-id".to_string()), + (LABEL_SANDBOX_NAME.to_string(), "sandbox-name".to_string()), + (LABEL_SANDBOX_WORKSPACE.to_string(), "workspace".to_string()), + ( + LABEL_MANAGED_BY.to_string(), + LABEL_MANAGED_BY_VALUE.to_string(), + ), + ])), + ..Default::default() + }, + data: serde_json::json!({}), + }; + let source = futures::stream::iter([ + Err(expired_watch_error()), + Ok(Event::Restarted(vec![recovered])), + ]) + .chain(futures::stream::pending()); + let sandbox_stream = recovering_watcher_stream(source, "sandbox-resource").boxed(); + let mut outward = cluster_wide_watch_stream(sandbox_stream, "default".to_string()); + + let event = outward + .next() + .await + .expect("outward stream must stay open through recovery") + .expect("recoverable watcher error must not reach the outward stream"); + let Some(watch_sandboxes_event::Payload::Sandbox(event)) = event.payload else { + panic!("expected recovered sandbox event"); + }; + let sandbox = event.sandbox.expect("sandbox payload must be populated"); + assert_eq!(sandbox.id, "sandbox-id"); + assert_eq!(sandbox.namespace, "recovered-namespace"); + + let next = outward.next(); + futures::pin_mut!(next); + assert!( + futures::poll!(next).is_pending(), + "outward stream must remain open after the recovered event" + ); + } + + #[tokio::test] + async fn kubernetes_event_watcher_error_does_not_hide_restarted_recovery_event() { + let source = futures::stream::iter([ + Err(expired_watch_error()), + Ok(Event::Restarted(vec![KubeEventObj::default()])), + ]); + let mut stream = continue_on_watcher_errors(source, "kubernetes-event"); + + let event = stream + .next() + .await + .expect("410 Expired must not terminate the watcher stream"); + let Event::Restarted(events) = event else { + panic!("expected kube-runtime recovery to emit Restarted"); + }; + assert_eq!(events.len(), 1); + assert!( + stream.next().await.is_none(), + "source closure must be preserved" + ); + } + #[test] fn sandbox_api_version_probe_retries_on_structured_and_raw_404() { let structured = kube_api_error(404, "could not find the requested resource"); From 57c7f743f984cf83322c5b7259a19c0d8d559f78 Mon Sep 17 00:00:00 2001 From: Sean Lopp Date: Fri, 28 Aug 2026 23:36:21 +0000 Subject: [PATCH 048/505] fix(cli): include provider identity metadata for interceptors on update (#3014) * fix(cli): preserve provider identity on update Signed-off-by: Sean Lopp * fix(server): hydrate provider identity before interception Signed-off-by: Sean Lopp --------- Signed-off-by: Sean Lopp --- crates/openshell-cli/src/run.rs | 1 - .../tests/provider_commands_integration.rs | 21 ++ crates/openshell-server/src/multiplex.rs | 234 +++++++++++++++++- 3 files changed, 248 insertions(+), 8 deletions(-) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 0a0b21a7f4..54dcb70f48 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -4822,7 +4822,6 @@ pub async fn provider_update(options: ProviderUpdateOptions<'_>) -> Result<()> { } let mut client = grpc_client(server, tls).await?; - let oidc_profile = if from_oidc_token { let existing = client .get_provider(GetProviderRequest { diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 2c71e0b39f..f4d8d0178c 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -32,6 +32,7 @@ use openshell_core::proto::{ use openshell_core::{ObjectId, ObjectName}; use std::collections::HashMap; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use tempfile::TempDir; use tokio::net::TcpListener; use tokio::sync::{Mutex, mpsc}; @@ -45,6 +46,8 @@ struct ProviderState { profiles: Arc>>, refresh_statuses: Arc>>, refresh_requests: Arc>>, + provider_update_requests: Arc>>, + deny_provider_reads: Arc, delete_provider_requests: Arc>>, fail_configure_refresh_message: Arc>>, fail_rotate_refresh_message: Arc>>, @@ -432,6 +435,9 @@ impl OpenShell for TestOpenShell { &self, request: tonic::Request, ) -> Result, Status> { + if self.state.deny_provider_reads.load(Ordering::SeqCst) { + return Err(Status::permission_denied("scope 'provider:read' required")); + } let name = request.into_inner().name; let providers = self.state.providers.lock().await; let provider = providers @@ -611,6 +617,11 @@ impl OpenShell for TestOpenShell { .into_inner() .provider .ok_or_else(|| Status::invalid_argument("provider is required"))?; + self.state + .provider_update_requests + .lock() + .await + .push(provider.clone()); let mut providers = self.state.providers.lock().await; let existing = providers @@ -1198,6 +1209,10 @@ async fn provider_cli_run_functions_support_full_crud_flow() { .await .expect("provider list"); + // A credential-only update must remain available to callers that have + // provider:write but not provider:read. + ts.state.deny_provider_reads.store(true, Ordering::SeqCst); + run::provider_update(run::ProviderUpdateOptions { server: &ts.endpoint, name: "my-claude", @@ -1212,6 +1227,12 @@ async fn provider_cli_run_functions_support_full_crud_flow() { .await .expect("provider update"); + let requests = ts.state.provider_update_requests.lock().await; + let request = requests.last().expect("provider update request"); + assert!(request.r#type.is_empty()); + assert!(request.profile_workspace.is_empty()); + drop(requests); + run::provider_delete(&ts.endpoint, &["my-claude".to_string()], "default", &ts.tls) .await .expect("provider delete"); diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index 7a7125dcc3..fc7963f2e1 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -17,15 +17,19 @@ use hyper_util::{ service::TowerToHyperService, }; use metrics::{counter, histogram}; -use openshell_core::Config; use openshell_core::proto::{ inference_server::InferenceServer, open_shell_server::OpenShellServer, }; +use openshell_core::{ + Config, + proto::{Provider, UpdateProviderRequest}, +}; use openshell_gateway_interceptors::{EvaluationContext, GatewayInterceptorRuntime}; use openshell_otel::HeaderMapExtractor; use opentelemetry::propagation::TextMapPropagator; use opentelemetry::trace::TraceContextExt as _; use opentelemetry_sdk::propagation::TraceContextPropagator; +use prost::Message; use std::collections::BTreeMap; use std::convert::Infallible; use std::future::Future; @@ -46,6 +50,7 @@ use crate::{ auth::identity::Identity, auth::oidc::{self, OidcAuthenticator}, auth::principal::{Principal, UserPrincipal}, + auth::workspace_authz::{MinWorkspaceRole, authorize_workspace}, gateway_listener::GatewayListenerScope, http_router, inference::InferenceService, @@ -267,8 +272,11 @@ impl MultiplexService { { let openshell = OpenShellServer::new(OpenShellService::new(self.state.clone())) .max_decoding_message_size(MAX_GRPC_DECODE_SIZE); - let openshell = - GatewayInterceptorGrpcService::new(openshell, self.state.gateway_interceptors.clone()); + let openshell = GatewayInterceptorGrpcService::new( + openshell, + self.state.gateway_interceptors.clone(), + Some(self.state.clone()), + ); let inference = InferenceServer::new(InferenceService::new(self.state.clone())) .max_decoding_message_size(MAX_GRPC_DECODE_SIZE); let authz_policy = self.state.config.oidc.as_ref().map(|oidc| AuthzPolicy { @@ -394,13 +402,19 @@ where struct GatewayInterceptorGrpcService { inner: S, interceptors: Option, + state: Option>, } impl GatewayInterceptorGrpcService { - fn new(inner: S, interceptors: Option) -> Self { + fn new( + inner: S, + interceptors: Option, + state: Option>, + ) -> Self { Self { inner, interceptors, + state, } } } @@ -424,6 +438,7 @@ where fn call(&mut self, req: Request) -> Self::Future { let interceptors = self.interceptors.clone(); + let state = self.state.clone(); let mut inner = self.inner.clone(); Box::pin(async move { @@ -437,11 +452,21 @@ where } let context = gateway_interceptor_context(req.extensions()); + let principal = req.extensions().get::().cloned(); let (parts, body) = req.into_parts(); - let body = match collect_intercepted_grpc_body(body).await { + let mut body = match collect_intercepted_grpc_body(body).await { Ok(body) => body, Err(status) => return Ok(status.into_http()), }; + if let Some(state) = state.as_ref() { + body = + match hydrate_update_provider_identity(&path, body, state, principal.as_ref()) + .await + { + Ok(body) => body, + Err(status) => return Ok(status.into_http()), + }; + } let intercepted = match interceptors.evaluate_request(&path, &body, &context).await { Ok(intercepted) => intercepted, @@ -497,6 +522,104 @@ where } } +const UPDATE_PROVIDER_PATH: &str = "/openshell.v1.OpenShell/UpdateProvider"; +const GRPC_FRAME_HEADER_LEN: usize = 5; + +/// Complete immutable provider identity before policy interception. +/// +/// Update requests intentionally omit immutable fields. Loading them here keeps +/// `provider update` a write-only operation while giving policy interceptors a +/// canonical proposed operation derived from trusted gateway state. +async fn hydrate_update_provider_identity( + path: &str, + body: Bytes, + state: &ServerState, + principal: Option<&Principal>, +) -> Result { + if path != UPDATE_PROVIDER_PATH { + return Ok(body); + } + + let mut request = decode_unary_grpc_message::(&body)?; + let Some(provider) = request.provider.as_mut() else { + return Ok(body); + }; + if !provider.r#type.is_empty() && !provider.profile_workspace.is_empty() { + return Ok(body); + } + let name = provider + .metadata + .as_ref() + .map(|metadata| metadata.name.as_str()) + .unwrap_or_default(); + if name.is_empty() { + return Ok(body); + } + + let principal = + principal.ok_or_else(|| tonic::Status::unauthenticated("authentication required"))?; + let authorized = authorize_workspace( + state.store.as_ref(), + &state.admin_role, + principal, + &request.workspace, + MinWorkspaceRole::Admin, + ) + .await?; + let workspace = + crate::grpc::workspace::resolve_workspace(state.store.as_ref(), &authorized.workspace) + .await? + .name; + let existing = state + .store + .get_message_by_name::(&workspace, name) + .await + .map_err(|error| tonic::Status::internal(format!("provider lookup failed: {error}")))? + .ok_or_else(|| tonic::Status::not_found(format!("provider '{name}' not found")))?; + + if provider.r#type.is_empty() { + provider.r#type = existing.r#type; + } + if provider.profile_workspace.is_empty() { + provider.profile_workspace = existing.profile_workspace; + } + + encode_unary_grpc_message(&request) +} + +fn decode_unary_grpc_message(body: &[u8]) -> Result +where + M: Message + Default, +{ + if body.len() < GRPC_FRAME_HEADER_LEN { + return Err(tonic::Status::invalid_argument("gRPC frame is too short")); + } + if body[0] != 0 { + return Err(tonic::Status::unimplemented( + "gateway interceptors do not support compressed gRPC frames", + )); + } + let message_len = u32::from_be_bytes([body[1], body[2], body[3], body[4]]) as usize; + if body.len() != GRPC_FRAME_HEADER_LEN + message_len { + return Err(tonic::Status::invalid_argument( + "gRPC body must contain exactly one frame", + )); + } + M::decode(&body[GRPC_FRAME_HEADER_LEN..]) + .map_err(|error| tonic::Status::invalid_argument(format!("invalid gRPC message: {error}"))) +} + +fn encode_unary_grpc_message(message: &M) -> Result { + let message = message.encode_to_vec(); + let message_len = u32::try_from(message.len()) + .map_err(|_| tonic::Status::resource_exhausted("gRPC message exceeds u32"))?; + let mut frame = Vec::with_capacity(GRPC_FRAME_HEADER_LEN + message.len()); + frame.push(0); + frame.extend_from_slice(&message_len.to_be_bytes()); + frame.extend_from_slice(&message); + Ok(Bytes::from(frame)) +} + async fn collect_intercepted_grpc_body(body: BoxBody) -> Result { Limited::new(body, MAX_INTERCEPTED_GRPC_BODY_SIZE) .collect() @@ -1325,7 +1448,6 @@ mod tests { ProviderProfileSnapshotRequest, gateway_interceptor_server::{GatewayInterceptor, GatewayInterceptorServer}, }; - use prost::Message as _; use std::convert::Infallible; use std::sync::Mutex; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -1653,6 +1775,57 @@ mod tests { assert_eq!(status.code(), tonic::Code::ResourceExhausted); } + #[tokio::test] + async fn update_provider_interception_hydrates_identity_from_trusted_state() { + let state = crate::grpc::test_support::test_server_state().await; + let existing = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "provider-id".to_string(), + name: "managed-provider".to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + r#type: "agent-tool-gateway".to_string(), + profile_workspace: "default".to_string(), + ..Default::default() + }; + state.store.put_message(&existing).await.unwrap(); + + let request = UpdateProviderRequest { + provider: Some(Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + name: "managed-provider".to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + credentials: std::collections::HashMap::from([( + "TOKEN".to_string(), + "rotated".to_string(), + )]), + ..Default::default() + }), + workspace: "default".to_string(), + ..Default::default() + }; + let authed = crate::grpc::test_support::authed_request(()); + let principal = authed.extensions().get::().unwrap(); + + let hydrated = hydrate_update_provider_identity( + UPDATE_PROVIDER_PATH, + grpc_frame(&request.encode_to_vec()), + state.as_ref(), + Some(principal), + ) + .await + .unwrap(); + let hydrated = decode_unary_grpc_message::(&hydrated).unwrap(); + let provider = hydrated.provider.unwrap(); + + assert_eq!(provider.r#type, "agent-tool-gateway"); + assert_eq!(provider.profile_workspace, "default"); + assert_eq!(provider.credentials.get("TOKEN").unwrap(), "rotated"); + } + #[tokio::test] async fn intercepted_grpc_response_preserves_body_and_trailers() { let bytes = Bytes::from_static(b"committed-response"); @@ -1720,7 +1893,7 @@ mod tests { ))) } }); - let mut service = GatewayInterceptorGrpcService::new(inner, Some(runtime)); + let mut service = GatewayInterceptorGrpcService::new(inner, Some(runtime), None); let request_body = grpc_frame(&CreateSandboxRequest::default().encode_to_vec()); let request = Request::builder() .uri("/openshell.v1.OpenShell/CreateSandbox") @@ -2539,6 +2712,18 @@ mod tests { }) } + fn provider_writer_principal() -> Principal { + Principal::User(UserPrincipal { + identity: Identity { + subject: "provider-rotation-job".to_string(), + display_name: None, + roles: vec!["openshell-admin".to_string()], + scopes: vec!["provider:write".to_string()], + provider: IdentityProvider::Oidc, + }, + }) + } + fn mtls_identity(subject: &str) -> Identity { Identity { subject: subject.to_string(), @@ -2795,6 +2980,41 @@ mod tests { } } + #[tokio::test] + async fn provider_write_scope_allows_update_without_provider_read() { + let policy = AuthzPolicy { + admin_role: "openshell-admin".to_string(), + user_role: "openshell-user".to_string(), + scopes_enabled: true, + }; + + let mock = Arc::new(MockAuthenticator::returning(Ok(Some( + provider_writer_principal(), + )))); + let chain = AuthenticatorChain::new(vec![mock]); + let (recorder, seen) = PrincipalRecorder::new(); + let mut router = AuthGrpcRouter::new(recorder, Some(chain), Some(policy.clone())); + let response = router + .call(empty_request(UPDATE_PROVIDER_PATH)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert!(seen.lock().unwrap().is_some()); + + let mock = Arc::new(MockAuthenticator::returning(Ok(Some( + provider_writer_principal(), + )))); + let chain = AuthenticatorChain::new(vec![mock]); + let (recorder, seen) = PrincipalRecorder::new(); + let mut router = AuthGrpcRouter::new(recorder, Some(chain), Some(policy)); + let response = router + .call(empty_request("/openshell.v1.OpenShell/GetProvider")) + .await + .unwrap(); + assert_eq!(grpc_status(&response).as_deref(), Some("7")); + assert!(seen.lock().unwrap().is_none()); + } + #[tokio::test] async fn sandbox_principal_is_denied_on_user_and_admin_methods() { for path in [ From 69a05ebb3b154e304a66fe80eed8504e889abc6d Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 28 Aug 2026 18:21:20 -0700 Subject: [PATCH 049/505] fix(sandbox): complete successful main processes (#2884) --- .agents/skills/openshell-cli/SKILL.md | 18 +- .agents/skills/openshell-cli/cli-reference.md | 19 +- architecture/compute-runtimes.md | 8 + architecture/gateway.md | 16 +- architecture/sandbox.md | 16 +- crates/openshell-cli/src/commands/common.rs | 1 + crates/openshell-cli/src/main.rs | 12 +- crates/openshell-cli/src/run.rs | 203 +++- crates/openshell-cli/src/ssh.rs | 93 +- .../tests/ensure_providers_integration.rs | 7 + .../openshell-cli/tests/mtls_integration.rs | 7 + .../tests/provider_commands_integration.rs | 7 + .../sandbox_create_lifecycle_integration.rs | 196 ++++ .../sandbox_name_fallback_integration.rs | 7 + crates/openshell-core/src/sandbox_env.rs | 6 + crates/openshell-driver-docker/src/tests.rs | 1 + .../src/proto_json.rs | 1 + .../src/runtime.rs | 1 + crates/openshell-sandbox/src/lib.rs | 109 +- crates/openshell-sandbox/src/main.rs | 17 +- .../openshell-sandbox/src/sidecar_control.rs | 40 +- crates/openshell-sdk/src/client.rs | 24 +- crates/openshell-sdk/src/types.rs | 2 + crates/openshell-sdk/tests/client_mock.rs | 42 + crates/openshell-server/src/compute/mod.rs | 388 ++++++- crates/openshell-server/src/grpc/mod.rs | 12 +- crates/openshell-server/src/grpc/sandbox.rs | 100 +- .../src/supervisor_session.rs | 87 +- crates/openshell-server/tests/common/mod.rs | 7 + .../tests/supervisor_relay_integration.rs | 7 + .../src/main_session.rs | 228 +++- .../openshell-supervisor-process/src/run.rs | 141 ++- .../openshell-supervisor-process/src/ssh.rs | 128 ++- .../src/supervisor_session.rs | 86 +- crates/openshell-tui/src/lib.rs | 3 + crates/openshell-tui/src/ui/sandbox_detail.rs | 3 +- crates/openshell-tui/src/ui/sandboxes.rs | 2 +- docs/reference/sandbox-compute-drivers.mdx | 4 +- docs/sandboxes/manage-sandboxes.mdx | 38 +- e2e/mcp-conformance.sh | 1 + e2e/rust/tests/oidc_pkce.rs | 23 +- e2e/rust/tests/sandbox_lifecycle.rs | 66 +- e2e/rust/tests/workspace_namespace_managed.rs | 81 +- .../tests/workspace_namespace_operator.rs | 42 +- proto/compute_driver.proto | 3 + proto/openshell.proto | 29 +- python/openshell/sandbox.py | 12 + python/openshell/sandbox_test.py | 35 + sdk/go/openshell/v1/fake/sandbox.go | 6 +- .../v1/internal/converter/coverage_test.go | 4 +- .../v1/internal/converter/sandbox.go | 4 + .../v1/internal/converter/sandbox_test.go | 2 + sdk/go/openshell/v1/sandbox_client.go | 9 +- sdk/go/openshell/v1/sandbox_client_test.go | 30 + sdk/go/openshell/v1/types.go | 1 + sdk/go/openshell/v1/types/types.go | 1 + sdk/go/proto/openshellv1/openshell.pb.go | 1011 +++++++++-------- sdk/go/proto/openshellv1/openshell_grpc.pb.go | 40 + sdk/typescript/src/client.test.ts | 24 + sdk/typescript/src/client.ts | 7 +- 60 files changed, 2686 insertions(+), 832 deletions(-) diff --git a/.agents/skills/openshell-cli/SKILL.md b/.agents/skills/openshell-cli/SKILL.md index dd1bd1051b..eb5c467cb6 100644 --- a/.agents/skills/openshell-cli/SKILL.md +++ b/.agents/skills/openshell-cli/SKILL.md @@ -74,6 +74,12 @@ This creates a sandbox whose canonical main process is `/bin/bash -l` and attaches your terminal to that retained process. Add `--detach` to return after the sandbox becomes ready without attaching. +An explicit trailing command is foreground even when stdin or stdout is not a +terminal. The CLI streams its stdout and stderr and returns its exact exit +status. Exit code 0 leaves a retained sandbox in `Completed`; nonzero leaves it +in `Error` with `MainProcessFailed`. Use `--no-keep` to delete either result +after output drains, or `--detach` for a long-running service. + When supplying `--name`, use a portable DNS-1123 label: at most 63 lowercase alphanumeric or `-` characters, beginning and ending with an alphanumeric character. The Kubernetes driver rejects uppercase letters, underscores, dots, and other names that cannot become Kubernetes resource labels. **Shortcut for known tools**: When the trailing command is a recognized tool, the CLI auto-creates the required provider from local credentials: @@ -248,11 +254,16 @@ Key flags: - `--approval-mode manual|auto`: Control handling of agent-authored policy proposals; `manual` is the default - `--upload [:]`: Upload local files into the container working directory or an explicit destination - `--no-git-ignore`: Disable `.gitignore` filtering for uploads -- `--no-keep`: Delete the sandbox after the initial command or shell exits +- `--no-keep`: Delete the sandbox after main output and the exit result drain - `--detach`: Start the canonical main process without attaching - `--forward [BIND_ADDRESS:]PORT`: Forward a local port and keep the sandbox alive - `--editor vscode|cursor`: Open a remote editor after creation and keep the sandbox alive +`--detach` adds no attachment grace period. When the canonical process exits, +its terminal phase is reported immediately. A foreground create declares one +expected main-process SSH attachment; cleanup finalizes after that connection +closes naturally. + Do not combine `--upload` with a trailing main command. Uploads currently finish after the canonical process starts; create a scratch sandbox and use `sandbox exec`, or build the files into the image. @@ -363,7 +374,10 @@ openshell sandbox start [name] Both commands default to the last-used sandbox. Stop stops background forwards and waits for `Stopped`; start waits for `Ready`. Connect, exec, file transfer, forwarding, and exposed services are unavailable while -stopped. Delete remains the operation that removes retained state. +stopped or completed. Starting a retained `Completed` or +`Error/MainProcessFailed` sandbox launches a fresh canonical-main instance and +invalidates SSH sessions from the previous runtime generation. Delete remains +the operation that removes retained state. --- diff --git a/.agents/skills/openshell-cli/cli-reference.md b/.agents/skills/openshell-cli/cli-reference.md index 856fe32d07..4d39143d8d 100644 --- a/.agents/skills/openshell-cli/cli-reference.md +++ b/.agents/skills/openshell-cli/cli-reference.md @@ -207,18 +207,28 @@ identity provider. Requires an authenticated gateway connection. 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. +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 ` | Sandbox name (auto-generated if omitted) | | `--from ` | Community name, Dockerfile path, directory, or image reference (BYOC) | -| `--no-keep` | Delete the sandbox after the initial command or shell exits | +| `--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 ` | CPU limit (for example: `500m`, `1`, `2.5`) | | `--memory ` | 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 ` | Experimental driver-keyed configuration object | | `--provider ` | Provider to attach (repeatable) | | `--policy ` | Custom policy YAML; overrides the built-in default and `OPENSHELL_SANDBOX_POLICY` | @@ -269,8 +279,9 @@ and waits for the `Stopped` phase. ### `openshell sandbox start [name]` -Start a stopped sandbox and wait for `Ready`. The name defaults to the -last-used sandbox. +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...` diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index 2a36073486..3821d48afe 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -137,6 +137,14 @@ The gateway persists lifecycle intent before mutating compute: Ready -> Stopping -> Stopped -> Starting -> Ready ``` +A canonical main process that exits successfully follows `Ready -> Completed`. +A nonzero or signal-normalized result follows `Ready -> Error` with a +`MainProcessFailed` condition. Both retained results may be started explicitly, +which creates a fresh main-process instance. Drivers must not automatically +restart a completed or failed canonical process. Before an explicit restart, +the gateway disconnects the prior supervisor session and deletes its SSH +sessions so credentials cannot cross runtime generations. + `StopSandbox` and `StartSandbox` are idempotent driver operations. Stop retains the driver resource and its persistent workspace boundary while making exec, SSH, forwarding, and exposed services unavailable. Start reactivates the diff --git a/architecture/gateway.md b/architecture/gateway.md index dcb1dd5414..594a940d7d 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -16,8 +16,10 @@ workloads. - Coordinate supervisor relay sessions for connect, exec, file sync, and service forwarding. - Persist the canonical main-process instance ID and normalized exit code on - sandbox status. Any main process exit transitions the sandbox to `Error`, - including exit code zero. + sandbox status. Exit code zero transitions the sandbox to `Completed`; + nonzero results transition it to `Error/MainProcessFailed`. Infrastructure + failures also use `Error`, with a distinct reason and no fabricated command + result. The gateway does not enforce agent network policy at request time. That happens inside each sandbox, where the supervisor and proxy can observe local process @@ -26,7 +28,15 @@ identity. The live supervisor session is the readiness authority for its main-process instance. The supervisor reports its normalized result through the sandbox-authenticated `ReportMainProcessExit` RPC, and the gateway rejects -results from stale instance IDs. +results from stale instance IDs. Foreground creation carries a one-shot +attachment intent to the process supervisor. The supervisor durably reports the +result immediately, accepts that declared SSH attachment even when the process +has already exited, sends the retained output and exit status, and waits for the +peer's channel close before finalizing the result for ephemeral cleanup. +Detached commands carry no attachment intent, so they finalize and exit +immediately without a grace period. Finalization is persisted separately from +the exit result; the gateway deletes an ephemeral sandbox only after the +finalized supervisor session disconnects. ## Protocol and Auth diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 786ed5194d..b1209167c4 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -481,7 +481,15 @@ engine with a gateway policy revision. re-evaluate. - If the supervisor relay drops, the sandbox can keep running, but connect and exec operations fail until the supervisor registers again. -- If the canonical main process exits, including with code 0, the supervisor - reports its normalized exit code before shutdown. The gateway persists the - code on sandbox status, records `MainProcessExited`, and makes the sandbox - terminal `Error`; runtime restart policies must not replace the process. +- If the canonical main process exits, the supervisor durably reports the + normalized result immediately. A foreground create declares a one-shot main + attachment, so the supervisor accepts it even after a fast process exits, + sends the retained output and SSH exit status, waits for the peer's channel + close, and then finalizes ephemeral cleanup. With no declared or active + attachment, it finalizes and exits without a grace period. The gateway waits + for that finalized supervisor session to disconnect before deleting an + ephemeral sandbox. Exit code 0 records + `Completed/MainProcessCompleted`; nonzero and signal-normalized exits record + `Error/MainProcessFailed`. Infrastructure failures also use `Error`, with a + distinct condition reason and no fabricated canonical-process result. Runtime + restart policies must not replace the canonical process. diff --git a/crates/openshell-cli/src/commands/common.rs b/crates/openshell-cli/src/commands/common.rs index 75dc260ba7..2af950cd01 100644 --- a/crates/openshell-cli/src/commands/common.rs +++ b/crates/openshell-cli/src/commands/common.rs @@ -65,6 +65,7 @@ pub fn phase_name(phase: i32) -> &'static str { Ok(SandboxPhase::Stopping) => "Stopping", Ok(SandboxPhase::Stopped) => "Stopped", Ok(SandboxPhase::Starting) => "Starting", + Ok(SandboxPhase::Completed) => "Completed", Ok(SandboxPhase::Unknown) | Err(_) => "Unknown", } } diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index aaabf26625..d17e830969 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -3071,7 +3071,7 @@ async fn run_async() -> Result<()> { let endpoint = &ctx.endpoint; let mut tls = tls.with_gateway_name(&ctx.name); apply_auth(&mut tls, &ctx.name); - Box::pin(run::sandbox_create( + let exit_code = Box::pin(run::sandbox_create( endpoint, &ctx.name, run::SandboxCreateConfig { @@ -3100,6 +3100,9 @@ async fn run_async() -> Result<()> { &tls, )) .await?; + if exit_code != 0 { + std::process::exit(exit_code); + } } SandboxCommands::Upload { name, @@ -3225,7 +3228,12 @@ async fn run_async() -> Result<()> { ) .await?; } else { - run::sandbox_connect(endpoint, &name, &tls, &cli.workspace).await?; + let exit_code = + run::sandbox_connect(endpoint, &name, &tls, &cli.workspace) + .await?; + if exit_code != 0 { + std::process::exit(exit_code); + } } let _ = save_last_sandbox(&ctx.name, &cli.workspace, &name); } diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 54dcb70f48..ae139ff665 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -224,6 +224,23 @@ fn sandbox_should_persist(keep: bool, forward: Option<&ForwardSpec>) -> bool { keep || forward.is_some() } +fn has_main_process_result(sandbox: &Sandbox) -> bool { + let Some(status) = sandbox.status.as_ref() else { + return false; + }; + if status.exit_code.is_none() { + return false; + } + + let phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown); + phase != SandboxPhase::Error + || status.conditions.iter().any(|condition| { + condition.r#type == "Ready" + && condition.status.eq_ignore_ascii_case("false") + && condition.reason == "MainProcessFailed" + }) +} + fn build_sandbox_resource_limits( cpu: Option<&str>, memory: Option<&str>, @@ -339,19 +356,21 @@ async fn finalize_sandbox_create_session( server: &str, sandbox_name: &str, persist: bool, - session_result: Result<()>, + session_result: Result, workspace: &str, tls: &TlsOptions, gateway: &str, -) -> Result<()> { +) -> Result { if persist { return session_result; } let names = [sandbox_name.to_string()]; if let Err(err) = sandbox_delete(server, &names, false, workspace, tls, gateway).await { - if session_result.is_ok() { - return Err(err); + if let Ok(exit_code) = session_result.as_ref() { + return Err(miette::miette!( + "sandbox command exited with status {exit_code}, but ephemeral cleanup failed: {err}" + )); } eprintln!("Failed to delete sandbox {sandbox_name}: {err}"); } @@ -422,7 +441,7 @@ pub async fn sandbox_create( config: SandboxCreateConfig<'_>, workspace: &str, tls: &TlsOptions, -) -> Result<()> { +) -> Result { let SandboxCreateConfig { name, from, @@ -456,6 +475,11 @@ pub async fn sandbox_create( "--upload cannot be combined with a trailing main command yet because uploads complete after the canonical process starts" )); } + if output != "table" && !command.is_empty() && !detach { + return Err(miette::miette!( + "structured output cannot be combined with an attached trailing command; use table output to stream the command or add --detach" + )); + } // Check port availability *before* creating the sandbox so we don't // leave an orphaned sandbox behind when the forward would fail. @@ -538,6 +562,20 @@ pub async fn sandbox_create( } else { command.to_vec() }; + let persist = sandbox_should_persist(keep, forward.as_ref()); + let create_detaches = detach + || (persist + && command.is_empty() + && (!std::io::stdin().is_terminal() || !std::io::stdout().is_terminal())); + let await_main_process_attachment = output == "table" && editor.is_none() && !create_detaches; + let annotations = if persist { + HashMap::new() + } else { + HashMap::from([( + "openshell.nvidia.com/retention".to_string(), + "ephemeral".to_string(), + )]) + }; let request = CreateSandboxRequest { spec: Some(SandboxSpec { resource_requirements, @@ -551,8 +589,9 @@ pub async fn sandbox_create( }), name: name.unwrap_or_default().to_string(), labels, - annotations: HashMap::new(), + annotations, workspace: workspace.to_string(), + await_main_process_attachment, }; let response = match client.create_sandbox(request).await { @@ -571,7 +610,6 @@ pub async fn sandbox_create( .ok_or_else(|| miette::miette!("sandbox missing from response"))?; let interactive = std::io::stdout().is_terminal(); - let persist = sandbox_should_persist(keep, forward.as_ref()); let sandbox_name = if sandbox.object_name().is_empty() { "unknown".to_string() } else { @@ -748,8 +786,20 @@ pub async fn sandbox_create( saw_non_ready = true; } - // Capture error reason from conditions only when phase is Error - // to avoid showing stale transient error reasons + let main_process_result = has_main_process_result(&s); + if matches!( + phase, + SandboxPhase::Completed | SandboxPhase::Error | SandboxPhase::Stopped + ) && main_process_result + { + if let Some(d) = display.as_interactive_mut() { + d.clear(); + } + break; + } + + // Capture infrastructure error reasons only after excluding a + // canonical-command result, which must attach and drain output. if phase == SandboxPhase::Error && let Some(status) = &s.status { @@ -828,7 +878,11 @@ pub async fn sandbox_create( // If we exited the loop without hitting the Ready break, finish the display. let final_phase = SandboxPhase::try_from(last_phase).unwrap_or(SandboxPhase::Unknown); - if final_phase != SandboxPhase::Ready + let final_has_main_process_result = has_main_process_result(&last_sandbox); + if !(matches!( + final_phase, + SandboxPhase::Ready | SandboxPhase::Completed | SandboxPhase::Stopped + ) || final_phase == SandboxPhase::Error && final_has_main_process_result) && let Some(d) = display.as_interactive_mut() { if final_phase == SandboxPhase::Error { @@ -941,7 +995,7 @@ pub async fn sandbox_create( if structured_output { crate::output::print_output_single(output, &last_sandbox, sandbox_to_json)?; - return Ok(()); + return Ok(0); } if let Some(editor) = editor { @@ -955,18 +1009,18 @@ pub async fn sandbox_create( workspace, ) .await?; - return Ok(()); + return Ok(0); } - // Persistent non-interactive creates detach implicitly. An - // explicitly ephemeral (`--no-keep`) create must still attach so - // it can observe the canonical process and delete the sandbox when - // that session ends. + // An explicit trailing command is foreground regardless of TTY + // detection. Only --detach opts out. Scratch shells retain the + // non-interactive implicit-detach behavior. if detach || (persist + && command.is_empty() && (!std::io::stdin().is_terminal() || !std::io::stdout().is_terminal())) { - return Ok(()); + return Ok(0); } let connect_result = if persist { @@ -992,6 +1046,32 @@ pub async fn sandbox_create( ) .await } + SandboxPhase::Completed | SandboxPhase::Stopped | SandboxPhase::Error + if final_has_main_process_result => + { + drop(stream); + drop(client); + if detach { + return Ok(0); + } + let connect_result = crate::ssh::sandbox_connect_terminal_main( + &effective_server, + &sandbox_name, + &effective_tls, + workspace, + ) + .await; + finalize_sandbox_create_session( + &effective_server, + &sandbox_name, + persist, + connect_result, + workspace, + &effective_tls, + gateway_name, + ) + .await + } SandboxPhase::Error => { drop(stream); drop(client); @@ -1326,6 +1406,9 @@ pub async fn sandbox_get( println!(" {} {}", "Id:".dimmed(), id); println!(" {} {}", "Name:".dimmed(), name); println!(" {} {}", "Phase:".dimmed(), phase_name(sandbox.phase())); + if let Some(exit_code) = sandbox.status.as_ref().and_then(|status| status.exit_code) { + println!(" {} {}", "Exit Code:".dimmed(), exit_code); + } println!( " {} {}", "Resource version:".dimmed(), @@ -2059,8 +2142,16 @@ pub async fn sandbox_list( for sandbox in sandboxes { let phase = phase_name(sandbox.phase()); let phase_colored = match SandboxPhase::try_from(sandbox.phase()) { - Ok(SandboxPhase::Ready) => phase.green().to_string(), + Ok(SandboxPhase::Ready | SandboxPhase::Completed) => phase.green().to_string(), Ok(SandboxPhase::Error) => phase.red().to_string(), + Ok(SandboxPhase::Stopped) + if sandbox + .status + .as_ref() + .is_some_and(|status| status.exit_code.is_some()) => + { + phase.red().to_string() + } Ok(SandboxPhase::Provisioning) => phase.yellow().to_string(), Ok(SandboxPhase::Deleting) => phase.dimmed().to_string(), _ => phase.to_string(), @@ -2104,6 +2195,7 @@ fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value { "created_at": format_epoch_ms(meta.map_or(0, |m| m.created_at_ms)), "phase": phase_name(sandbox.phase()), "current_policy_version": sandbox.current_policy_version(), + "exit_code": sandbox.status.as_ref().and_then(|status| status.exit_code), }) } @@ -2393,13 +2485,21 @@ pub async fn sandbox_delete( } } - let response = client + let response = match client .delete_sandbox(DeleteSandboxRequest { name: name.clone(), workspace: workspace.to_string(), }) .await - .into_diagnostic()?; + { + Ok(response) => response, + Err(status) if status.code() == Code::NotFound => { + clear_last_sandbox_if_matches(gateway, workspace, name); + println!("{} Sandbox {name} already deleted", "✓".green().bold()); + continue; + } + Err(status) => return Err(status).into_diagnostic(), + }; let deleted = response.into_inner().deleted; if deleted { @@ -2478,9 +2578,9 @@ async fn wait_for_lifecycle_phase( return Ok(sandbox); } if current == SandboxPhase::Error { - return Err(miette!( - "sandbox entered Error while waiting for {target:?}" - )); + let detail = ready_false_condition_message(sandbox.status.as_ref()) + .unwrap_or_else(|| "sandbox entered Error".to_string()); + return Err(miette!("{detail} while waiting for {target:?}")); } let timeout = Duration::from_secs( @@ -7405,13 +7505,14 @@ mod tests { use super::{ PolicyGetView, ProvisioningStep, build_sandbox_resource_limits, dockerfile_sources_supported_for_gateway, format_endpoint, - format_provider_attachment_table, git_sync_files, inferred_provider_type, - parse_cli_setting_value, parse_credential_expiry_cli_value, parse_credential_expiry_pairs, - parse_credential_pairs, parse_driver_config_json, parse_secret_material_env_pairs, - policy_revision_to_json, provider_profile_allows_empty_credentials, - provisioning_timeout_message, ready_false_condition_message, refresh_status_header, - refresh_status_row, resolve_from, sandbox_should_persist, sandbox_upload_plan, - service_expose_status_error, service_url_for_gateway, + format_provider_attachment_table, git_sync_files, has_main_process_result, + inferred_provider_type, parse_cli_setting_value, parse_credential_expiry_cli_value, + parse_credential_expiry_pairs, parse_credential_pairs, parse_driver_config_json, + parse_secret_material_env_pairs, policy_revision_to_json, + provider_profile_allows_empty_credentials, provisioning_timeout_message, + ready_false_condition_message, refresh_status_header, refresh_status_row, resolve_from, + sandbox_should_persist, sandbox_upload_plan, service_expose_status_error, + service_url_for_gateway, }; use crate::TEST_ENV_LOCK; use crate::commands::common::progress_step_from_metadata; @@ -7960,6 +8061,48 @@ mod tests { assert!(sandbox_should_persist(false, Some(&spec))); } + #[test] + fn infrastructure_error_with_observed_exit_is_not_a_main_process_result() { + let mut sandbox = Sandbox { + status: Some(SandboxStatus { + exit_code: Some(137), + conditions: vec![SandboxCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: "ComputeResourceMissing".to_string(), + message: "sandbox runtime disappeared".to_string(), + ..Default::default() + }], + ..Default::default() + }), + ..Default::default() + }; + sandbox.set_phase(SandboxPhase::Error as i32); + + assert!(!has_main_process_result(&sandbox)); + } + + #[test] + fn main_process_failed_condition_identifies_command_result() { + let mut sandbox = Sandbox { + status: Some(SandboxStatus { + exit_code: Some(7), + conditions: vec![SandboxCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: "MainProcessFailed".to_string(), + message: "canonical main process exited with status 7".to_string(), + ..Default::default() + }], + ..Default::default() + }), + ..Default::default() + }; + sandbox.set_phase(SandboxPhase::Error as i32); + + assert!(has_main_process_result(&sandbox)); + } + #[test] fn resolve_from_classifies_existing_dockerfile_path() { let temp = tempfile::tempdir().expect("failed to create tempdir"); diff --git a/crates/openshell-cli/src/ssh.rs b/crates/openshell-cli/src/ssh.rs index 4768dc27f2..1e1e48257a 100644 --- a/crates/openshell-cli/src/ssh.rs +++ b/crates/openshell-cli/src/ssh.rs @@ -29,6 +29,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tokio::process::{Child, Command as TokioCommand}; use tokio_stream::wrappers::ReceiverStream; +use tonic::Code; /// Time budget for the local listener to become reachable after `ssh` starts. /// This is a user-visible readiness deadline for both foreground and background @@ -39,6 +40,10 @@ const FORWARD_LISTENER_PROBE_INTERVAL: Duration = Duration::from_millis(50); /// Per-attempt connect timeout, so one hung probe cannot consume the whole /// grace period. const FORWARD_LISTENER_CONNECT_TIMEOUT: Duration = Duration::from_millis(200); +/// Time budget for the supervisor relay to register after a fast canonical +/// command has already reported its terminal result. +const TERMINAL_RELAY_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(5); +const TERMINAL_RELAY_REGISTRATION_INTERVAL: Duration = Duration::from_millis(50); #[derive(Clone, Copy, Debug)] pub enum Editor { @@ -79,6 +84,7 @@ async fn ssh_session_config( name: &str, tls: &TlsOptions, workspace: &str, + terminal_relay_registration_timeout: Option, ) -> Result { let mut client = grpc_client(server, tls).await?; @@ -94,12 +100,26 @@ async fn ssh_session_config( .sandbox .ok_or_else(|| miette::miette!("sandbox not found"))?; - let response = client - .create_ssh_session(CreateSshSessionRequest { - sandbox_id: sandbox.object_id().to_string(), - }) - .await - .into_diagnostic()?; + let relay_registration_deadline = + terminal_relay_registration_timeout.map(|timeout| tokio::time::Instant::now() + timeout); + let response = loop { + match client + .create_ssh_session(CreateSshSessionRequest { + sandbox_id: sandbox.object_id().to_string(), + }) + .await + { + Ok(response) => break response, + Err(status) + if status.code() == Code::FailedPrecondition + && relay_registration_deadline + .is_some_and(|deadline| tokio::time::Instant::now() < deadline) => + { + tokio::time::sleep(TERMINAL_RELAY_REGISTRATION_INTERVAL).await; + } + Err(status) => return Err(status).into_diagnostic(), + } + }; let session = response.into_inner(); validate_ssh_session_response(&session) .map_err(|err| miette::miette!("gateway returned invalid SSH session response: {err}"))?; @@ -229,7 +249,7 @@ fn reset_transient_tty_signals(command: &mut Command) { } } -fn exec_or_wait(mut command: Command, replace_process: bool) -> Result<()> { +fn exec_or_wait(mut command: Command, replace_process: bool) -> Result { if replace_process && std::io::stdin().is_terminal() { #[cfg(unix)] { @@ -248,11 +268,7 @@ fn exec_or_wait(mut command: Command, replace_process: bool) -> Result<()> { let status = command.status().into_diagnostic()?; - if !status.success() { - return Err(miette::miette!("ssh exited with status {status}")); - } - - Ok(()) + Ok(status.code().unwrap_or(1)) } async fn sandbox_connect_with_mode( @@ -261,8 +277,16 @@ async fn sandbox_connect_with_mode( tls: &TlsOptions, replace_process: bool, workspace: &str, -) -> Result<()> { - let session = ssh_session_config(server, name, tls, workspace).await?; + terminal_relay_registration_timeout: Option, +) -> Result { + let session = ssh_session_config( + server, + name, + tls, + workspace, + terminal_relay_registration_timeout, + ) + .await?; let mut command = ssh_base_command(&session.proxy_command); if session.main_terminal { @@ -280,11 +304,11 @@ async fn sandbox_connect_with_mode( .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); - tokio::task::spawn_blocking(move || exec_or_wait(command, replace_process)) + let exit_code = tokio::task::spawn_blocking(move || exec_or_wait(command, replace_process)) .await .into_diagnostic()??; - Ok(()) + Ok(exit_code) } /// Connect to a sandbox via SSH. @@ -293,8 +317,8 @@ pub async fn sandbox_connect( name: &str, tls: &TlsOptions, workspace: &str, -) -> Result<()> { - sandbox_connect_with_mode(server, name, tls, true, workspace).await +) -> Result { + sandbox_connect_with_mode(server, name, tls, true, workspace, None).await } pub(crate) async fn sandbox_connect_without_exec( @@ -302,8 +326,25 @@ pub(crate) async fn sandbox_connect_without_exec( name: &str, tls: &TlsOptions, workspace: &str, -) -> Result<()> { - sandbox_connect_with_mode(server, name, tls, false, workspace).await +) -> Result { + sandbox_connect_with_mode(server, name, tls, false, workspace, None).await +} + +pub(crate) async fn sandbox_connect_terminal_main( + server: &str, + name: &str, + tls: &TlsOptions, + workspace: &str, +) -> Result { + sandbox_connect_with_mode( + server, + name, + tls, + false, + workspace, + Some(TERMINAL_RELAY_REGISTRATION_TIMEOUT), + ) + .await } pub async fn sandbox_connect_editor( @@ -314,7 +355,7 @@ pub async fn sandbox_connect_editor( tls: &TlsOptions, workspace: &str, ) -> Result<()> { - let session = ssh_session_config(server, name, tls, workspace).await?; + let session = ssh_session_config(server, name, tls, workspace, None).await?; let workspace_root = discover_workspace_root(&session).await?; let host_alias = host_alias(name, workspace); @@ -343,7 +384,7 @@ pub async fn sandbox_forward( ) -> Result<()> { openshell_core::forward::check_port_available(spec)?; - let session = ssh_session_config(server, name, tls, workspace).await?; + let session = ssh_session_config(server, name, tls, workspace, None).await?; let mut command = TokioCommand::from(ssh_base_command(&session.proxy_command)); command @@ -558,7 +599,7 @@ async fn sandbox_exec_with_mode( return Err(miette::miette!("no command provided")); } - let session = ssh_session_config(server, name, tls, workspace).await?; + let session = ssh_session_config(server, name, tls, workspace, None).await?; let mut ssh = ssh_base_command(&session.proxy_command); if tty { @@ -771,7 +812,7 @@ async fn ssh_tar_upload( tls: &TlsOptions, workspace: &str, ) -> Result<()> { - let session = ssh_session_config(server, name, tls, workspace).await?; + let session = ssh_session_config(server, name, tls, workspace, None).await?; let dest_dir = dest_dir.unwrap_or("."); let escaped_dest = shell_escape(dest_dir); @@ -1204,7 +1245,7 @@ pub async fn sandbox_sync_down( tls: &TlsOptions, workspace: &str, ) -> Result<()> { - let session = ssh_session_config(server, name, tls, workspace).await?; + let session = ssh_session_config(server, name, tls, workspace, None).await?; let sandbox_path = resolve_sandbox_source_path(&session, sandbox_path).await?; let kind = probe_sandbox_source_kind(&session, &sandbox_path).await?; @@ -1486,7 +1527,7 @@ pub async fn sandbox_ssh_proxy_by_name( tls: &TlsOptions, workspace: &str, ) -> Result<()> { - let session = ssh_session_config(server, name, tls, workspace).await?; + let session = ssh_session_config(server, name, tls, workspace, None).await?; sandbox_ssh_proxy( &session.gateway_url, &session.sandbox_id, diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 8192989375..9999a7c083 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -88,6 +88,13 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("not used by this test server")) } + async fn finalize_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn get_current_user( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index 91d520d1af..e4735ac4c1 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -41,6 +41,13 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("not used by this test server")) } + async fn finalize_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn get_current_user( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index f4d8d0178c..84fb8f9163 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -109,6 +109,13 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("not used by this test server")) } + async fn finalize_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn get_current_user( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index bcc07619ee..118daff902 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -48,8 +48,12 @@ struct SandboxState { deleted_names: Arc>>>, create_requests: Arc>>, vm_error_after_started: Arc, + vm_error_with_observed_exit: Arc, vm_slow_progress_before_ready: Arc, vm_log_churn_before_ready: Arc, + terminal_before_relay: Arc, + ssh_session_failures_remaining: Arc, + ssh_session_requests: Arc, global_settings: Arc>>, gateway_config_requests: Arc, } @@ -68,6 +72,13 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("not used by this test server")) } + async fn finalize_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn get_current_user( &self, _request: tonic::Request, @@ -236,6 +247,19 @@ impl OpenShell for TestOpenShell { &self, request: tonic::Request, ) -> Result, Status> { + self.state + .ssh_session_requests + .fetch_add(1, Ordering::SeqCst); + if self + .state + .ssh_session_failures_remaining + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + remaining.checked_sub(1) + }) + .is_ok() + { + return Err(Status::failed_precondition("sandbox is not ready")); + } let sandbox_id = request.into_inner().sandbox_id; Ok(Response::new(CreateSshSessionResponse { sandbox_id, @@ -409,11 +433,16 @@ impl OpenShell for TestOpenShell { let sandbox_id = request.into_inner().id; let (tx, rx) = mpsc::channel(4); let vm_error_after_started = self.state.vm_error_after_started.load(Ordering::SeqCst); + let vm_error_with_observed_exit = self + .state + .vm_error_with_observed_exit + .load(Ordering::SeqCst); let vm_slow_progress_before_ready = self .state .vm_slow_progress_before_ready .load(Ordering::SeqCst); let vm_log_churn_before_ready = self.state.vm_log_churn_before_ready.load(Ordering::SeqCst); + let terminal_before_relay = self.state.terminal_before_relay.load(Ordering::SeqCst); tokio::spawn(async move { let mut provisioning = Sandbox { @@ -445,8 +474,17 @@ impl OpenShell for TestOpenShell { ..provisioning.clone() }; error.set_phase(SandboxPhase::Error as i32); + if vm_error_with_observed_exit { + error.status.as_mut().unwrap().exit_code = Some(137); + } let mut ready = provisioning.clone(); ready.set_phase(SandboxPhase::Ready as i32); + let mut completed = provisioning.clone(); + completed.status = Some(SandboxStatus { + exit_code: Some(0), + ..SandboxStatus::default() + }); + completed.set_phase(SandboxPhase::Completed as i32); let _ = tx .send(Ok(SandboxStreamEvent { @@ -496,6 +534,14 @@ impl OpenShell for TestOpenShell { .await; return; } + if terminal_before_relay { + let _ = tx + .send(Ok(SandboxStreamEvent { + payload: Some(sandbox_stream_event::Payload::Sandbox(completed)), + })) + .await; + return; + } if vm_slow_progress_before_ready { tokio::time::sleep(Duration::from_millis(600)).await; let _ = tx @@ -1341,6 +1387,35 @@ async fn sandbox_create_persists_exact_trailing_argv_as_main_process() { .expect("sandbox spec should be persisted at create time"); assert_eq!(spec.command, command); assert!(!spec.tty); + assert!(requests[0].await_main_process_attachment); +} + +#[tokio::test] +async fn detached_command_does_not_declare_main_process_attachment() { + let server = run_server().await; + let fake_ssh_dir = tempfile::tempdir().unwrap(); + let xdg_dir = tempfile::tempdir().unwrap(); + let _env = test_env(&fake_ssh_dir, &xdg_dir); + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("detached-main"), + command: &["echo".into(), "OK".into()], + detach: true, + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("detached sandbox create should succeed"); + + let requests = create_requests(&server).await; + assert!(!requests[0].await_main_process_attachment); } #[tokio::test] @@ -1558,6 +1633,44 @@ async fn sandbox_create_returns_vm_error_without_waiting_for_timeout() { assert!(!rendered.contains("timed out")); } +#[tokio::test] +async fn sandbox_create_preserves_vm_error_when_exit_code_is_observed() { + let server = run_server().await; + server + .openshell + .state + .vm_error_after_started + .store(true, Ordering::SeqCst); + server + .openshell + .state + .vm_error_with_observed_exit + .store(true, Ordering::SeqCst); + let fake_ssh_dir = tempfile::tempdir().unwrap(); + let xdg_dir = tempfile::tempdir().unwrap(); + let _env = test_env(&fake_ssh_dir, &xdg_dir); + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + let err = run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("vm-error-with-exit"), + command: &["echo".into(), "OK".into()], + ..test_config() + }, + "default", + &tls, + ) + .await + .expect_err("an observed process exit must not hide the infrastructure error"); + + let rendered = err.to_string(); + assert!(rendered.contains("sandbox entered error phase while provisioning")); + assert!(rendered.contains("ProcessExited: VM process exited with status 0")); +} + #[tokio::test] async fn sandbox_create_keeps_waiting_while_vm_progress_arrives() { let server = run_server().await; @@ -1631,6 +1744,50 @@ async fn sandbox_create_times_out_when_only_logs_arrive() { assert!(err.to_string().contains("sandbox provisioning timed out")); } +#[tokio::test] +async fn sandbox_create_retries_terminal_attachment_until_relay_registers() { + let server = run_server().await; + server + .openshell + .state + .terminal_before_relay + .store(true, Ordering::SeqCst); + server + .openshell + .state + .ssh_session_failures_remaining + .store(1, Ordering::SeqCst); + let fake_ssh_dir = tempfile::tempdir().unwrap(); + let xdg_dir = tempfile::tempdir().unwrap(); + let _env = test_env(&fake_ssh_dir, &xdg_dir); + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + let exit_code = run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("fast-command"), + command: &["echo".into(), "OK".into()], + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("sandbox create should wait for the declared terminal attachment relay"); + + assert_eq!(exit_code, 0); + assert_eq!( + server + .openshell + .state + .ssh_session_requests + .load(Ordering::SeqCst), + 2 + ); +} + #[tokio::test] async fn sandbox_create_deletes_command_sessions_with_no_keep() { let server = run_server().await; @@ -1659,6 +1816,14 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() { deleted_names(&server).await, vec![vec!["ephemeral-command".to_string()]] ); + let requests = create_requests(&server).await; + assert_eq!( + requests[0] + .annotations + .get("openshell.nvidia.com/retention") + .map(String::as_str), + Some("ephemeral") + ); assert_eq!( load_last_sandbox("openshell", "default"), None, @@ -1666,6 +1831,37 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() { ); } +#[tokio::test] +async fn sandbox_create_returns_exact_main_status_after_no_keep_cleanup() { + let server = run_server().await; + let fake_ssh_dir = tempfile::tempdir().unwrap(); + let xdg_dir = tempfile::tempdir().unwrap(); + let _env = test_env(&fake_ssh_dir, &xdg_dir); + let tls = test_tls(&server); + install_executable_script(&fake_ssh_dir, "ssh", "#!/bin/sh\nexit 7\n"); + + let exit_code = run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("ephemeral-failure"), + keep: false, + command: &["sh".into(), "-c".into(), "exit 7".into()], + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("a main-process failure is a command result, not a cleanup error"); + + assert_eq!(exit_code, 7); + assert_eq!( + deleted_names(&server).await, + vec![vec!["ephemeral-failure".to_string()]] + ); +} + #[tokio::test] async fn sandbox_create_deletes_shell_sessions_with_no_keep() { let server = run_server().await; diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index 7e2cf74f50..ee72728aa9 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -56,6 +56,13 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("not used by this test server")) } + async fn finalize_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn get_current_user( &self, _request: tonic::Request, diff --git a/crates/openshell-core/src/sandbox_env.rs b/crates/openshell-core/src/sandbox_env.rs index 40a7f0a72f..8da9a7d2a8 100644 --- a/crates/openshell-core/src/sandbox_env.rs +++ b/crates/openshell-core/src/sandbox_env.rs @@ -40,6 +40,8 @@ pub struct MainProcessConfig { pub version: u32, pub command: Vec, pub tty: bool, + #[serde(default)] + pub await_main_process_attachment: bool, } impl MainProcessConfig { @@ -51,6 +53,7 @@ impl MainProcessConfig { version: Self::VERSION, command: vec!["/bin/bash".to_string(), "-l".to_string()], tty: true, + await_main_process_attachment: false, } } @@ -61,6 +64,7 @@ impl MainProcessConfig { version: Self::VERSION, command: spec.command.clone(), tty: spec.tty, + await_main_process_attachment: spec.await_main_process_attachment, }, None | Some(_) => Self::scratch(), } @@ -233,12 +237,14 @@ mod tests { let spec = crate::proto::compute::v1::DriverSandboxSpec { command: vec!["/bin/sh".into(), "-c".into(), "printf '%s' 'a b'".into()], tty: false, + await_main_process_attachment: true, ..Default::default() }; let encoded = MainProcessConfig::encode_driver_spec(Some(&spec)).unwrap(); let decoded = MainProcessConfig::decode(&encoded).unwrap(); assert_eq!(decoded.command, spec.command); assert!(!decoded.tty); + assert!(decoded.await_main_process_attachment); } #[test] diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index b52cb87836..fb5bc61e5c 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -47,6 +47,7 @@ fn test_sandbox() -> DriverSandbox { sandbox_token: String::new(), command: Vec::new(), tty: false, + await_main_process_attachment: false, }), status: None, workspace: String::new(), diff --git a/crates/openshell-gateway-interceptors/src/proto_json.rs b/crates/openshell-gateway-interceptors/src/proto_json.rs index f6aecbcf67..b0d6b0f53d 100644 --- a/crates/openshell-gateway-interceptors/src/proto_json.rs +++ b/crates/openshell-gateway-interceptors/src/proto_json.rs @@ -316,6 +316,7 @@ mod tests { labels: HashMap::from([("team".to_string(), "agent".to_string())]), annotations: HashMap::new(), workspace: String::new(), + await_main_process_attachment: false, }; let bytes = request.encode_to_vec(); let json = codec diff --git a/crates/openshell-gateway-interceptors/src/runtime.rs b/crates/openshell-gateway-interceptors/src/runtime.rs index 4e7f5ba613..02afddab75 100644 --- a/crates/openshell-gateway-interceptors/src/runtime.rs +++ b/crates/openshell-gateway-interceptors/src/runtime.rs @@ -1075,6 +1075,7 @@ mod tests { labels: HashMap::new(), annotations: HashMap::new(), workspace: String::new(), + await_main_process_attachment: false, }; let bytes = request.encode_to_vec(); diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index b1c226cebd..e70aac7a9f 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -103,6 +103,7 @@ pub async fn run_sandbox( workdir: Option, timeout_secs: u64, interactive: bool, + await_main_process_attachment: bool, sandbox_id: Option, sandbox: Option, openshell_endpoint: Option, @@ -889,35 +890,54 @@ pub async fn run_sandbox( } else { None }; - let sidecar_exit_tx = - if process_uses_sidecar_control && let Some(writer) = process_control_writer.clone() { - let exit_ack = Arc::clone(&process_exit_ack); - let (tx, mut rx) = tokio::sync::mpsc::channel::< - openshell_supervisor_process::run::SidecarExitReport, - >(1); - tokio::spawn(async move { - while let Some((instance_id, exit_code, ack)) = rx.recv().await { - let (durable_tx, durable_rx) = tokio::sync::oneshot::channel(); - *exit_ack.lock().await = Some((instance_id.clone(), durable_tx)); - let result = match sidecar_control::send_main_process_exited( - &writer, + let sidecar_exit_tx = if process_uses_sidecar_control + && let Some(writer) = process_control_writer.clone() + { + let exit_ack = Arc::clone(&process_exit_ack); + let (tx, mut rx) = tokio::sync::mpsc::channel::< + openshell_supervisor_process::run::SidecarExitReport, + >(1); + tokio::spawn(async move { + while let Some(report) = rx.recv().await { + match report { + openshell_supervisor_process::run::SidecarExitReport::Exited { instance_id, exit_code, - ) - .await - { - Ok(()) => durable_rx.await.map_err(|_| { - "sidecar durable exit acknowledgement closed".to_string() - }), - Err(error) => Err(error.to_string()), - }; - let _ = ack.send(result); + ack, + } => { + let (durable_tx, durable_rx) = tokio::sync::oneshot::channel(); + *exit_ack.lock().await = Some((instance_id.clone(), durable_tx)); + let result = match sidecar_control::send_main_process_exited( + &writer, + instance_id, + exit_code, + ) + .await + { + Ok(()) => durable_rx.await.map_err(|_| { + "sidecar durable exit acknowledgement closed".to_string() + }), + Err(error) => Err(error.to_string()), + }; + let _ = ack.send(result); + } + openshell_supervisor_process::run::SidecarExitReport::Finalized { + instance_id, + ack, + } => { + let result = + sidecar_control::send_main_process_finalized(&writer, instance_id) + .await + .map_err(|error| error.to_string()); + let _ = ack.send(result); + } } - }); - Some(tx) - } else { - None - }; + } + }); + Some(tx) + } else { + None + }; let process = openshell_supervisor_process::run::run_process( program, @@ -925,6 +945,7 @@ pub async fn run_sandbox( workspace, timeout_secs, interactive, + await_main_process_attachment, sandbox_id.as_deref(), openshell_endpoint.as_deref(), ssh_socket_path, @@ -1313,11 +1334,39 @@ fn spawn_sidecar_entrypoint_handler( control_publisher, } = handler; let mut session_started = false; + let mut session_task: Option> = None; let mut trusted_supervisor_pid = None; let terminating = Arc::new(AtomicBool::new(false)); while let Some(started) = entrypoint_rx.recv().await { - if let Some(exit_code) = started.exit_code { + if started.finalized { + if let (Some(endpoint), Some(id)) = + (openshell_endpoint.as_ref(), sandbox_id.as_ref()) + { + let mut delay = Duration::from_millis(250); + loop { + match openshell_supervisor_process::supervisor_session::finalize_main_process_exit( + endpoint, + id, + &started.instance_id, + ) + .await + { + Ok(()) => break, + Err(error) => { + warn!(%error, "sidecar main-process finalization failed; retrying"); + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_secs(2)); + } + } + } + } terminating.store(true, Ordering::Release); + if let Some(task) = session_task.take() { + task.abort(); + } + break; + } + if let Some(exit_code) = started.exit_code { if let (Some(endpoint), Some(id)) = (openshell_endpoint.as_ref(), sandbox_id.as_ref()) { @@ -1343,7 +1392,7 @@ fn spawn_sidecar_entrypoint_handler( publisher.publish_main_process_exit_ack(started.instance_id.clone()); } } - break; + continue; } entrypoint_pid.store(started.pid, Ordering::Release); if started.start_session { @@ -1386,7 +1435,7 @@ fn spawn_sidecar_entrypoint_handler( ); continue; }; - openshell_supervisor_process::supervisor_session::spawn( + session_task = Some(openshell_supervisor_process::supervisor_session::spawn( endpoint.clone(), id.clone(), trusted_ssh_socket_path.clone(), @@ -1394,7 +1443,7 @@ fn spawn_sidecar_entrypoint_handler( Some(supervisor_pid), Arc::clone(&terminating), started.instance_id.clone(), - ); + )); session_started = true; info!("sidecar supervisor session task spawned"); } diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 6d244fb6bc..7108378654 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -659,15 +659,23 @@ fn main() -> Result<()> { // drivers otherwise provide a versioned JSON transport so argument // boundaries are never reconstructed with shell parsing. let workdir = args.workdir.clone(); - let (command, interactive) = if !args.command.is_empty() { - (args.command, args.interactive) + let (command, interactive, await_main_process_attachment) = if !args.command.is_empty() { + (args.command, args.interactive, false) } else if let Ok(json) = std::env::var(openshell_core::sandbox_env::MAIN_PROCESS_SPEC) { let config = openshell_core::sandbox_env::MainProcessConfig::decode(&json) .map_err(|error| miette::miette!("{error}"))?; - (config.command, config.tty) + ( + config.command, + config.tty, + config.await_main_process_attachment, + ) } else { let config = openshell_core::sandbox_env::MainProcessConfig::scratch(); - (config.command, config.tty) + ( + config.command, + config.tty, + config.await_main_process_attachment, + ) }; info!(command = ?command, "Starting sandbox"); @@ -689,6 +697,7 @@ fn main() -> Result<()> { workdir, args.timeout, interactive, + await_main_process_attachment, args.sandbox_id, args.sandbox, args.openshell_endpoint, diff --git a/crates/openshell-sandbox/src/sidecar_control.rs b/crates/openshell-sandbox/src/sidecar_control.rs index 658a20132c..fdf88f050b 100644 --- a/crates/openshell-sandbox/src/sidecar_control.rs +++ b/crates/openshell-sandbox/src/sidecar_control.rs @@ -38,6 +38,7 @@ pub struct EntrypointStarted { pub start_session: bool, pub instance_id: String, pub exit_code: Option, + pub finalized: bool, } #[derive(Debug, Clone, Copy)] @@ -176,6 +177,7 @@ enum WireClientMessage { BootstrapRequest { supervisor_pid: u32 }, EntrypointStarted { pid: u32, instance_id: String }, MainProcessExited { instance_id: String, exit_code: i32 }, + MainProcessFinalized { instance_id: String }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -466,12 +468,14 @@ async fn handle_connection( start_session: false, instance_id: String::new(), exit_code: None, + finalized: false, }) .await .map_err(|_| miette::miette!("sidecar entrypoint receiver closed"))?; } WireClientMessage::EntrypointStarted { .. } - | WireClientMessage::MainProcessExited { .. } => { + | WireClientMessage::MainProcessExited { .. } + | WireClientMessage::MainProcessFinalized { .. } => { return Err(miette::miette!( "sidecar control client sent entrypoint event before bootstrap" )); @@ -509,6 +513,7 @@ async fn handle_connection( start_session: true, instance_id, exit_code: None, + finalized: false, }) .await .map_err(|_| miette::miette!("sidecar entrypoint receiver closed"))?; @@ -523,6 +528,19 @@ async fn handle_connection( start_session: false, instance_id, exit_code: Some(exit_code), + finalized: false, + }) + .await + .map_err(|_| miette::miette!("sidecar entrypoint receiver closed"))?; + } + WireClientMessage::MainProcessFinalized { instance_id } => { + entrypoint_tx + .send(EntrypointStarted { + pid: 0, + start_session: false, + instance_id, + exit_code: None, + finalized: true, }) .await .map_err(|_| miette::miette!("sidecar entrypoint receiver closed"))?; @@ -640,6 +658,15 @@ pub async fn send_main_process_exited( write_json_line(&mut *writer, &message).await } +pub async fn send_main_process_finalized( + writer: &Arc>, + instance_id: String, +) -> Result<()> { + let message = WireClientMessage::MainProcessFinalized { instance_id }; + let mut writer = writer.lock().await; + write_json_line(&mut *writer, &message).await +} + async fn write_json_line(writer: &mut W, value: &T) -> Result<()> where W: AsyncWrite + Unpin + Send, @@ -893,6 +920,7 @@ mod tests { .unwrap() .unwrap(); assert_eq!(terminal.exit_code, Some(0)); + assert!(!terminal.finalized); assert!( tokio::time::timeout(Duration::from_millis(20), connection.updates.recv()) @@ -909,6 +937,16 @@ mod tests { ack, ControlUpdate::MainProcessExitAck { instance_id } if instance_id == "instance-1" )); + + send_main_process_finalized(&connection.writer, "instance-1".to_string()) + .await + .unwrap(); + let delivered = tokio::time::timeout(Duration::from_secs(1), entrypoint_rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(delivered.exit_code.is_none()); + assert!(delivered.finalized); } #[tokio::test] diff --git a/crates/openshell-sdk/src/client.rs b/crates/openshell-sdk/src/client.rs index f95b7ee111..fd2b7d5273 100644 --- a/crates/openshell-sdk/src/client.rs +++ b/crates/openshell-sdk/src/client.rs @@ -246,15 +246,19 @@ impl OpenShellClient { } /// Poll [`OpenShellClient::get_sandbox`] until the sandbox reaches - /// [`SandboxPhase::Ready`] or the `timeout` elapses. + /// [`SandboxPhase::Ready`] or successful [`SandboxPhase::Completed`], or + /// until the `timeout` elapses. /// /// Returns the terminal sandbox snapshot on success. Returns an /// [`SdkError::Connect`] when the timeout expires, or whatever error /// the gateway returns if the sandbox transitions into - /// [`SandboxPhase::Error`]. + /// [`SandboxPhase::Stopped`] or [`SandboxPhase::Error`]. pub async fn wait_ready(&self, name: &str, timeout: Duration) -> Result { self.wait_for(name, timeout, |phase| match phase { - SandboxPhase::Ready => Some(Ok(())), + SandboxPhase::Ready | SandboxPhase::Completed => Some(Ok(())), + SandboxPhase::Stopped => Some(Err(SdkError::connect(format!( + "sandbox '{name}' main process failed" + )))), SandboxPhase::Error => Some(Err(SdkError::connect(format!( "sandbox '{name}' entered error phase" )))), @@ -644,15 +648,22 @@ impl WorkspaceScopedClient { sandbox_from_response(response.sandbox) } - /// Poll until the sandbox reaches [`SandboxPhase::Ready`] or the timeout - /// elapses. + /// Poll until the sandbox reaches [`SandboxPhase::Ready`] or successful + /// [`SandboxPhase::Completed`], or the timeout elapses. pub async fn wait_ready(&self, name: &str, timeout: Duration) -> Result { let deadline = Instant::now() + timeout; let mut delay = Duration::from_millis(250); loop { let snapshot = self.get_sandbox(name).await?; match snapshot.phase { - SandboxPhase::Ready => return Ok(snapshot), + SandboxPhase::Ready | SandboxPhase::Completed => return Ok(snapshot), + SandboxPhase::Stopped => { + let detail = snapshot.exit_code.map_or_else( + || "stopped before becoming ready".to_string(), + |code| format!("main process failed with status {code}"), + ); + return Err(SdkError::connect(format!("sandbox '{name}' {detail}"))); + } SandboxPhase::Error => { return Err(SdkError::connect(format!( "sandbox '{name}' entered error phase" @@ -823,6 +834,7 @@ fn create_sandbox_request(spec: SandboxSpec) -> proto::CreateSandboxRequest { labels, annotations: HashMap::new(), workspace: String::new(), + await_main_process_attachment: false, } } diff --git a/crates/openshell-sdk/src/types.rs b/crates/openshell-sdk/src/types.rs index 3715c7fdd6..f7f1547115 100644 --- a/crates/openshell-sdk/src/types.rs +++ b/crates/openshell-sdk/src/types.rs @@ -65,6 +65,7 @@ pub enum SandboxPhase { Stopping, Stopped, Starting, + Completed, } impl From for SandboxPhase { @@ -79,6 +80,7 @@ impl From for SandboxPhase { proto::SandboxPhase::Stopping => Self::Stopping, proto::SandboxPhase::Stopped => Self::Stopped, proto::SandboxPhase::Starting => Self::Starting, + proto::SandboxPhase::Completed => Self::Completed, } } } diff --git a/crates/openshell-sdk/tests/client_mock.rs b/crates/openshell-sdk/tests/client_mock.rs index 4b06b9c1e5..1cdac7da41 100644 --- a/crates/openshell-sdk/tests/client_mock.rs +++ b/crates/openshell-sdk/tests/client_mock.rs @@ -107,6 +107,13 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("not used by this test server")) } + async fn finalize_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn get_current_user( &self, _request: tonic::Request, @@ -908,6 +915,41 @@ async fn wait_ready_transitions_through_phases() { assert!(state.get_calls.load(Ordering::SeqCst) >= 3); } +#[tokio::test] +async fn wait_ready_accepts_successful_completion() { + let state = Arc::new(MockState { + phase_sequence: vec![ + proto::SandboxPhase::Provisioning, + proto::SandboxPhase::Completed, + ], + ..Default::default() + }); + let endpoint = start_mock(state).await; + let client = connect(&endpoint).await; + + let sandbox = client + .wait_ready("short-job", std::time::Duration::from_secs(5)) + .await + .unwrap(); + assert_eq!(sandbox.phase, SandboxPhase::Completed); +} + +#[tokio::test] +async fn wait_ready_surfaces_stopped_phase_without_timing_out() { + let state = Arc::new(MockState { + phase_sequence: vec![proto::SandboxPhase::Stopped], + ..Default::default() + }); + let endpoint = start_mock(state).await; + let client = connect(&endpoint).await; + + let err = client + .wait_ready("failed-job", std::time::Duration::from_secs(5)) + .await + .unwrap_err(); + assert_eq!(err.code(), "connect"); +} + #[tokio::test] async fn wait_ready_surfaces_error_phase() { let state = Arc::new(MockState { diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index b85641c986..ef311523a9 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -941,6 +941,7 @@ impl ComputeRuntime { &self, sandbox: Sandbox, sandbox_token: Option, + await_main_process_attachment: bool, ) -> Result { let sandbox_id = sandbox.object_id().to_string(); let mut driver_sandbox = driver_sandbox_from_public(&sandbox, &self.driver_info.name) @@ -989,6 +990,9 @@ impl ComputeRuntime { { spec.sandbox_token = token; } + if let Some(spec) = driver_sandbox.spec.as_mut() { + spec.await_main_process_attachment = await_main_process_attachment; + } // A1: stage the typed SandboxPolicy out-of-band into the MXC backend's // side channel, keyed by sandbox id (== DriverSandbox.id), immediately // before dispatch. The driver removes/consumes it in create_sandbox. The @@ -1079,7 +1083,9 @@ impl ComputeRuntime { } let phase = SandboxPhase::try_from(current.phase()).unwrap_or(SandboxPhase::Unknown); - if phase == SandboxPhase::Stopped { + if matches!(phase, SandboxPhase::Stopped | SandboxPhase::Completed) + || is_failed_main_process_result(¤t) + { self.cleanup_stopped_sandbox_sessions(¤t) .await .map_err(Status::internal)?; @@ -1237,12 +1243,22 @@ impl ComputeRuntime { if phase == SandboxPhase::Ready { return Ok(current); } - if !matches!(phase, SandboxPhase::Stopped | SandboxPhase::Starting) { + if !matches!( + phase, + SandboxPhase::Stopped | SandboxPhase::Completed | SandboxPhase::Starting + ) && !is_failed_main_process_result(¤t) + { return Err(Status::failed_precondition(format!( - "sandbox must be Stopped to start (current phase: {phase:?})" + "sandbox must be Stopped, Completed, or a failed main-process Error to start (current phase: {phase:?})" ))); } + if phase == SandboxPhase::Completed || is_failed_main_process_result(¤t) { + self.cleanup_stopped_sandbox_sessions(¤t) + .await + .map_err(Status::internal)?; + } + let (previous, starting) = if phase == SandboxPhase::Starting { // Acquiring the lifecycle gate proves that no local worker still // owns this transition. Retry the idempotent driver operation. @@ -2335,11 +2351,16 @@ impl ComputeRuntime { }; let phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown); match phase { - SandboxPhase::Stopped => { + SandboxPhase::Stopped | SandboxPhase::Completed => { if let Err(err) = self.cleanup_stopped_sandbox_sessions(&sandbox).await { warn!(sandbox_id = %sandbox.object_id(), error = %err, "Failed to complete recovered sandbox session cleanup"); } } + SandboxPhase::Error if is_failed_main_process_result(&sandbox) => { + if let Err(err) = self.cleanup_stopped_sandbox_sessions(&sandbox).await { + warn!(sandbox_id = %sandbox.object_id(), error = %err, "Failed to complete recovered failed-main session cleanup"); + } + } SandboxPhase::Stopping => { let sandbox_id = sandbox.object_id().to_string(); let sandbox_name = sandbox.object_name().to_string(); @@ -2857,12 +2878,16 @@ impl ComputeRuntime { sandbox_id: &str, instance_id: &str, ) -> Result<(), String> { - self.set_supervisor_session_state(sandbox_id, true, Some(instance_id)) + self.set_supervisor_session_state(sandbox_id, true, Some(instance_id), false) .await } - pub async fn supervisor_session_disconnected(&self, sandbox_id: &str) -> Result<(), String> { - self.set_supervisor_session_state(sandbox_id, false, None) + pub async fn supervisor_session_disconnected( + &self, + sandbox_id: &str, + terminal_delivery_finalized: bool, + ) -> Result<(), String> { + self.set_supervisor_session_state(sandbox_id, false, None, terminal_delivery_finalized) .await } @@ -2871,8 +2896,9 @@ impl ComputeRuntime { sandbox_id: &str, connected: bool, instance_id: Option<&str>, + terminal_delivery_finalized: bool, ) -> Result<(), String> { - let _guard = self.sync_lock.lock().await; + let guard = self.sync_lock.lock().await; let Some(existing) = self .store @@ -2884,12 +2910,21 @@ impl ComputeRuntime { }; let current_phase = SandboxPhase::try_from(existing.phase()).unwrap_or(SandboxPhase::Unknown); + if !connected + && matches!(current_phase, SandboxPhase::Error | SandboxPhase::Completed) + && terminal_delivery_finalized + { + drop(guard); + self.schedule_ephemeral_sandbox_delete(&existing); + return Ok(()); + } if matches!( current_phase, SandboxPhase::Deleting | SandboxPhase::Error | SandboxPhase::Stopping | SandboxPhase::Stopped + | SandboxPhase::Completed ) { return Ok(()); } @@ -2941,13 +2976,23 @@ impl ComputeRuntime { Ok(()) } - /// Persist a terminal canonical-process result. Exit code zero is still a - /// sandbox error because the canonical process defines sandbox health. + /// Persist a terminal canonical-process result. Successful completion is + /// distinct from a nonzero command result and from infrastructure error. pub async fn main_process_exited( &self, sandbox_id: &str, instance_id: &str, exit_code: i32, + ) -> Result<(), String> { + self.report_main_process_exit(sandbox_id, instance_id, exit_code) + .await + } + + pub async fn report_main_process_exit( + &self, + sandbox_id: &str, + instance_id: &str, + exit_code: i32, ) -> Result<(), String> { let _guard = self.sync_lock.lock().await; let Some(existing) = self @@ -2999,6 +3044,7 @@ impl ComputeRuntime { reported_exit_code = exit_code, "ignoring conflicting duplicate main-process exit report" ); + return Ok(()); } return Ok(()); } @@ -3016,6 +3062,60 @@ impl ComputeRuntime { Ok(()) } + /// Permit cleanup after the main-process terminal transport has finished. + pub async fn finalize_main_process_exit( + &self, + sandbox_id: &str, + instance_id: &str, + ) -> Result<(), String> { + let _guard = self.sync_lock.lock().await; + let Some(sandbox) = self + .store + .get_message::(sandbox_id) + .await + .map_err(|error| error.to_string())? + else { + return Ok(()); + }; + let Some(status) = sandbox.status.as_ref() else { + return Err("main-process exit has not been reported".to_string()); + }; + if status.exit_code.is_none() { + return Err("main-process exit has not been reported".to_string()); + } + if !status.main_process_instance_id.is_empty() + && status.main_process_instance_id != instance_id + { + return Err("main-process instance does not match the terminal result".to_string()); + } + Ok(()) + } + + fn schedule_ephemeral_sandbox_delete(&self, sandbox: &Sandbox) { + let ephemeral = sandbox.metadata.as_ref().is_some_and(|metadata| { + metadata + .annotations + .get("openshell.nvidia.com/retention") + .is_some_and(|value| value == "ephemeral") + }); + if !ephemeral { + return; + } + + let runtime = self.clone(); + let workspace = sandbox.object_workspace().to_string(); + let name = sandbox.object_name().to_string(); + tokio::spawn(async move { + if let Err(error) = runtime.delete_sandbox(&workspace, &name).await { + tracing::warn!( + sandbox_name = %name, + error = %error, + "Failed to delete completed ephemeral sandbox" + ); + } + }); + } + async fn apply_deleted(&self, sandbox_id: &str) -> Result<(), String> { let _guard = self.sync_lock.lock().await; self.apply_deleted_locked(sandbox_id).await @@ -3283,6 +3383,11 @@ impl ComputeRuntime { let sandbox = decode_sandbox_record(¤t_record)?; let phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown); + if phase == SandboxPhase::Completed || is_failed_main_process_result(&sandbox) { + // A terminal canonical process may legitimately have removed its + // transient compute object. Keep the durable command result. + return Ok(()); + } if matches!( phase, SandboxPhase::Stopping | SandboxPhase::Stopped | SandboxPhase::Starting @@ -3372,24 +3477,53 @@ impl ComputeRuntime { fn apply_main_process_exit(sandbox: &mut Sandbox, instance_id: &str, exit_code: i32) { let sandbox_name = sandbox.object_name().to_string(); + let preserve_infrastructure_error = sandbox.phase() == SandboxPhase::Error as i32; let status = sandbox.status.get_or_insert_with(|| SandboxStatus { sandbox_name: sandbox_name.clone(), ..Default::default() }); status.main_process_instance_id = instance_id.to_string(); status.exit_code = Some(exit_code); + if preserve_infrastructure_error { + return; + } + let (phase, reason, message) = if exit_code == 0 { + ( + SandboxPhase::Completed, + "MainProcessCompleted", + "Canonical main process completed successfully".to_string(), + ) + } else { + ( + SandboxPhase::Error, + "MainProcessFailed", + format!("Canonical main process exited with status {exit_code}"), + ) + }; upsert_ready_condition( &mut sandbox.status, &sandbox_name, SandboxCondition { r#type: "Ready".to_string(), status: "False".to_string(), - reason: "MainProcessExited".to_string(), - message: "Canonical main process exited".to_string(), + reason: reason.to_string(), + message, last_transition_time: String::new(), }, ); - sandbox.set_phase(SandboxPhase::Error as i32); + sandbox.set_phase(phase as i32); +} + +fn is_failed_main_process_result(sandbox: &Sandbox) -> bool { + sandbox.phase() == SandboxPhase::Error as i32 + && sandbox.status.as_ref().is_some_and(|status| { + status.exit_code.is_some() + && status.conditions.iter().any(|condition| { + condition.r#type == "Ready" + && condition.status.eq_ignore_ascii_case("false") + && condition.reason == "MainProcessFailed" + }) + }) } /// Connect to an unmanaged remote compute driver that is already listening on @@ -3486,6 +3620,7 @@ fn driver_sandbox_spec_from_public( sandbox_token: String::new(), command: spec.command.clone(), tty: spec.tty, + await_main_process_attachment: false, }) } @@ -3766,10 +3901,10 @@ fn apply_driver_snapshot(sandbox: &mut Sandbox, incoming: &DriverSandbox, sessio let old_phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown); let sandbox_name = &incoming.name; - // Error is terminal until an explicit future lifecycle operation changes - // desired state. In particular, a still-running backend snapshot must not - // revive a sandbox whose canonical process has exited. - if old_phase == SandboxPhase::Error { + // Infrastructure errors and successful main-process completions are + // sticky until an explicit lifecycle operation changes desired state. A + // late backend snapshot must not revive either result. + if matches!(old_phase, SandboxPhase::Error | SandboxPhase::Completed) { if let Some(metadata) = sandbox.metadata.as_mut() { metadata.name.clone_from(sandbox_name); } @@ -3816,6 +3951,7 @@ fn apply_driver_snapshot(sandbox: &mut Sandbox, incoming: &DriverSandbox, sessio } SandboxPhase::Stopping if phase != SandboxPhase::Error => SandboxPhase::Stopping, SandboxPhase::Stopped => SandboxPhase::Stopped, + SandboxPhase::Completed => SandboxPhase::Completed, SandboxPhase::Starting if !matches!(phase, SandboxPhase::Ready | SandboxPhase::Error) => { SandboxPhase::Starting } @@ -5044,13 +5180,13 @@ mod tests { } #[test] - fn main_process_exit_zero_is_terminal_error() { + fn main_process_exit_zero_is_completed() { let mut sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); apply_main_process_exit(&mut sandbox, "instance-1", 0); assert_eq!( SandboxPhase::try_from(sandbox.phase()), - Ok(SandboxPhase::Error) + Ok(SandboxPhase::Completed) ); let status = sandbox.status.as_ref().unwrap(); assert_eq!(status.exit_code, Some(0)); @@ -5058,7 +5194,26 @@ mod tests { assert!(status.conditions.iter().any(|condition| { condition.r#type == "Ready" && condition.status == "False" - && condition.reason == "MainProcessExited" + && condition.reason == "MainProcessCompleted" + })); + } + + #[test] + fn main_process_nonzero_exit_is_error() { + let mut sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); + apply_main_process_exit(&mut sandbox, "instance-1", 7); + + assert_eq!( + SandboxPhase::try_from(sandbox.phase()), + Ok(SandboxPhase::Error) + ); + let status = sandbox.status.as_ref().unwrap(); + assert_eq!(status.exit_code, Some(7)); + assert_eq!(status.main_process_instance_id, "instance-1"); + assert!(status.conditions.iter().any(|condition| { + condition.r#type == "Ready" + && condition.status == "False" + && condition.reason == "MainProcessFailed" })); } @@ -5126,6 +5281,55 @@ mod tests { assert_eq!(stored.status.unwrap().exit_code, Some(9)); } + #[tokio::test] + async fn ephemeral_cleanup_waits_for_terminal_finalization() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let mut sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Provisioning); + sandbox.metadata.as_mut().unwrap().annotations.insert( + "openshell.nvidia.com/retention".to_string(), + "ephemeral".to_string(), + ); + runtime.store.put_message(&sandbox).await.unwrap(); + runtime + .supervisor_session_connected("sb-1", "instance-1") + .await + .unwrap(); + + runtime + .report_main_process_exit("sb-1", "instance-1", 0) + .await + .unwrap(); + assert_eq!(driver.delete_calls(), 0); + assert_eq!( + runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .unwrap() + .phase(), + SandboxPhase::Completed as i32 + ); + + runtime + .finalize_main_process_exit("sb-1", "instance-1") + .await + .unwrap(); + assert_eq!(driver.delete_calls(), 0); + runtime + .supervisor_session_disconnected("sb-1", true) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(1), async { + while driver.delete_calls() == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("terminal finalization should release ephemeral cleanup"); + } + #[tokio::test] async fn conflicting_duplicate_main_process_exit_is_acknowledged() { let runtime = test_runtime(Arc::new(TestDriver::default())).await; @@ -5852,7 +6056,7 @@ mod tests { let traced = test_exporter::install_traced(); async { runtime - .create_sandbox(sandbox, None) + .create_sandbox(sandbox, None, false) .await .expect("create succeeds"); } @@ -5997,7 +6201,7 @@ mod tests { let traced = test_exporter::install_traced(); async { runtime - .create_sandbox(sandbox, None) + .create_sandbox(sandbox, None, false) .await .expect_err("driver refuses the create"); } @@ -6089,6 +6293,94 @@ mod tests { assert_eq!(driver.start_calls(), 2, "ready start is idempotent"); } + #[tokio::test] + async fn completed_sandbox_can_start_a_fresh_main_instance() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let mut sandbox = + sandbox_record("sb-completed", "sandbox-completed", SandboxPhase::Completed); + sandbox.status = Some(SandboxStatus { + phase: SandboxPhase::Completed as i32, + main_process_instance_id: "instance-old".to_string(), + exit_code: Some(0), + ..Default::default() + }); + runtime.store.put_message(&sandbox).await.unwrap(); + let session = ssh_session_record("completed-session", sandbox.object_id()); + runtime.store.put_message(&session).await.unwrap(); + register_test_supervisor_session(&runtime, sandbox.object_id()); + + let starting = runtime + .start_sandbox("default", sandbox.object_name()) + .await + .unwrap(); + + assert_eq!(starting.phase(), SandboxPhase::Starting as i32); + let status = starting.status.unwrap(); + assert_eq!(status.main_process_instance_id, "instance-old"); + assert_eq!(status.exit_code, None); + assert_eq!(driver.start_calls(), 1); + assert!(!runtime.supervisor_sessions.has_session(sandbox.object_id())); + assert!( + runtime + .store + .get_message::(session.object_id()) + .await + .unwrap() + .is_none(), + "restart revokes SSH sessions from the completed instance" + ); + } + + #[tokio::test] + async fn failed_main_process_error_can_start_a_fresh_instance() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let mut sandbox = sandbox_record("sb-failed", "sandbox-failed", SandboxPhase::Ready); + apply_main_process_exit(&mut sandbox, "instance-old", 130); + runtime.store.put_message(&sandbox).await.unwrap(); + let session = ssh_session_record("failed-session", sandbox.object_id()); + runtime.store.put_message(&session).await.unwrap(); + register_test_supervisor_session(&runtime, sandbox.object_id()); + + let starting = runtime + .start_sandbox("default", sandbox.object_name()) + .await + .unwrap(); + + assert_eq!(starting.phase(), SandboxPhase::Starting as i32); + let status = starting.status.unwrap(); + assert_eq!(status.main_process_instance_id, "instance-old"); + assert_eq!(status.exit_code, None); + assert_eq!(driver.start_calls(), 1); + assert!(!runtime.supervisor_sessions.has_session(sandbox.object_id())); + assert!( + runtime + .store + .get_message::(session.object_id()) + .await + .unwrap() + .is_none(), + "restart revokes SSH sessions from the failed instance" + ); + } + + #[tokio::test] + async fn infrastructure_error_cannot_be_started_as_a_command_result() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-error", "sandbox-error", SandboxPhase::Error); + runtime.store.put_message(&sandbox).await.unwrap(); + + let error = runtime + .start_sandbox("default", sandbox.object_name()) + .await + .unwrap_err(); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert_eq!(driver.start_calls(), 0); + } + #[tokio::test] async fn retained_stopping_transition_retries_driver_operation() { let driver = ControlledDriver::new(); @@ -6734,7 +7026,7 @@ mod tests { let mut watch_rx = runtime.sandbox_watch_bus.subscribe("sb-1"); runtime - .supervisor_session_disconnected("sb-1") + .supervisor_session_disconnected("sb-1", false) .await .unwrap(); @@ -6795,7 +7087,7 @@ mod tests { ..Default::default() }); - runtime.create_sandbox(sandbox, None).await.unwrap(); + runtime.create_sandbox(sandbox, None, false).await.unwrap(); runtime .apply_sandbox_update(ready_driver_sandbox("sb-1", "sandbox-a")) .await @@ -7830,6 +8122,38 @@ mod tests { assert_eq!(status.exit_code, None); } + #[tokio::test] + async fn late_driver_exit_preserves_completed_main_result() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let mut sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Completed); + sandbox.status = Some(SandboxStatus { + sandbox_name: "sandbox-a".to_string(), + phase: SandboxPhase::Completed as i32, + main_process_instance_id: "instance-1".to_string(), + exit_code: Some(0), + ..Default::default() + }); + runtime.store.put_message(&sandbox).await.unwrap(); + let mut exited = ready_driver_sandbox("sb-1", "sandbox-a"); + exited.status = Some(make_driver_status(make_driver_condition( + "ContainerExited", + "container exited after the canonical process completed", + ))); + + runtime.apply_sandbox_update(exited).await.unwrap(); + + let stored = runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Completed as i32); + let status = stored.status.unwrap(); + assert_eq!(status.main_process_instance_id, "instance-1"); + assert_eq!(status.exit_code, Some(0)); + } + #[tokio::test] async fn apply_sandbox_update_without_status_preserves_existing_status() { let runtime = test_runtime(Arc::new(TestDriver::default())).await; @@ -7977,7 +8301,7 @@ mod tests { runtime.store.put_message(&sandbox).await.unwrap(); runtime - .supervisor_session_disconnected("sb-1") + .supervisor_session_disconnected("sb-1", false) .await .unwrap(); @@ -8221,7 +8545,7 @@ mod tests { // Session drops. runtime.supervisor_sessions.cleanup_sandbox("sb-1"); runtime - .supervisor_session_disconnected("sb-1") + .supervisor_session_disconnected("sb-1", false) .await .unwrap(); let stored = runtime @@ -9365,7 +9689,7 @@ mod tests { }); runtime.validate_sandbox_create(&sandbox).await.unwrap(); - runtime.create_sandbox(sandbox, None).await.unwrap(); + runtime.create_sandbox(sandbox, None, false).await.unwrap(); let calls = driver.calls(); assert_eq!(calls.len(), 4, "unexpected calls: {calls:?}"); let validated = match &calls[2] { @@ -9478,7 +9802,7 @@ mod tests { deletion_timestamp_ms: 0, }); - let created = runtime.create_sandbox(sandbox, None).await.unwrap(); + let created = runtime.create_sandbox(sandbox, None, false).await.unwrap(); assert_eq!( created.metadata.as_ref().unwrap().resource_version, @@ -9513,7 +9837,7 @@ mod tests { .labels .insert("env".to_string(), "prod".to_string()); - runtime.create_sandbox(sandbox, None).await.unwrap(); + runtime.create_sandbox(sandbox, None, false).await.unwrap(); let matching = runtime .store @@ -9537,11 +9861,13 @@ mod tests { // Spawn two concurrent creation attempts for the same sandbox let runtime1 = runtime.clone(); let sandbox1 = sandbox.clone(); - let handle1 = tokio::spawn(async move { runtime1.create_sandbox(sandbox1, None).await }); + let handle1 = + tokio::spawn(async move { runtime1.create_sandbox(sandbox1, None, false).await }); let runtime2 = runtime.clone(); let sandbox2 = sandbox.clone(); - let handle2 = tokio::spawn(async move { runtime2.create_sandbox(sandbox2, None).await }); + let handle2 = + tokio::spawn(async move { runtime2.create_sandbox(sandbox2, None, false).await }); // Wait for both to complete let result1 = handle1.await.unwrap(); diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index f502369bcf..957a77cbac 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -25,8 +25,9 @@ use openshell_core::proto::{ DetachSandboxProviderRequest, DetachSandboxProviderResponse, EditDraftChunkRequest, EditDraftChunkResponse, ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, - ExposeServiceRequest, GatewayMessage, GetCurrentUserRequest, GetCurrentUserResponse, - GetDraftHistoryRequest, GetDraftHistoryResponse, GetDraftPolicyRequest, GetDraftPolicyResponse, + ExposeServiceRequest, FinalizeMainProcessExitRequest, FinalizeMainProcessExitResponse, + GatewayMessage, GetCurrentUserRequest, GetCurrentUserResponse, GetDraftHistoryRequest, + GetDraftHistoryResponse, GetDraftPolicyRequest, GetDraftPolicyResponse, GetGatewayConfigRequest, GetGatewayConfigResponse, GetGatewayInfoRequest, GetGatewayInfoResponse, GetProviderProfileRequest, GetProviderRefreshStatusRequest, GetProviderRefreshStatusResponse, GetProviderRequest, GetSandboxConfigRequest, @@ -695,6 +696,13 @@ impl OpenShell for OpenShellService { crate::supervisor_session::handle_report_main_process_exit(&self.state, request).await } + async fn finalize_main_process_exit( + &self, + request: Request, + ) -> Result, Status> { + crate::supervisor_session::handle_finalize_main_process_exit(&self.state, request).await + } + type RelayStreamStream = Pin> + Send + 'static>>; diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 89f8c942ea..403bece8b9 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -216,6 +216,7 @@ async fn handle_create_sandbox_inner( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let request = request.into_inner(); + let await_main_process_attachment = request.await_main_process_attachment; let mut spec = request .spec .ok_or_else(|| Status::invalid_argument("spec is required"))?; @@ -365,7 +366,10 @@ async fn handle_create_sandbox_inner( None => None, }; - let sandbox = state.compute.create_sandbox(sandbox, sandbox_token).await?; + let sandbox = state + .compute + .create_sandbox(sandbox, sandbox_token, await_main_process_attachment) + .await?; info!( sandbox_id = %id, @@ -1032,7 +1036,7 @@ pub(super) async fn handle_watch_sandbox( if stop_on_terminal { let phase = SandboxPhase::try_from(sandbox.phase()) .unwrap_or(SandboxPhase::Unknown); - if phase == SandboxPhase::Ready { + if is_watch_terminal(phase) { return; } } @@ -1106,7 +1110,7 @@ pub(super) async fn handle_watch_sandbox( } if stop_on_terminal { let phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown); - if phase == SandboxPhase::Ready { + if is_watch_terminal(phase) { return; } } @@ -1179,6 +1183,13 @@ pub(super) async fn handle_watch_sandbox( Ok(Response::new(WatchSandboxStream::new(rx, producer))) } +fn is_watch_terminal(phase: SandboxPhase) -> bool { + matches!( + phase, + SandboxPhase::Ready | SandboxPhase::Completed | SandboxPhase::Stopped | SandboxPhase::Error + ) +} + // --------------------------------------------------------------------------- // Exec handler // --------------------------------------------------------------------------- @@ -1316,7 +1327,10 @@ pub(super) async fn handle_forward_tcp( let sandbox = fetch_and_authorize_sandbox(state, &principal, &init.sandbox_id).await?; - if SandboxPhase::try_from(sandbox.phase()).ok() != Some(SandboxPhase::Ready) { + // The main process may finish between minting the SSH token and opening + // its transport. Keep the relay reachable until terminal delivery is + // finalized so fast commands can attach without a readiness race. + if !sandbox_relay_reachable(state, &sandbox) { return Err(Status::failed_precondition("sandbox is not ready")); } @@ -1697,6 +1711,16 @@ pub(super) async fn handle_exec_sandbox_interactive( // SSH session handlers // --------------------------------------------------------------------------- +fn sandbox_relay_reachable(state: &ServerState, sandbox: &Sandbox) -> bool { + let phase = SandboxPhase::try_from(sandbox.phase()).ok(); + matches!(phase, Some(SandboxPhase::Ready)) + || (matches!(phase, Some(SandboxPhase::Completed | SandboxPhase::Error)) + && state.supervisor_sessions.has_session(sandbox.object_id()) + && !state + .supervisor_sessions + .terminal_delivery_finalized(sandbox.object_id())) +} + pub(super) async fn handle_create_ssh_session( state: &Arc, request: Request, @@ -1709,7 +1733,7 @@ pub(super) async fn handle_create_ssh_session( let sandbox = fetch_and_authorize_sandbox(state, &principal, &req.sandbox_id).await?; - if SandboxPhase::try_from(sandbox.phase()).ok() != Some(SandboxPhase::Ready) { + if !sandbox_relay_reachable(state, &sandbox) { return Err(Status::failed_precondition("sandbox is not ready")); } @@ -2458,6 +2482,30 @@ mod tests { // ---- shell_escape ---- + #[test] + fn watch_terminal_phases_include_command_results_and_errors() { + for phase in [ + SandboxPhase::Ready, + SandboxPhase::Completed, + SandboxPhase::Stopped, + SandboxPhase::Error, + ] { + assert!(is_watch_terminal(phase), "{phase:?} should stop the watch"); + } + for phase in [ + SandboxPhase::Provisioning, + SandboxPhase::Starting, + SandboxPhase::Stopping, + SandboxPhase::Deleting, + SandboxPhase::Unknown, + ] { + assert!( + !is_watch_terminal(phase), + "{phase:?} should keep the watch open" + ); + } + } + #[test] fn telemetry_compute_driver_uses_resolved_driver_kind() { assert_eq!( @@ -3361,6 +3409,7 @@ mod tests { labels: HashMap::new(), annotations: HashMap::new(), workspace: String::new(), + await_main_process_attachment: false, }), ) .await @@ -3384,6 +3433,7 @@ mod tests { labels: HashMap::new(), annotations: HashMap::new(), workspace: String::new(), + await_main_process_attachment: false, }), ) .await @@ -3419,6 +3469,7 @@ mod tests { labels: HashMap::new(), annotations: HashMap::new(), workspace: String::new(), + await_main_process_attachment: false, }), ) .await @@ -3443,6 +3494,7 @@ mod tests { labels: HashMap::new(), annotations: HashMap::from([(annotation_key.clone(), annotation_value.clone())]), workspace: String::new(), + await_main_process_attachment: false, }), ) .await @@ -3503,6 +3555,7 @@ mod tests { labels: HashMap::new(), annotations: HashMap::new(), workspace: String::new(), + await_main_process_attachment: false, }), ) .await @@ -3568,6 +3621,7 @@ mod tests { labels: HashMap::new(), annotations: HashMap::new(), workspace: String::new(), + await_main_process_attachment: false, }), ) .await @@ -3598,6 +3652,7 @@ mod tests { labels: HashMap::from([("team".to_string(), "x".repeat(512))]), annotations: HashMap::new(), workspace: String::new(), + await_main_process_attachment: false, }), ) .await @@ -3630,6 +3685,7 @@ mod tests { labels: HashMap::new(), annotations: HashMap::new(), workspace: String::new(), + await_main_process_attachment: false, }), ) .await @@ -3926,6 +3982,40 @@ mod tests { assert!(session2.is_some()); } + #[tokio::test] + async fn create_ssh_session_allows_terminal_sandbox_while_supervisor_is_reachable() { + let state = test_server_state().await; + let mut sandbox = test_sandbox("work", Vec::new()); + sandbox.set_phase(SandboxPhase::Completed as i32); + state.store.put_message(&sandbox).await.unwrap(); + + let (tx, _rx) = mpsc::channel(1); + let (shutdown_tx, _shutdown_rx) = oneshot::channel(); + let _ = state.supervisor_sessions.register( + "sandbox-work".to_string(), + "session-1".to_string(), + tx, + shutdown_tx, + ); + + let response = handle_create_ssh_session( + &state, + authed_request(CreateSshSessionRequest { + sandbox_id: "sandbox-work".to_string(), + }), + ) + .await; + + assert!(response.is_ok()); + + assert!( + state + .supervisor_sessions + .finalize_main_process_exit("sandbox-work") + ); + assert!(!sandbox_relay_reachable(&state, &sandbox)); + } + #[tokio::test] async fn concurrent_revoke_ssh_session_handles_cas_properly() { let state = test_server_state().await; diff --git a/crates/openshell-server/src/supervisor_session.rs b/crates/openshell-server/src/supervisor_session.rs index ed8dbdb958..c8491dc1eb 100644 --- a/crates/openshell-server/src/supervisor_session.rs +++ b/crates/openshell-server/src/supervisor_session.rs @@ -58,6 +58,9 @@ struct LiveSession { /// the old session's `tx` just before supersede could still enqueue a /// `RelayOpen` onto the stale stream and sit until the relay timeout. shutdown: oneshot::Sender<()>, + /// Set after the supervisor confirms that every expected foreground + /// attachment has closed and terminal output delivery is complete. + terminal_delivery_finalized: bool, #[allow(dead_code)] connected_at: Instant, } @@ -125,6 +128,7 @@ impl SupervisorSessionRegistry { session_id, tx, shutdown, + terminal_delivery_finalized: false, connected_at: Instant::now(), }, ); @@ -163,15 +167,17 @@ impl SupervisorSessionRegistry { /// This guards against the supersede race: an old session's task may /// finish long after a new session has taken its place. The old task's /// cleanup must not evict the new registration. - fn remove_if_current(&self, sandbox_id: &str, session_id: &str) -> bool { + fn remove_if_current(&self, sandbox_id: &str, session_id: &str) -> Option { let mut sessions = self.sessions.lock().unwrap(); let is_current = sessions .get(sandbox_id) .is_some_and(|s| s.session_id == session_id); if is_current { - sessions.remove(sandbox_id); + return sessions + .remove(sandbox_id) + .map(|session| session.terminal_delivery_finalized); } - is_current + None } /// Look up the sender for a supervisor session, waiting up to `timeout` @@ -210,6 +216,23 @@ impl SupervisorSessionRegistry { self.sessions.lock().unwrap().contains_key(sandbox_id) } + pub fn terminal_delivery_finalized(&self, sandbox_id: &str) -> bool { + self.sessions + .lock() + .unwrap() + .get(sandbox_id) + .is_some_and(|session| session.terminal_delivery_finalized) + } + + pub fn finalize_main_process_exit(&self, sandbox_id: &str) -> bool { + let mut sessions = self.sessions.lock().unwrap(); + let Some(session) = sessions.get_mut(sandbox_id) else { + return false; + }; + session.terminal_delivery_finalized = true; + true + } + pub fn is_current_session(&self, sandbox_id: &str, session_id: &str) -> bool { self.sessions .lock() @@ -797,17 +820,17 @@ pub async fn handle_connect_supervisor( shutdown_rx, ) .await; - let still_ours = state_clone + let terminal_finalized = state_clone .supervisor_sessions .remove_if_current(&sandbox_id_clone, &session_id); - if still_ours { + if let Some(terminal_finalized) = terminal_finalized { info!(sandbox_id = %sandbox_id_clone, session_id = %session_id, "supervisor session: ended"); state_clone .telemetry .sandbox_session_disconnected(&sandbox_id_clone); if let Err(err) = state_clone .compute - .supervisor_session_disconnected(&sandbox_id_clone) + .supervisor_session_disconnected(&sandbox_id_clone, terminal_finalized) .await { warn!( @@ -848,12 +871,45 @@ pub async fn handle_report_main_process_exit( } state .compute - .main_process_exited(&report.sandbox_id, &report.instance_id, report.exit_code) + .report_main_process_exit(&report.sandbox_id, &report.instance_id, report.exit_code) .await .map_err(Status::failed_precondition)?; Ok(Response::new(ReportMainProcessExitResponse {})) } +pub async fn handle_finalize_main_process_exit( + state: &Arc, + request: Request, +) -> Result, Status> { + let principal = request.extensions().get::().cloned(); + let report = request.into_inner(); + if report.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } + if report.instance_id.is_empty() { + return Err(Status::invalid_argument("instance_id is required")); + } + if let Some(principal) = principal.as_ref() { + crate::auth::guard::ensure_sandbox_principal_scope(principal, &report.sandbox_id)?; + } + state + .compute + .finalize_main_process_exit(&report.sandbox_id, &report.instance_id) + .await + .map_err(Status::failed_precondition)?; + if !state + .supervisor_sessions + .finalize_main_process_exit(&report.sandbox_id) + { + return Err(Status::failed_precondition( + "supervisor session is not connected", + )); + } + Ok(Response::new( + openshell_core::proto::FinalizeMainProcessExitResponse {}, + )) +} + async fn run_session_loop( state: &Arc, sandbox_id: &str, @@ -1108,7 +1164,7 @@ mod tests { let (tx, _rx) = mpsc::channel(1); registry.register("sbx".to_string(), "s1".to_string(), tx, make_shutdown()); - assert!(registry.remove_if_current("sbx", "s1")); + assert_eq!(registry.remove_if_current("sbx", "s1"), Some(false)); assert!(!registry.sessions.lock().unwrap().contains_key("sbx")); } @@ -1134,7 +1190,7 @@ mod tests { // Cleanup from the old session task runs late. It must NOT evict the // newly registered session. - assert!(!registry.remove_if_current("sbx", "s-old")); + assert_eq!(registry.remove_if_current("sbx", "s-old"), None); let sessions = registry.sessions.lock().unwrap(); assert!( sessions.contains_key("sbx"), @@ -1146,7 +1202,18 @@ mod tests { #[test] fn remove_if_current_unknown_sandbox_is_noop() { let registry = SupervisorSessionRegistry::new(); - assert!(!registry.remove_if_current("sbx-does-not-exist", "s1")); + assert_eq!(registry.remove_if_current("sbx-does-not-exist", "s1"), None); + } + + #[test] + fn remove_if_current_returns_terminal_finalization_state() { + let registry = SupervisorSessionRegistry::new(); + let (tx, _rx) = mpsc::channel(1); + registry.register("sbx".to_string(), "s1".to_string(), tx, make_shutdown()); + + assert!(registry.finalize_main_process_exit("sbx")); + assert!(registry.terminal_delivery_finalized("sbx")); + assert_eq!(registry.remove_if_current("sbx", "s1"), Some(true)); } // ---- open_relay: happy path and wait semantics ---- diff --git a/crates/openshell-server/tests/common/mod.rs b/crates/openshell-server/tests/common/mod.rs index 96b620a230..9e3957abb6 100644 --- a/crates/openshell-server/tests/common/mod.rs +++ b/crates/openshell-server/tests/common/mod.rs @@ -60,6 +60,13 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("not used by this test server")) } + async fn finalize_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn get_current_user( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/supervisor_relay_integration.rs b/crates/openshell-server/tests/supervisor_relay_integration.rs index 32ef513adb..6cfd3b009b 100644 --- a/crates/openshell-server/tests/supervisor_relay_integration.rs +++ b/crates/openshell-server/tests/supervisor_relay_integration.rs @@ -55,6 +55,13 @@ impl OpenShell for RelayGateway { Err(Status::unimplemented("not used by this test server")) } + async fn finalize_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn get_current_user( &self, _request: tonic::Request, diff --git a/crates/openshell-supervisor-process/src/main_session.rs b/crates/openshell-supervisor-process/src/main_session.rs index fc5c9693e6..00dd2ea53c 100644 --- a/crates/openshell-supervisor-process/src/main_session.rs +++ b/crates/openshell-supervisor-process/src/main_session.rs @@ -54,6 +54,8 @@ struct OutputLogState { struct OutputLog { state: Mutex, version: watch::Sender, + terminal_reported: std::sync::atomic::AtomicBool, + terminal_reported_notify: Notify, } impl OutputLog { @@ -66,6 +68,8 @@ impl OutputLog { next_sequence: 0, }), version, + terminal_reported: std::sync::atomic::AtomicBool::new(false), + terminal_reported_notify: Notify::new(), }) } @@ -106,6 +110,20 @@ impl OutputLog { } } +#[derive(Debug)] +struct TerminalAttachmentState { + active: usize, + process_finished: bool, + expectation: AttachmentExpectation, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AttachmentExpectation { + None, + Pending, + Satisfied, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct MainOutputLagged { pub skipped: u64, @@ -170,6 +188,9 @@ pub struct MainSession { pty_master: Option>, readers_remaining: AtomicUsize, readers_done: Notify, + finished: std::sync::atomic::AtomicBool, + terminal_attachments: Mutex, + terminal_attachments_done: Notify, } impl MainSession { @@ -186,6 +207,13 @@ impl MainSession { pty_master: None, readers_remaining: AtomicUsize::new(0), readers_done: Notify::new(), + finished: std::sync::atomic::AtomicBool::new(false), + terminal_attachments: Mutex::new(TerminalAttachmentState { + active: 0, + process_finished: false, + expectation: AttachmentExpectation::None, + }), + terminal_attachments_done: Notify::new(), }) } @@ -230,6 +258,13 @@ impl MainSession { pty_master, readers_remaining: AtomicUsize::new(if terminal { 1 } else { 2 }), readers_done: Notify::new(), + finished: std::sync::atomic::AtomicBool::new(false), + terminal_attachments: Mutex::new(TerminalAttachmentState { + active: 0, + process_finished: false, + expectation: AttachmentExpectation::None, + }), + terminal_attachments_done: Notify::new(), }); Self::start_io(&session, io, input_rx); session @@ -340,18 +375,113 @@ impl MainSession { } } - pub async fn finish(&self, exit_code: i32) { + /// Publish the terminal event and retain the transport only when a real + /// foreground attachment exists or the creating client declared one. + /// + /// Returns whether terminal delivery must complete before shutdown. + pub async fn finish(&self, exit_code: i32, attachment_expected: bool) -> bool { let notified = self.readers_done.notified(); if self.readers_remaining.load(Ordering::Acquire) != 0 { - let _ = tokio::time::timeout(std::time::Duration::from_secs(2), notified).await; + notified.await; } + let delivery_pending = { + let mut state = self + .terminal_attachments + .lock() + .expect("terminal attachment lock poisoned"); + state.process_finished = true; + state.expectation = if attachment_expected { + if state.active == 0 && state.expectation != AttachmentExpectation::Satisfied { + AttachmentExpectation::Pending + } else { + AttachmentExpectation::Satisfied + } + } else { + AttachmentExpectation::None + }; + attachment_expected || state.active != 0 + }; + self.finished.store(true, Ordering::Release); self.publish(MainOutput::Exit(exit_code)); + delivery_pending } pub fn subscribe(&self) -> MainOutputCursor { self.output.subscribe() } + /// Wait until the gateway durably acknowledges the main-process result. + pub async fn wait_for_terminal_reported(&self) { + let notified = self.output.terminal_reported_notify.notified(); + if self.output.terminal_reported.load(Ordering::Acquire) { + return; + } + notified.await; + } + + /// Release attached clients to receive their SSH exit status after the + /// durable sandbox phase and exit code have been recorded. + pub fn mark_terminal_reported(&self) { + self.output.terminal_reported.store(true, Ordering::Release); + self.output.terminal_reported_notify.notify_waiters(); + } + + /// Register a foreground main attachment while the process is live. + pub fn begin_terminal_attachment(&self) -> Result<(), &'static str> { + let mut state = self + .terminal_attachments + .lock() + .expect("terminal attachment lock poisoned"); + if state.process_finished && state.expectation != AttachmentExpectation::Pending { + return Err("canonical main process already finished"); + } + state.active = state + .active + .checked_add(1) + .expect("terminal attachment count exhausted"); + state.expectation = AttachmentExpectation::Satisfied; + self.terminal_attachments_done.notify_waiters(); + Ok(()) + } + + /// Release a foreground main attachment after its SSH channel closes. + pub fn end_terminal_attachment(&self) { + let completed = { + let mut state = self + .terminal_attachments + .lock() + .expect("terminal attachment lock poisoned"); + debug_assert!(state.active != 0, "terminal attachment count underflow"); + if state.active == 0 { + return; + } + state.active -= 1; + state.active == 0 + }; + if completed { + self.terminal_attachments_done.notify_waiters(); + } + } + + /// Wait for the declared foreground attachment to start, then for every + /// accepted attachment to close naturally. + pub async fn wait_for_terminal_attachments(&self) { + loop { + let notified = self.terminal_attachments_done.notified(); + let complete = { + let state = self + .terminal_attachments + .lock() + .expect("terminal attachment lock poisoned"); + state.active == 0 && state.expectation != AttachmentExpectation::Pending + }; + if complete { + return; + } + notified.await; + } + } + pub fn acquire_input(&self) -> Result<(u64, tokio::sync::mpsc::Sender>), &'static str> { let mut owner = self.input_owner.lock().expect("main input lock poisoned"); if owner.is_some() { @@ -394,6 +524,11 @@ impl MainSession { pub const fn terminal(&self) -> bool { self.terminal } + + #[must_use] + pub fn finished(&self) -> bool { + self.finished.load(Ordering::Acquire) + } } fn set_nonblocking(file: &std::fs::File) -> Result<(), nix::errno::Errno> { @@ -440,9 +575,94 @@ mod tests { } #[tokio::test] - async fn exit_is_replayed_once_to_late_subscribers() { + async fn finish_without_attachment_does_not_defer_shutdown() { + let session = MainSession::inert(); + assert!(!session.finish(0, false).await); + assert!(session.finished()); + assert!(session.begin_terminal_attachment().is_err()); + } + + #[tokio::test] + async fn terminal_report_acknowledgement_is_independent_from_delivery() { + let session = MainSession::inert(); + + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(10), + session.wait_for_terminal_reported(), + ) + .await + .is_err(), + "draining output must not imply durable gateway persistence" + ); + + session.mark_terminal_reported(); + tokio::time::timeout( + std::time::Duration::from_secs(1), + session.wait_for_terminal_reported(), + ) + .await + .expect("durable report acknowledgement should wake waiter"); + } + + #[tokio::test] + async fn finish_waits_for_an_active_attachment_to_close_naturally() { + let session = MainSession::inert(); + session + .begin_terminal_attachment() + .expect("begin terminal attachment"); + assert!(session.finish(0, false).await); + + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(10), + session.wait_for_terminal_attachments(), + ) + .await + .is_err(), + "an active attachment must keep terminal delivery open" + ); + + session.end_terminal_attachment(); + tokio::time::timeout( + std::time::Duration::from_secs(1), + session.wait_for_terminal_attachments(), + ) + .await + .expect("closing the attachment should wake the waiter"); + } + + #[tokio::test] + async fn declared_attachment_waits_for_connection_then_natural_close() { + let session = MainSession::inert(); + assert!(session.finish(0, true).await); + + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(10), + session.wait_for_terminal_attachments(), + ) + .await + .is_err(), + "declared attachment must connect before delivery is complete" + ); + + session + .begin_terminal_attachment() + .expect("declared post-exit attachment"); + session.end_terminal_attachment(); + tokio::time::timeout( + std::time::Duration::from_secs(1), + session.wait_for_terminal_attachments(), + ) + .await + .expect("natural attachment close should complete delivery"); + } + + #[tokio::test] + async fn exit_is_retained_in_the_output_log() { let session = MainSession::inert(); - session.finish(0).await; + let _ = session.finish(0, false).await; let mut output = session.subscribe(); assert!(matches!( diff --git a/crates/openshell-supervisor-process/src/run.rs b/crates/openshell-supervisor-process/src/run.rs index f0e792306d..b2820d9588 100644 --- a/crates/openshell-supervisor-process/src/run.rs +++ b/crates/openshell-supervisor-process/src/run.rs @@ -39,11 +39,17 @@ use crate::process::{ ResolvedWorkspace, }; -pub type SidecarExitReport = ( - String, - i32, - tokio::sync::oneshot::Sender>, -); +pub enum SidecarExitReport { + Exited { + instance_id: String, + exit_code: i32, + ack: tokio::sync::oneshot::Sender>, + }, + Finalized { + instance_id: String, + ack: tokio::sync::oneshot::Sender>, + }, +} fn ocsf_ctx() -> &'static openshell_ocsf::SandboxContext { openshell_ocsf::ctx::ctx() @@ -63,6 +69,7 @@ pub async fn run_process( workspace: ResolvedWorkspace, timeout_secs: u64, interactive: bool, + await_main_process_attachment: bool, sandbox_id: Option<&str>, openshell_endpoint: Option<&str>, ssh_socket_path: Option, @@ -322,9 +329,9 @@ pub async fn run_process( } }); - // Wait for the SSH server to bind its socket before spawning the - // entrypoint process. This prevents exec requests from racing against - // SSH server startup when Kubernetes marks the pod Ready. + // Wait for the SSH server to bind before advertising its relay. The + // main process is already supervised; MainSession retains any output + // produced while this endpoint is being prepared. match timeout(Duration::from_secs(10), ssh_ready_rx).await { Ok(Ok(Ok(()))) => { ocsf_emit!( @@ -354,16 +361,14 @@ pub async fn run_process( let supervisor_terminating = Arc::new(AtomicBool::new(false)); // A canonical process may have completed while the SSH socket was being - // prepared. Never open a readiness-bearing supervisor session for a child - // that is already terminal. + // prepared. Detect that exit before entering the main wait path. let early_exit = handle.try_wait().into_diagnostic()?; // Spawn the persistent supervisor session if we have a gateway endpoint // and sandbox identity. The session provides relay channels for SSH // connect and ExecSandbox through the gateway. - let supervisor_session_task = if early_exit.is_none() - && let (Some(endpoint), Some(id), Some(socket)) = - (openshell_endpoint, sandbox_id, ssh_socket_path.as_ref()) + let supervisor_session_task = if let (Some(endpoint), Some(id), Some(socket)) = + (openshell_endpoint, sandbox_id, ssh_socket_path.as_ref()) { let task = crate::supervisor_session::spawn( endpoint.to_string(), @@ -382,9 +387,7 @@ pub async fn run_process( // Store the entrypoint PID so the proxy can resolve TCP peer identity entrypoint_pid.store(handle.pid(), Ordering::Release); - if early_exit.is_none() - && let Some(tx) = entrypoint_started_tx - { + if let Some(tx) = entrypoint_started_tx { let _ = tx.send((handle.pid(), main_instance_id.clone())); } ocsf_emit!( @@ -407,8 +410,8 @@ pub async fn run_process( .await? }; - let rendered_code = match outcome { - ProcessWaitOutcome::Exited(status) => status.code(), + let (rendered_code, drain_terminal) = match outcome { + ProcessWaitOutcome::Exited(status) => (status.code(), true), ProcessWaitOutcome::TimedOut => { ocsf_emit!( ProcessActivityBuilder::new(ocsf_ctx()) @@ -420,7 +423,7 @@ pub async fn run_process( .message("Process timed out, killing") .build() ); - 124 + (124, false) } ProcessWaitOutcome::ShutdownSignal { signal, status } => { info!( @@ -428,11 +431,15 @@ pub async fn run_process( exit_code = status.code(), "Entrypoint exited after supervisor shutdown signal" ); - status.code() + (status.code(), false) } }; - supervisor_terminating.store(true, Ordering::Release); - main_session.finish(rendered_code).await; + let terminal_delivery_pending = main_session + .finish( + rendered_code, + drain_terminal && await_main_process_attachment, + ) + .await; ocsf_emit!( ProcessActivityBuilder::new(ocsf_ctx()) @@ -446,22 +453,30 @@ pub async fn run_process( .build() ); - if let Some(task) = supervisor_session_task { - task.abort(); - } - if let Some(tx) = sidecar_exit_tx { - let (ack_tx, ack_rx) = tokio::sync::oneshot::channel(); - tx.send((main_instance_id.clone(), rendered_code, ack_tx)) - .await - .map_err(|_| miette::miette!("sidecar exit reporter closed"))?; - ack_rx - .await - .map_err(|_| miette::miette!("sidecar exit reporter dropped acknowledgement"))? - .map_err(|error| miette::miette!(error))?; + if let Some(tx) = sidecar_exit_tx.as_ref() { + report_sidecar_main_process_exit(tx, &main_instance_id, rendered_code).await?; } else if let (Some(endpoint), Some(id)) = (openshell_endpoint, sandbox_id) { report_main_process_exit_until_ack(endpoint, id, &main_instance_id, rendered_code).await; info!(instance_id = %main_instance_id, "main-process exit acknowledged"); } + main_session.mark_terminal_reported(); + if drain_terminal && terminal_delivery_pending { + // The peer's SSH channel-close confirms that the terminal frames sent + // above traversed russh and the relay. Detached commands have no active + // attachment and never enter this wait. + main_session.wait_for_terminal_attachments().await; + } + if let Some(tx) = sidecar_exit_tx.as_ref() { + finalize_sidecar_main_process_exit(tx, &main_instance_id).await?; + } else if let (Some(endpoint), Some(id)) = (openshell_endpoint, sandbox_id) { + finalize_main_process_exit_until_ack(endpoint, id, &main_instance_id).await; + info!(instance_id = %main_instance_id, "main-process terminal delivery finalized"); + } + + supervisor_terminating.store(true, Ordering::Release); + if let Some(task) = supervisor_session_task { + task.abort(); + } Ok(rendered_code) } @@ -492,6 +507,62 @@ async fn report_main_process_exit_until_ack( } } +async fn finalize_main_process_exit_until_ack(endpoint: &str, sandbox_id: &str, instance_id: &str) { + let mut retry_delay = Duration::from_millis(250); + loop { + match crate::supervisor_session::finalize_main_process_exit( + endpoint, + sandbox_id, + instance_id, + ) + .await + { + Ok(()) => return, + Err(error) => { + tracing::warn!(%error, "main-process terminal finalization failed; retrying"); + tokio::time::sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(Duration::from_secs(2)); + } + } + } +} + +async fn report_sidecar_main_process_exit( + tx: &tokio::sync::mpsc::Sender, + instance_id: &str, + exit_code: i32, +) -> Result<()> { + let (ack_tx, ack_rx) = tokio::sync::oneshot::channel(); + tx.send(SidecarExitReport::Exited { + instance_id: instance_id.to_string(), + exit_code, + ack: ack_tx, + }) + .await + .map_err(|_| miette::miette!("sidecar exit reporter closed"))?; + ack_rx + .await + .map_err(|_| miette::miette!("sidecar exit reporter dropped acknowledgement"))? + .map_err(|error| miette::miette!(error)) +} + +async fn finalize_sidecar_main_process_exit( + tx: &tokio::sync::mpsc::Sender, + instance_id: &str, +) -> Result<()> { + let (ack_tx, ack_rx) = tokio::sync::oneshot::channel(); + tx.send(SidecarExitReport::Finalized { + instance_id: instance_id.to_string(), + ack: ack_tx, + }) + .await + .map_err(|_| miette::miette!("sidecar exit reporter closed"))?; + ack_rx + .await + .map_err(|_| miette::miette!("sidecar exit reporter dropped acknowledgement"))? + .map_err(|error| miette::miette!(error)) +} + enum ProcessWaitOutcome { Exited(ProcessStatus), TimedOut, @@ -515,7 +586,6 @@ async fn wait_for_process_exit_or_shutdown( tokio::pin!(deadline); tokio::select! { result = &mut wait => { - terminating.store(true, Ordering::Release); Ok(ProcessWaitOutcome::Exited(result.into_diagnostic()?)) } () = &mut deadline => { @@ -533,7 +603,6 @@ async fn wait_for_process_exit_or_shutdown( } else { tokio::select! { result = &mut wait => { - terminating.store(true, Ordering::Release); Ok(ProcessWaitOutcome::Exited(result.into_diagnostic()?)) } signal = wait_for_supervisor_shutdown_signal() => { diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index 893967b2ac..12daff8d0c 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -460,6 +460,10 @@ struct SshHandler { impl Drop for SshHandler { fn drop(&mut self) { for state in self.channels.values_mut() { + if state.main_attached { + self.main_session.end_terminal_attachment(); + state.main_attached = false; + } if let Some(owner) = state.main_input_owner.take() { self.main_session.release_input(owner); } @@ -537,6 +541,9 @@ impl russh::server::Handler for SshHandler { _session: &mut Session, ) -> Result<(), Self::Error> { if let Some(state) = self.channels.remove(&channel) { + if state.main_attached { + self.main_session.end_terminal_attachment(); + } if let Some(owner) = state.main_input_owner { self.main_session.release_input(owner); } @@ -557,6 +564,12 @@ impl russh::server::Handler for SshHandler { reply: ChannelOpenHandle, _session: &mut Session, ) -> Result<(), Self::Error> { + if self.main_session.finished() { + reply + .reject(ChannelOpenFailure::AdministrativelyProhibited) + .await; + return Ok(()); + } // Validate port range before truncating u32 -> u16. The SSH protocol // uses u32 for ports, but valid TCP ports are 0-65535. Without this // check, port 65537 truncates to port 1 (privileged). @@ -692,6 +705,10 @@ impl russh::server::Handler for SshHandler { channel: ChannelId, session: &mut Session, ) -> Result<(), Self::Error> { + if self.main_session.finished() { + session.channel_failure(channel)?; + return Ok(()); + } session.channel_success(channel)?; // Only allocate a PTY when the client explicitly requested one via // pty_request. VS Code Remote-SSH sends shell_request *without* a @@ -709,6 +726,10 @@ impl russh::server::Handler for SshHandler { data: &[u8], session: &mut Session, ) -> Result<(), Self::Error> { + if self.main_session.finished() { + session.channel_failure(channel)?; + return Ok(()); + } session.channel_success(channel)?; let command = String::from_utf8_lossy(data).trim().to_string(); if command.is_empty() { @@ -725,9 +746,20 @@ impl russh::server::Handler for SshHandler { session: &mut Session, ) -> Result<(), Self::Error> { if name == "openshell-main" { - let state = self.channels.get_mut(&channel).ok_or_else(|| { - anyhow::anyhow!("subsystem_request on unknown channel {channel:?}") - })?; + if !self.channels.contains_key(&channel) { + return Err(anyhow::anyhow!( + "subsystem_request on unknown channel {channel:?}" + )); + } + if self.main_session.begin_terminal_attachment().is_err() { + session.channel_failure(channel)?; + return Ok(()); + } + let state = self + .channels + .get_mut(&channel) + .expect("main channel existence checked above"); + state.main_attached = true; if let Some(pty) = state.pty_request.take() { self.main_session.resize( pty.col_width, @@ -750,10 +782,10 @@ impl russh::server::Handler for SshHandler { } } }; - state.main_attached = true; state.main_detach_prefix_pending = false; state.input_sender = input; let mut output = self.main_session.subscribe(); + let terminal_delivery = Arc::clone(&self.main_session); let handle = session.handle(); session.channel_success(channel)?; if let Some(error) = input_warning { @@ -769,11 +801,13 @@ impl russh::server::Handler for SshHandler { loop { match output.recv().await { Ok(event) => { - let exited = matches!(event, MainOutput::Exit(_)); - send_main_output(&handle, channel, event).await; - if exited { + if let MainOutput::Exit(code) = event { + terminal_delivery.wait_for_terminal_reported().await; + let _ = send_main_output(&handle, channel, MainOutput::Exit(code)) + .await; break; } + let _ = send_main_output(&handle, channel, event).await; } Err(error) => { let _ = handle @@ -796,7 +830,7 @@ impl russh::server::Handler for SshHandler { if let Some(state) = self.channels.get_mut(&channel) { state.main_output_task = Some(output_task.abort_handle()); } - } else if name == "sftp" { + } else if name == "sftp" && !self.main_session.finished() { session.channel_success(channel)?; // sftp-server speaks the SFTP binary protocol over stdin/stdout, // which is exactly what spawn_pipe_exec wires up. This enables @@ -954,20 +988,18 @@ impl russh::server::Handler for SshHandler { } } -async fn send_main_output(handle: &Handle, channel: ChannelId, event: MainOutput) { +async fn send_main_output(handle: &Handle, channel: ChannelId, event: MainOutput) -> bool { match event { - MainOutput::Stdout(data) => { - let _ = handle.data(channel, data).await; - } - MainOutput::Stderr(data) => { - let _ = handle.extended_data(channel, 1, data).await; - } + MainOutput::Stdout(data) => handle.data(channel, data).await.is_ok(), + MainOutput::Stderr(data) => handle.extended_data(channel, 1, data).await.is_ok(), MainOutput::Exit(code) => { - let _ = handle.eof(channel).await; - let _ = handle + let eof_sent = handle.eof(channel).await.is_ok(); + let status_sent = handle .exit_status_request(channel, code.max(0).unsigned_abs()) - .await; - let _ = handle.close(channel).await; + .await + .is_ok(); + let close_sent = handle.close(channel).await.is_ok(); + eof_sent && status_sent && close_sent } } } @@ -980,6 +1012,10 @@ impl SshHandler { error: Option<&str>, ) { if let Some(state) = self.channels.get_mut(&channel) { + if state.main_attached { + self.main_session.end_terminal_attachment(); + state.main_attached = false; + } if let Some(owner) = state.main_input_owner.take() { self.main_session.release_input(owner); } @@ -988,7 +1024,6 @@ impl SshHandler { if let Some(task) = state.main_output_task.take() { task.abort(); } - state.main_attached = false; } if let Some(error) = error { let _ = handle @@ -2521,6 +2556,59 @@ mod tests { .expect("handler drop should release canonical input lease"); } + #[tokio::test] + async fn main_attachment_closes_naturally_after_terminal_delivery() { + let main_session = MainSession::inert(); + let client = authenticated_test_client_with_main(Arc::clone(&main_session)).await; + let mut channel = client.channel_open_session().await.expect("open session"); + channel + .request_subsystem(true, "openshell-main") + .await + .expect("attach main subsystem"); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + match main_session.acquire_input() { + Err(_) => break, + Ok((owner, _)) => main_session.release_input(owner), + } + tokio::task::yield_now().await; + } + }) + .await + .expect("main subsystem should register its attachment"); + + assert!(main_session.finish(7, false).await); + main_session.mark_terminal_reported(); + + let exit_status = tokio::time::timeout(Duration::from_secs(1), async { + let mut exit_status = None; + loop { + match channel.wait().await { + Some(russh::ChannelMsg::ExitStatus { + exit_status: status, + }) => { + exit_status = Some(status); + } + Some(russh::ChannelMsg::Close) => break exit_status, + None => panic!("main channel ended without a close message"), + Some(_) => {} + } + } + }) + .await + .expect("main channel should deliver its exit status"); + assert_eq!(exit_status, Some(7)); + drop(channel); + drop(client); + + tokio::time::timeout( + Duration::from_secs(1), + main_session.wait_for_terminal_attachments(), + ) + .await + .expect("peer channel close should release terminal delivery"); + } + #[tokio::test] async fn main_subsystem_applies_initial_pty_dimensions() { let (main_session, _slave) = MainSession::terminal_for_test(); diff --git a/crates/openshell-supervisor-process/src/supervisor_session.rs b/crates/openshell-supervisor-process/src/supervisor_session.rs index e8a140e483..98a3c0497b 100644 --- a/crates/openshell-supervisor-process/src/supervisor_session.rs +++ b/crates/openshell-supervisor-process/src/supervisor_session.rs @@ -19,9 +19,9 @@ use std::time::Duration; use openshell_core::proto::open_shell_client::OpenShellClient; use openshell_core::proto::{ - GatewayMessage, RelayFrame, RelayInit, RelayOpen, RelayOpenResult, - ReportMainProcessExitRequest, SupervisorHeartbeat, SupervisorHello, SupervisorMessage, - TcpRelayTarget, gateway_message, relay_open, supervisor_message, + FinalizeMainProcessExitRequest, GatewayMessage, RelayFrame, RelayInit, RelayOpen, + RelayOpenResult, ReportMainProcessExitRequest, SupervisorHeartbeat, SupervisorHello, + SupervisorMessage, TcpRelayTarget, gateway_message, relay_open, supervisor_message, }; use openshell_ocsf::{ ActivityId, ConnectionInfo, Endpoint, NetworkActivityBuilder, OcsfEvent, SandboxContext, @@ -283,7 +283,7 @@ pub fn spawn( terminating: Arc, instance_id: String, ) -> tokio::task::JoinHandle<()> { - tokio::spawn(run_session_loop( + let config = SessionConfig { endpoint, sandbox_id, ssh_socket_path, @@ -291,10 +291,11 @@ pub fn spawn( expected_ssh_peer_pid, terminating, instance_id, - )) + }; + tokio::spawn(run_session_loop(config)) } -async fn run_session_loop( +struct SessionConfig { endpoint: String, sandbox_id: String, ssh_socket_path: std::path::PathBuf, @@ -302,34 +303,29 @@ async fn run_session_loop( expected_ssh_peer_pid: Option, terminating: Arc, instance_id: String, -) { +} + +async fn run_session_loop(config: SessionConfig) { let mut backoff = INITIAL_BACKOFF; let mut attempt: u64 = 0; loop { attempt += 1; - match run_single_session( - &endpoint, - &sandbox_id, - &ssh_socket_path, - netns_fd, - expected_ssh_peer_pid, - Arc::clone(&terminating), - &instance_id, - ) - .await - { + match run_single_session(&config).await { Ok(()) => { - let event = - session_closed_event(openshell_ocsf::ctx::ctx(), &endpoint, &sandbox_id); + let event = session_closed_event( + openshell_ocsf::ctx::ctx(), + &config.endpoint, + &config.sandbox_id, + ); ocsf_emit!(event); break; } Err(e) => { let event = session_failed_event( openshell_ocsf::ctx::ctx(), - &endpoint, + &config.endpoint, attempt, &e.to_string(), ); @@ -342,19 +338,13 @@ async fn run_session_loop( } async fn run_single_session( - endpoint: &str, - sandbox_id: &str, - ssh_socket_path: &std::path::Path, - netns_fd: Option, - expected_ssh_peer_pid: Option, - terminating: Arc, - instance_id: &str, + config: &SessionConfig, ) -> Result<(), Box> { // Connect to the gateway. The same `Channel` is used for both the // long-lived control stream and all data-plane `RelayStream` calls, so // every relay rides the same TCP+TLS+HTTP/2 connection — no new TLS // handshake per relay. - let channel = grpc_client::connect_channel_pub(endpoint) + let channel = grpc_client::connect_channel_pub(&config.endpoint) .await .map_err(|e| format!("connect failed: {e}"))?; let mut client = OpenShellClient::new(channel.clone()); @@ -366,8 +356,8 @@ async fn run_single_session( // Send hello as the first message. tx.send(SupervisorMessage { payload: Some(supervisor_message::Payload::Hello(SupervisorHello { - sandbox_id: sandbox_id.to_string(), - instance_id: instance_id.to_string(), + sandbox_id: config.sandbox_id.clone(), + instance_id: config.instance_id.clone(), })), }) .await @@ -397,12 +387,11 @@ async fn run_single_session( let heartbeat_secs = accepted.heartbeat_interval_secs.max(5); let event = session_established_event( openshell_ocsf::ctx::ctx(), - endpoint, + &config.endpoint, &accepted.session_id, heartbeat_secs, ); ocsf_emit!(event); - // Main loop: receive gateway messages + send heartbeats. let mut heartbeat_interval = tokio::time::interval(Duration::from_secs(u64::from(heartbeat_secs))); @@ -414,19 +403,19 @@ async fn run_single_session( let msg = match map_session_stream_message( msg, "gateway closed stream", - &terminating, + &config.terminating, )? { SessionStreamMessage::Message(msg) => msg, SessionStreamMessage::ExpectedShutdownClose => return Ok(()), }; let context = GatewayMessageContext { - sandbox_id, - ssh_socket_path, - netns_fd, - expected_ssh_peer_pid, + sandbox_id: &config.sandbox_id, + ssh_socket_path: &config.ssh_socket_path, + netns_fd: config.netns_fd, + expected_ssh_peer_pid: config.expected_ssh_peer_pid, channel: &channel, tx: &tx, - terminating: &terminating, + terminating: &config.terminating, }; handle_gateway_message( &msg, @@ -468,6 +457,25 @@ pub async fn report_main_process_exit( Ok(()) } +/// Confirm terminal delivery and permit ephemeral cleanup. +pub async fn finalize_main_process_exit( + endpoint: &str, + sandbox_id: &str, + instance_id: &str, +) -> Result<(), Box> { + let channel = grpc_client::connect_channel_pub(endpoint) + .await + .map_err(|error| format!("connect failed: {error}"))?; + let mut client = OpenShellClient::new(channel); + client + .finalize_main_process_exit(FinalizeMainProcessExitRequest { + sandbox_id: sandbox_id.to_string(), + instance_id: instance_id.to_string(), + }) + .await?; + Ok(()) +} + struct GatewayMessageContext<'a> { sandbox_id: &'a str, ssh_socket_path: &'a std::path::Path, diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index e90cee8b38..625bc8a41a 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1407,6 +1407,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { labels: HashMap::new(), annotations: HashMap::new(), workspace: workspace.clone(), + await_main_process_attachment: false, }; let sandbox_name = @@ -2705,6 +2706,7 @@ fn phase_label(phase: i32) -> String { x if x == SandboxPhase::Stopping as i32 => "Stopping", x if x == SandboxPhase::Stopped as i32 => "Stopped", x if x == SandboxPhase::Starting as i32 => "Starting", + x if x == SandboxPhase::Completed as i32 => "Completed", _ => "Unknown", } .to_string() @@ -2766,6 +2768,7 @@ mod phase_label_tests { assert_eq!(phase_label(SandboxPhase::Stopping as i32), "Stopping"); assert_eq!(phase_label(SandboxPhase::Stopped as i32), "Stopped"); assert_eq!(phase_label(SandboxPhase::Starting as i32), "Starting"); + assert_eq!(phase_label(SandboxPhase::Completed as i32), "Completed"); } } diff --git a/crates/openshell-tui/src/ui/sandbox_detail.rs b/crates/openshell-tui/src/ui/sandbox_detail.rs index 434f369d39..4ff78617f0 100644 --- a/crates/openshell-tui/src/ui/sandbox_detail.rs +++ b/crates/openshell-tui/src/ui/sandbox_detail.rs @@ -22,7 +22,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) { let age = app.sandbox_ages.get(idx).map_or("-", String::as_str); let phase_style = match phase { - "Ready" => t.status_ok, + "Ready" | "Completed" => t.status_ok, "Provisioning" | "Stopping" | "Starting" => t.status_warn, "Error" => t.status_err, _ => t.muted, @@ -30,6 +30,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) { let status_indicator = match phase { "Ready" => "●", + "Completed" => "✓", "Provisioning" | "Stopping" | "Starting" => "◐", "Error" | "Stopped" => "○", _ => "…", diff --git a/crates/openshell-tui/src/ui/sandboxes.rs b/crates/openshell-tui/src/ui/sandboxes.rs index d927537189..b1d3edc1fe 100644 --- a/crates/openshell-tui/src/ui/sandboxes.rs +++ b/crates/openshell-tui/src/ui/sandboxes.rs @@ -40,7 +40,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { let draft_count = app.sandbox_draft_counts.get(i).copied().unwrap_or(0); let phase_style = match phase { - "Ready" => t.status_ok, + "Ready" | "Completed" => t.status_ok, "Provisioning" | "Stopping" | "Starting" => t.status_warn, "Error" => t.status_err, _ => t.muted, diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index 5773fb2bb1..e453f7f219 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -29,7 +29,9 @@ The gateway forwards one exact, persisted main-process specification to every driver. Drivers serialize that specification in `OPENSHELL_MAIN_PROCESS_SPEC`; they do not install an idle `sleep` workload or reconstruct argv with shell parsing. Runtime restart policies are disabled so -an exited canonical process remains a terminal sandbox error. +an exited canonical process remains a terminal sandbox result. Exit code zero +produces `Completed`; a nonzero or signal-normalized exit produces `Error` +with the exact exit code. Driver and supervisor failures remain `Error`. ## Configure a Compute Driver diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index e5a27879f3..b3ed9f6eaa 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -21,7 +21,9 @@ openshell sandbox create -- claude ``` The trailing command is the sandbox's canonical main process. OpenShell starts -it once and attaches your terminal to it. With no trailing command, OpenShell +it once, streams its output, and returns its exit status. Exit code 0 leaves a +retained sandbox in `Completed`; a nonzero exit leaves it in `Error` with a +`MainProcessFailed` condition. With no trailing command, OpenShell starts `/bin/bash -l` in a retained pseudo-terminal. Add `--detach` to create the sandbox without attaching: @@ -29,6 +31,19 @@ the sandbox without attaching: openshell sandbox create --name worker --detach -- ./worker ``` +Detached commands have no attachment grace period. When the command exits, +OpenShell records its terminal phase immediately. For a foreground command, +the create request declares one expected SSH attachment. OpenShell retains the +terminal transport until that connection drains and closes naturally, then +finalizes ephemeral cleanup. + +Use `--no-keep` for an ephemeral command. OpenShell drains stdout and stderr, +captures the command result, and deletes the sandbox after the command exits: + +```shell +openshell sandbox create --no-keep -- sh -c 'echo done; exit 0' +``` + `--upload` cannot yet be combined with a trailing main command because uploads finish after the canonical process starts. Create a scratch sandbox, upload the files, then launch the workload with `sandbox exec`, or build the files into the @@ -490,8 +505,11 @@ commands, transfer files, forward ports, or reach exposed services. Policies, provider attachments, settings, service definitions, and persistent workspace data remain associated with the sandbox. -Stop and start are idempotent. Delete a stopped sandbox normally when you -no longer need its retained state. +Stop and start are idempotent. You can also start a retained `Completed` or +`Error/MainProcessFailed` sandbox to launch a fresh instance of its canonical +command. Starting a fresh instance invalidates SSH sessions issued for the +previous runtime generation. Delete an inactive sandbox normally when you no +longer need its retained state. ## Delete Sandboxes @@ -510,9 +528,10 @@ Every sandbox moves through a defined set of phases: | Provisioning | The runtime is setting up the sandbox environment, or the gateway is waiting for the sandbox supervisor to establish its authenticated control session. | | Ready | The sandbox is running and its supervisor control session is connected. You can connect, execute commands, sync files, and view logs. | | Stopping | The gateway accepted a stop request and is stopping compute while retaining persistent state. | -| Stopped | Compute is stopped and access is unavailable. The sandbox record and driver-owned persistent workspace remain. | +| Stopped | Compute was stopped explicitly and access is unavailable. | | Starting | Compute is starting. The sandbox becomes usable only after a fresh supervisor session connects. | -| Error | Provisioning failed or the canonical main process exited unexpectedly. Main-process exit is terminal even with exit code 0. Check logs with `openshell logs`. | +| Completed | The canonical main process exited with code 0. Its normalized result is available in `status.exit_code`. | +| Error | The canonical main process failed, or sandbox infrastructure failed. Inspect the condition reason and `status.exit_code`. | | Deleting | The sandbox is being torn down. The system releases resources and purges credentials. | The compute backend can become ready before the sandbox supervisor connects to @@ -522,10 +541,11 @@ After a gateway restart, an existing sandbox can return to `Provisioning` temporarily while its supervisor reconnects. Wait for the phase to return to `Ready` before you connect to the sandbox or execute commands. -The gateway records a canonical main-process exit as `Ready=False` with reason -`MainProcessExited`. It also sets `status.exit_code`; signal exits use the -standard `128 + signal` convention. Compute runtimes do not automatically -restart that process. +The gateway records a successful canonical main-process exit as `Ready=False` +with reason `MainProcessCompleted`. Nonzero and signal-normalized results use +`MainProcessFailed` and the `Error` phase. It also sets `status.exit_code`; +signal exits use the standard `128 + signal` convention. Compute runtimes do +not automatically restart that process. ## Sandbox Compute Drivers diff --git a/e2e/mcp-conformance.sh b/e2e/mcp-conformance.sh index c1b46fe53a..1bbe5951ff 100755 --- a/e2e/mcp-conformance.sh +++ b/e2e/mcp-conformance.sh @@ -338,6 +338,7 @@ create_client_sandbox() { --from "${CLIENT_IMAGE}" \ --policy "${policy_file}" \ --no-tty \ + --detach \ -- sleep infinity; then rm -f "${policy_file}" return 1 diff --git a/e2e/rust/tests/oidc_pkce.rs b/e2e/rust/tests/oidc_pkce.rs index e6f6067a34..f8edc3d7b2 100644 --- a/e2e/rust/tests/oidc_pkce.rs +++ b/e2e/rust/tests/oidc_pkce.rs @@ -28,8 +28,6 @@ use url::Url; static SANDBOX_LIFECYCLE_LOCK: Mutex<()> = Mutex::const_new(()); -const DURABLE_MAIN_SCRIPT: &str = r#"echo "$1"; exec sleep infinity"#; - #[derive(Clone, Copy)] struct IdentityScenario { gateway_name: &'static str, @@ -915,10 +913,7 @@ async fn workspace_user_cannot_create_sandbox_in_another_workspace() { "oidc-xcreate-denied", "--no-tty", "--", - "sh", - "-c", - DURABLE_MAIN_SCRIPT, - "_", + "echo", "denied", ], ) @@ -1388,12 +1383,8 @@ async fn assert_can_create_sandbox(session: &LoginSession, workspace: &str, sand "--name", sandbox_name, "--no-tty", - "--detach", "--", - "sh", - "-c", - DURABLE_MAIN_SCRIPT, - "_", + "echo", &marker, ], ) @@ -1413,6 +1404,10 @@ async fn assert_can_create_sandbox(session: &LoginSession, workspace: &str, sand let list_output = combined_output(&list); let cleanup = run_workspace_cli(session, workspace, &["sandbox", "delete", sandbox_name]).await; + assert!( + create_output.contains(&marker), + "sandbox command output should contain {marker}:\n{create_output}" + ); assert!( list.status.success() && list_output.contains(sandbox_name), "created sandbox {sandbox_name} should appear in the sandbox list:\n{list_output}" @@ -1435,12 +1430,8 @@ async fn assert_can_delete_sandbox(session: &LoginSession, workspace: &str, sand "--name", sandbox_name, "--no-tty", - "--detach", "--", - "sh", - "-c", - DURABLE_MAIN_SCRIPT, - "_", + "echo", &marker, ], ) diff --git a/e2e/rust/tests/sandbox_lifecycle.rs b/e2e/rust/tests/sandbox_lifecycle.rs index 0b0f4e0e66..c920fd42bb 100644 --- a/e2e/rust/tests/sandbox_lifecycle.rs +++ b/e2e/rust/tests/sandbox_lifecycle.rs @@ -220,7 +220,7 @@ async fn sandbox_can_be_deleted_while_stopped() { } #[tokio::test] -async fn canonical_main_exit_transitions_persistent_sandbox_to_error() { +async fn canonical_main_exit_zero_completes_persistent_sandbox() { let mut cmd = openshell_tty_cmd(&["sandbox", "create", "--", "echo", "OK"]); cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); @@ -229,9 +229,10 @@ async fn canonical_main_exit_transitions_persistent_sandbox_to_error() { let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let combined = normalize_output(&format!("{stdout}{stderr}")); + assert!(output.status.success(), "create failed:\n{combined}"); assert!( - !output.status.success(), - "main-process exit must fail create" + combined.contains("OK"), + "main output was not streamed:\n{combined}" ); let sandbox_name = extract_sandbox_name(&combined).expect("sandbox name should be present in output"); @@ -260,13 +261,65 @@ async fn canonical_main_exit_transitions_persistent_sandbox_to_error() { "sandbox get failed:\n{details}" ); assert!( - details.contains("Phase: Error"), + details.contains("Phase: Completed"), "expected terminal sandbox phase:\n{details}" ); delete_sandbox(&sandbox_name).await; } +#[tokio::test] +async fn canonical_main_nonzero_exit_preserves_status() { + let mut cmd = openshell_tty_cmd(&[ + "sandbox", + "create", + "--", + "sh", + "-c", + "echo failed-main; exit 7", + ]); + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + + let output = cmd.output().await.expect("spawn openshell sandbox create"); + let combined = normalize_output(&format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + )); + assert_eq!( + output.status.code(), + Some(7), + "unexpected result:\n{combined}" + ); + assert!( + combined.contains("failed-main"), + "main output was not streamed:\n{combined}" + ); + let sandbox_name = + extract_sandbox_name(&combined).expect("sandbox name should be present in output"); + + let mut get_cmd = openshell_cmd(); + get_cmd + .args(["sandbox", "get", &sandbox_name]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let get_output = get_cmd.output().await.expect("spawn openshell sandbox get"); + let details = normalize_output(&format!( + "{}{}", + String::from_utf8_lossy(&get_output.stdout), + String::from_utf8_lossy(&get_output.stderr), + )); + assert!( + details.contains("Phase: Error"), + "unexpected phase:\n{details}" + ); + assert!( + details.contains("Exit Code: 7"), + "missing exit code:\n{details}" + ); + delete_sandbox(&sandbox_name).await; +} + #[tokio::test] async fn canonical_tty_main_uses_sandbox_environment() { let script = r#"printf 'canonical_env home=%s user=%s term=%s\n' "$HOME" "$USER" "$TERM"; while true; do sleep 1; done"#; @@ -431,9 +484,10 @@ async fn sandbox_create_with_no_keep_cleans_up_after_tty_command() { let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let combined = normalize_output(&format!("{stdout}{stderr}")); + assert!(output.status.success(), "create failed:\n{combined}"); assert!( - !output.status.success(), - "main-process exit must fail create" + combined.contains("OK"), + "main output was not streamed:\n{combined}" ); let sandbox_name = extract_sandbox_name(&combined).expect("sandbox name should be present in output"); diff --git a/e2e/rust/tests/workspace_namespace_managed.rs b/e2e/rust/tests/workspace_namespace_managed.rs index b6d0555f3a..eae13e2cf6 100644 --- a/e2e/rust/tests/workspace_namespace_managed.rs +++ b/e2e/rust/tests/workspace_namespace_managed.rs @@ -148,16 +148,16 @@ async fn managed_creates_namespace_with_labels() { &ws, "--name", "mgd-sb", - "--detach", "--", - "sh", - "-c", - DURABLE_MAIN_SCRIPT, - "_", + "echo", "managed-ok", ]) .await; assert!(ok, "sandbox create failed: {out}"); + assert!( + out.contains("managed-ok"), + "sandbox output missing expected string: {out}" + ); // Verify the managed namespace was created. let (ok, out) = kubectl(&["get", "namespace", &ns]).await; @@ -281,10 +281,7 @@ async fn managed_namespace_survives_with_remaining_sandboxes() { "--name", "sb-a", "--", - "sh", - "-c", - DURABLE_MAIN_SCRIPT, - "_", + "echo", "a", ]) .await; @@ -298,10 +295,7 @@ async fn managed_namespace_survives_with_remaining_sandboxes() { "--name", "sb-b", "--", - "sh", - "-c", - DURABLE_MAIN_SCRIPT, - "_", + "echo", "b", ]) .await; @@ -363,10 +357,7 @@ async fn managed_isolates_workspaces_into_separate_namespaces() { "--name", "sb-iso-a", "--", - "sh", - "-c", - DURABLE_MAIN_SCRIPT, - "_", + "echo", "a", ]) .await; @@ -380,10 +371,7 @@ async fn managed_isolates_workspaces_into_separate_namespaces() { "--name", "sb-iso-b", "--", - "sh", - "-c", - DURABLE_MAIN_SCRIPT, - "_", + "echo", "b", ]) .await; @@ -457,16 +445,16 @@ async fn managed_workspace_delete_removes_namespace() { &ws, "--name", "del-sb", - "--detach", "--", - "sh", - "-c", - DURABLE_MAIN_SCRIPT, - "_", + "echo", "del-ok", ]) .await; assert!(ok, "sandbox create failed: {out}"); + assert!( + out.contains("del-ok"), + "sandbox output missing expected string: {out}" + ); let (ok, _) = kubectl(&["get", "namespace", &ns]).await; assert!( @@ -529,16 +517,16 @@ async fn managed_tls_secret_copied_to_namespace() { &ws, "--name", "tls-sb", - "--detach", "--", - "sh", - "-c", - DURABLE_MAIN_SCRIPT, - "_", + "echo", "tls-ok", ]) .await; assert!(ok, "sandbox create failed: {out}"); + assert!( + out.contains("tls-ok"), + "sandbox output missing expected string: {out}" + ); let (ok, out) = kubectl(&["get", "secret", "openshell-client-tls", "-n", &ns]).await; assert!( @@ -596,10 +584,7 @@ async fn managed_rejects_namespace_owned_by_different_gateway() { "--name", "conflict-sb", "--", - "sh", - "-c", - DURABLE_MAIN_SCRIPT, - "_", + "echo", "nope", ]) .await; @@ -629,10 +614,7 @@ async fn managed_full_lifecycle_with_multiple_sandboxes() { "--name", "lc-a", "--", - "sh", - "-c", - DURABLE_MAIN_SCRIPT, - "_", + "echo", "a", ]) .await; @@ -646,10 +628,7 @@ async fn managed_full_lifecycle_with_multiple_sandboxes() { "--name", "lc-b", "--", - "sh", - "-c", - DURABLE_MAIN_SCRIPT, - "_", + "echo", "b", ]) .await; @@ -727,6 +706,7 @@ async fn managed_stop_waits_for_workspace_pod_to_disappear() { &ws, "--name", sandbox, + "--detach", "--", "sh", "-c", @@ -780,10 +760,7 @@ async fn managed_rejects_invalid_dns1123_sandbox_name() { "--name", "my_bad_name", "--", - "sh", - "-c", - DURABLE_MAIN_SCRIPT, - "_", + "echo", "nope", ]) .await; @@ -804,10 +781,7 @@ async fn managed_rejects_invalid_dns1123_sandbox_name() { "--name", "MyBadName", "--", - "sh", - "-c", - DURABLE_MAIN_SCRIPT, - "_", + "echo", "nope", ]) .await; @@ -825,10 +799,7 @@ async fn managed_rejects_invalid_dns1123_sandbox_name() { "--name", "trailing-", "--", - "sh", - "-c", - DURABLE_MAIN_SCRIPT, - "_", + "echo", "nope", ]) .await; diff --git a/e2e/rust/tests/workspace_namespace_operator.rs b/e2e/rust/tests/workspace_namespace_operator.rs index 315587bacb..a874c1188b 100644 --- a/e2e/rust/tests/workspace_namespace_operator.rs +++ b/e2e/rust/tests/workspace_namespace_operator.rs @@ -18,7 +18,6 @@ use openshell_e2e::harness::output::strip_ansi; const OPERATOR_LABEL: &str = "openshell.ai/e2e-operator-workspace=true"; const SA_NAME: &str = "openshell-sandbox"; -const DURABLE_MAIN_SCRIPT: &str = r#"echo "$1"; exec sleep infinity"#; fn kube_context() -> String { std::env::var("OPENSHELL_E2E_KUBE_CONTEXT_ACTIVE") @@ -163,7 +162,7 @@ async fn operator_sandbox_in_labeled_namespace() { // Poll until the gateway's namespace watcher discovers the labeled namespace // and sandbox creation succeeds (up to 30s). let deadline = tokio::time::Instant::now() + Duration::from_secs(30); - loop { + let sandbox_out = loop { let (ok, out) = run_cli(&[ "sandbox", "create", @@ -171,23 +170,23 @@ async fn operator_sandbox_in_labeled_namespace() { &ns, "--name", "op-sb", - "--detach", "--", - "sh", - "-c", - DURABLE_MAIN_SCRIPT, - "_", + "echo", "operator-ok", ]) .await; if ok { - break; + break out; } if tokio::time::Instant::now() >= deadline { panic!("sandbox create did not succeed within 30s: {out}"); } tokio::time::sleep(Duration::from_secs(2)).await; - } + }; + assert!( + sandbox_out.contains("operator-ok"), + "sandbox output missing expected string: {sandbox_out}" + ); // Verify the sandbox CR lives in the pre-provisioned namespace. let (ok, out) = kubectl(&["get", "sandbox.agents.x-k8s.io", "-n", &ns, "-o", "name"]).await; @@ -255,10 +254,7 @@ async fn operator_rejects_unlabeled_namespace() { "--name", "should-fail", "--", - "sh", - "-c", - DURABLE_MAIN_SCRIPT, - "_", + "echo", "nope", ]) .await; @@ -294,10 +290,7 @@ async fn operator_rejects_nonexistent_namespace() { "--name", "should-fail", "--", - "sh", - "-c", - DURABLE_MAIN_SCRIPT, - "_", + "echo", "nope", ]) .await; @@ -334,10 +327,7 @@ async fn operator_workspace_delete_preserves_namespace() { "--name", "opdel-sb", "--", - "sh", - "-c", - DURABLE_MAIN_SCRIPT, - "_", + "echo", "opdel-ok", ]) .await; @@ -399,10 +389,7 @@ async fn operator_label_removal_blocks_sandbox_creation() { "--name", "lbl-sb1", "--", - "sh", - "-c", - DURABLE_MAIN_SCRIPT, - "_", + "echo", "lbl-ok", ]) .await; @@ -439,10 +426,7 @@ async fn operator_label_removal_blocks_sandbox_creation() { "--name", "lbl-sb2", "--", - "sh", - "-c", - DURABLE_MAIN_SCRIPT, - "_", + "echo", "should-fail", ]) .await; diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index afa93f1b18..e26fafdd6a 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -152,6 +152,9 @@ message DriverSandboxSpec { repeated string command = 12; // Allocate a retained pseudo-terminal for the canonical process. bool tty = 13; + // One-shot launch hint forwarded by the gateway when the creating client + // will attach to the canonical main process. + bool await_main_process_attachment = 14; } message ResourceRequirements { diff --git a/proto/openshell.proto b/proto/openshell.proto index 246fe0626f..32f39adf67 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -471,6 +471,14 @@ service OpenShell { }; } + // Confirm that foreground terminal delivery completed naturally. + rpc FinalizeMainProcessExit(FinalizeMainProcessExitRequest) + returns (FinalizeMainProcessExitResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "sandbox" + }; + } + // Raw byte relay between supervisor and gateway. // // The supervisor initiates this call after receiving a RelayOpen message @@ -907,7 +915,8 @@ message SandboxStatus { // The gateway uses this to reject stale exit reports after a restart. string main_process_instance_id = 8; // Normalized main process result. Signal exits use 128 + signal number. - // Presence indicates that the main process exited and the sandbox is in Error. + // Presence indicates that the canonical main process exited. Exit code 0 + // produces Completed; nonzero and signal-normalized exits produce Error. optional int32 exit_code = 9; } @@ -939,6 +948,8 @@ enum SandboxPhase { SANDBOX_PHASE_STOPPING = 6; SANDBOX_PHASE_STOPPED = 7; SANDBOX_PHASE_STARTING = 8; + // The canonical main process exited successfully and its result is final. + SANDBOX_PHASE_COMPLETED = 9; } // Public platform event exposed on the sandbox watch stream. @@ -968,6 +979,10 @@ message CreateSandboxRequest { map annotations = 4; // Workspace for the sandbox. Empty defaults to "default". string workspace = 5; + // One-shot launch hint indicating that the creating client will attach to + // the canonical main process. The supervisor keeps the terminal transport + // alive until that attachment connects and closes naturally. + bool await_main_process_attachment = 6; } // Get sandbox request. @@ -1355,7 +1370,8 @@ message WatchSandboxRequest { // Replay the last N platform events (best-effort) before following. uint32 event_tail = 6; - // Stop streaming once the sandbox reaches a terminal phase (READY or ERROR). + // Stop streaming once the sandbox reaches READY or a terminal result phase + // (COMPLETED, STOPPED, or ERROR). bool stop_on_terminal = 7; // Only include log lines with timestamp >= this value (milliseconds since epoch). @@ -2240,6 +2256,15 @@ message ReportMainProcessExitRequest { message ReportMainProcessExitResponse {} +// Terminal-delivery completion reported after all expected foreground SSH +// attachments close. A successful response permits ephemeral cleanup. +message FinalizeMainProcessExitRequest { + string sandbox_id = 1; + string instance_id = 2; +} + +message FinalizeMainProcessExitResponse {} + // Gateway requests the supervisor to open a relay channel. // // On receiving this, the supervisor should initiate a RelayStream RPC to diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index 94f3c57def..58856de454 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -887,6 +887,18 @@ def _wait_for_phase( sandbox = self.get(sandbox_name, workspace=workspace) if sandbox.status.phase == target_phase: return sandbox + if ( + target_phase == openshell_pb2.SANDBOX_PHASE_READY + and sandbox.status.phase == openshell_pb2.SANDBOX_PHASE_COMPLETED + ): + return sandbox + if ( + target_phase == openshell_pb2.SANDBOX_PHASE_READY + and sandbox.status.phase == openshell_pb2.SANDBOX_PHASE_STOPPED + ): + raise SandboxError( + f"sandbox {sandbox_name} stopped before becoming ready" + ) if sandbox.status.phase == openshell_pb2.SANDBOX_PHASE_ERROR: raise SandboxError(f"sandbox {sandbox_name} entered error phase") time.sleep(1) diff --git a/python/openshell/sandbox_test.py b/python/openshell/sandbox_test.py index d70aae49cc..23382d1093 100644 --- a/python/openshell/sandbox_test.py +++ b/python/openshell/sandbox_test.py @@ -2146,6 +2146,41 @@ def test_stop_and_start_forward_workspace_and_return_phase() -> None: assert starting.phase == openshell_pb2.SANDBOX_PHASE_STARTING +@pytest.mark.parametrize( + ("phase", "should_succeed"), + [ + (openshell_pb2.SANDBOX_PHASE_COMPLETED, True), + (openshell_pb2.SANDBOX_PHASE_ERROR, False), + ], +) +def test_wait_ready_handles_terminal_main_process_results( + phase: openshell_pb2.SandboxPhase, should_succeed: bool +) -> None: + class TerminalStub(_FakeSandboxStub): + def GetSandbox( + self, + request: openshell_pb2.GetSandboxRequest, + timeout: float | None = None, + ) -> Any: + _ = timeout + return SimpleNamespace( + sandbox=_make_sandbox_proto( + "sandbox-1", + request.name, + phase=phase, + workspace=request.workspace, + ) + ) + + client = _client_with_fake_stub(TerminalStub()) + if should_succeed: + result = client.wait_ready("job-1", workspace="default", timeout_seconds=0.1) + assert result.phase == openshell_pb2.SANDBOX_PHASE_COMPLETED + else: + with pytest.raises(SandboxError, match="entered error phase"): + client.wait_ready("job-1", workspace="default", timeout_seconds=0.1) + + def test_create_without_args_sends_empty_metadata() -> None: stub = _FakeSandboxStub() client = _client_with_fake_stub(stub) diff --git a/sdk/go/openshell/v1/fake/sandbox.go b/sdk/go/openshell/v1/fake/sandbox.go index da32adf4c5..6fa0c316a9 100644 --- a/sdk/go/openshell/v1/fake/sandbox.go +++ b/sdk/go/openshell/v1/fake/sandbox.go @@ -504,8 +504,8 @@ func (c *fakeSandboxClient) WaitReady(ctx context.Context, workspace, name strin // Watch registers a watcher for sandbox events. If name is non-empty, only // events for that sandbox are delivered. When StopOnTerminal is set, the -// watcher auto-closes after delivering a terminal phase event (SandboxReady -// or SandboxError). +// watcher auto-closes after delivering a terminal phase event (SandboxReady, +// SandboxCompleted, SandboxStopped, or SandboxError). func (c *fakeSandboxClient) Watch(ctx context.Context, _, name string, opts ...v1.WatchOptions) (types.WatchInterface[*types.Sandbox], error) { if c.closedFunc() { return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} @@ -547,7 +547,7 @@ func (c *fakeSandboxClient) Watch(ctx context.Context, _, name string, opts ...v return } if ev.Object != nil && - (ev.Object.Status.Phase == types.SandboxReady || ev.Object.Status.Phase == types.SandboxError) { + (ev.Object.Status.Phase == types.SandboxReady || ev.Object.Status.Phase == types.SandboxCompleted || ev.Object.Status.Phase == types.SandboxStopped || ev.Object.Status.Phase == types.SandboxError) { inner.Stop() return } diff --git a/sdk/go/openshell/v1/internal/converter/coverage_test.go b/sdk/go/openshell/v1/internal/converter/coverage_test.go index 6481454d6d..ea9add6d21 100644 --- a/sdk/go/openshell/v1/internal/converter/coverage_test.go +++ b/sdk/go/openshell/v1/internal/converter/coverage_test.go @@ -63,8 +63,8 @@ func TestConverterCoversAllProtoFields_SandboxStatus(t *testing.T) { "current_policy_version": true, "exit_code": true, } - // The instance ID is an internal gateway/supervisor fencing token exposed - // only through the raw protobuf API. + // The instance ID coordinates internal gateway/supervisor lifecycle + // fencing. It is exposed only through the raw protobuf API. skipped := fieldSet{"main_process_instance_id": true} assertAllFieldsCovered(t, (&pb.SandboxStatus{}).ProtoReflect().Descriptor(), handled, skipped) diff --git a/sdk/go/openshell/v1/internal/converter/sandbox.go b/sdk/go/openshell/v1/internal/converter/sandbox.go index 5d26a1a7e2..6b61053295 100644 --- a/sdk/go/openshell/v1/internal/converter/sandbox.go +++ b/sdk/go/openshell/v1/internal/converter/sandbox.go @@ -125,6 +125,8 @@ func SandboxPhaseFromProto(phase pb.SandboxPhase) types.SandboxPhase { return types.SandboxStopped case pb.SandboxPhase_SANDBOX_PHASE_STARTING: return types.SandboxStarting + case pb.SandboxPhase_SANDBOX_PHASE_COMPLETED: + return types.SandboxCompleted default: return types.SandboxUnknown } @@ -149,6 +151,8 @@ func SandboxPhaseToProto(phase types.SandboxPhase) pb.SandboxPhase { return pb.SandboxPhase_SANDBOX_PHASE_STOPPED case types.SandboxStarting: return pb.SandboxPhase_SANDBOX_PHASE_STARTING + case types.SandboxCompleted: + return pb.SandboxPhase_SANDBOX_PHASE_COMPLETED default: return pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN } diff --git a/sdk/go/openshell/v1/internal/converter/sandbox_test.go b/sdk/go/openshell/v1/internal/converter/sandbox_test.go index 8293a784c0..9f68013845 100644 --- a/sdk/go/openshell/v1/internal/converter/sandbox_test.go +++ b/sdk/go/openshell/v1/internal/converter/sandbox_test.go @@ -194,6 +194,7 @@ func TestSandboxPhaseFromProto(t *testing.T) { {pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN, v1.SandboxUnknown}, {pb.SandboxPhase_SANDBOX_PHASE_STOPPING, v1.SandboxStopping}, {pb.SandboxPhase_SANDBOX_PHASE_STOPPED, v1.SandboxStopped}, + {pb.SandboxPhase_SANDBOX_PHASE_COMPLETED, v1.SandboxCompleted}, {pb.SandboxPhase_SANDBOX_PHASE_STARTING, v1.SandboxStarting}, {pb.SandboxPhase_SANDBOX_PHASE_UNSPECIFIED, v1.SandboxUnknown}, {pb.SandboxPhase(999), v1.SandboxUnknown}, @@ -216,6 +217,7 @@ func TestSandboxPhaseToProto(t *testing.T) { {v1.SandboxUnknown, pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN}, {v1.SandboxStopping, pb.SandboxPhase_SANDBOX_PHASE_STOPPING}, {v1.SandboxStopped, pb.SandboxPhase_SANDBOX_PHASE_STOPPED}, + {v1.SandboxCompleted, pb.SandboxPhase_SANDBOX_PHASE_COMPLETED}, {v1.SandboxStarting, pb.SandboxPhase_SANDBOX_PHASE_STARTING}, {v1.SandboxPhase("bogus"), pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN}, } diff --git a/sdk/go/openshell/v1/sandbox_client.go b/sdk/go/openshell/v1/sandbox_client.go index 94d6047a01..f31cd40af9 100644 --- a/sdk/go/openshell/v1/sandbox_client.go +++ b/sdk/go/openshell/v1/sandbox_client.go @@ -214,6 +214,13 @@ func checkTerminalPhase(sb *Sandbox, name string, target SandboxPhase) (*Sandbox return sb, nil } switch sb.Status.Phase { + case SandboxCompleted: + if target == SandboxReady { + return sb, nil + } + return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q completed before reaching %s", name, target)} + case SandboxStopped: + return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q stopped before reaching %s", name, target)} case SandboxError: return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q is in error state", name)} case SandboxDeleting: @@ -278,7 +285,7 @@ func (s *sandboxClient) Watch(ctx context.Context, workspace, name string, opts case <-w.done: return } - if watchOpts.StopOnTerminal && (sandbox.Status.Phase == SandboxReady || sandbox.Status.Phase == SandboxError) { + if watchOpts.StopOnTerminal && (sandbox.Status.Phase == SandboxReady || sandbox.Status.Phase == SandboxCompleted || sandbox.Status.Phase == SandboxStopped || sandbox.Status.Phase == SandboxError) { w.Stop() return } diff --git a/sdk/go/openshell/v1/sandbox_client_test.go b/sdk/go/openshell/v1/sandbox_client_test.go index c3725afad6..91e80db148 100644 --- a/sdk/go/openshell/v1/sandbox_client_test.go +++ b/sdk/go/openshell/v1/sandbox_client_test.go @@ -633,6 +633,36 @@ func TestSandboxWaitReady_SandboxFailed(t *testing.T) { require.Error(t, err) } +func TestSandboxWaitReady_SandboxCompleted(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["complete-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "complete-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_COMPLETED}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.WaitReady(context.Background(), "default", "complete-sb") + + require.NoError(t, err) + assert.Equal(t, SandboxCompleted, result.Status.Phase) +} + +func TestSandboxWaitReady_SandboxStopped(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["stopped-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "stopped-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_STOPPED}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.WaitReady(context.Background(), "default", "stopped-sb") + + require.Error(t, err) + assert.Contains(t, err.Error(), "stopped") +} + func TestSandboxWaitReady_SandboxDeleting(t *testing.T) { mock := newMockSandboxServer() mock.sandboxes["deleting-sb"] = &pb.Sandbox{ diff --git a/sdk/go/openshell/v1/types.go b/sdk/go/openshell/v1/types.go index dea7872a04..6226aa0707 100644 --- a/sdk/go/openshell/v1/types.go +++ b/sdk/go/openshell/v1/types.go @@ -20,6 +20,7 @@ const ( SandboxStopping = types.SandboxStopping SandboxStopped = types.SandboxStopped SandboxStarting = types.SandboxStarting + SandboxCompleted = types.SandboxCompleted ) // EventType classifies watch events. diff --git a/sdk/go/openshell/v1/types/types.go b/sdk/go/openshell/v1/types/types.go index 4e3b830805..5b32ec3685 100644 --- a/sdk/go/openshell/v1/types/types.go +++ b/sdk/go/openshell/v1/types/types.go @@ -18,6 +18,7 @@ const ( SandboxStopping SandboxPhase = "Stopping" SandboxStopped SandboxPhase = "Stopped" SandboxStarting SandboxPhase = "Starting" + SandboxCompleted SandboxPhase = "Completed" ) // EventType classifies watch events. diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index 6b3b740a0b..1b77e0889e 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -44,6 +44,8 @@ const ( SandboxPhase_SANDBOX_PHASE_STOPPING SandboxPhase = 6 SandboxPhase_SANDBOX_PHASE_STOPPED SandboxPhase = 7 SandboxPhase_SANDBOX_PHASE_STARTING SandboxPhase = 8 + // The canonical main process exited successfully and its result is final. + SandboxPhase_SANDBOX_PHASE_COMPLETED SandboxPhase = 9 ) // Enum value maps for SandboxPhase. @@ -58,6 +60,7 @@ var ( 6: "SANDBOX_PHASE_STOPPING", 7: "SANDBOX_PHASE_STOPPED", 8: "SANDBOX_PHASE_STARTING", + 9: "SANDBOX_PHASE_COMPLETED", } SandboxPhase_value = map[string]int32{ "SANDBOX_PHASE_UNSPECIFIED": 0, @@ -69,6 +72,7 @@ var ( "SANDBOX_PHASE_STOPPING": 6, "SANDBOX_PHASE_STOPPED": 7, "SANDBOX_PHASE_STARTING": 8, + "SANDBOX_PHASE_COMPLETED": 9, } ) @@ -1559,7 +1563,8 @@ type SandboxStatus struct { // The gateway uses this to reject stale exit reports after a restart. MainProcessInstanceId string `protobuf:"bytes,8,opt,name=main_process_instance_id,json=mainProcessInstanceId,proto3" json:"main_process_instance_id,omitempty"` // Normalized main process result. Signal exits use 128 + signal number. - // Presence indicates that the main process exited and the sandbox is in Error. + // Presence indicates that the canonical main process exited. Exit code 0 + // produces Completed; nonzero and signal-normalized exits produce Error. ExitCode *int32 `protobuf:"varint,9,opt,name=exit_code,json=exitCode,proto3,oneof" json:"exit_code,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -1842,9 +1847,13 @@ type CreateSandboxRequest struct { // Optional annotations for the sandbox (non-selector metadata). Annotations map[string]string `protobuf:"bytes,4,rep,name=annotations,proto3" json:"annotations,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // Workspace for the sandbox. Empty defaults to "default". - Workspace string `protobuf:"bytes,5,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Workspace string `protobuf:"bytes,5,opt,name=workspace,proto3" json:"workspace,omitempty"` + // One-shot launch hint indicating that the creating client will attach to + // the canonical main process. The supervisor keeps the terminal transport + // alive until that attachment connects and closes naturally. + AwaitMainProcessAttachment bool `protobuf:"varint,6,opt,name=await_main_process_attachment,json=awaitMainProcessAttachment,proto3" json:"await_main_process_attachment,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CreateSandboxRequest) Reset() { @@ -1912,6 +1921,13 @@ func (x *CreateSandboxRequest) GetWorkspace() string { return "" } +func (x *CreateSandboxRequest) GetAwaitMainProcessAttachment() bool { + if x != nil { + return x.AwaitMainProcessAttachment + } + return false +} + // Get sandbox request. type GetSandboxRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -4283,7 +4299,8 @@ type WatchSandboxRequest struct { LogTailLines uint32 `protobuf:"varint,5,opt,name=log_tail_lines,json=logTailLines,proto3" json:"log_tail_lines,omitempty"` // Replay the last N platform events (best-effort) before following. EventTail uint32 `protobuf:"varint,6,opt,name=event_tail,json=eventTail,proto3" json:"event_tail,omitempty"` - // Stop streaming once the sandbox reaches a terminal phase (READY or ERROR). + // Stop streaming once the sandbox reaches READY or a terminal result phase + // (COMPLETED, STOPPED, or ERROR). StopOnTerminal bool `protobuf:"varint,7,opt,name=stop_on_terminal,json=stopOnTerminal,proto3" json:"stop_on_terminal,omitempty"` // Only include log lines with timestamp >= this value (milliseconds since epoch). // 0 means no time filter. Applies to both tail replay and live streaming. @@ -10075,6 +10092,96 @@ func (*ReportMainProcessExitResponse) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{138} } +// Terminal-delivery completion reported after all expected foreground SSH +// attachments close. A successful response permits ephemeral cleanup. +type FinalizeMainProcessExitRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + InstanceId string `protobuf:"bytes,2,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FinalizeMainProcessExitRequest) Reset() { + *x = FinalizeMainProcessExitRequest{} + mi := &file_openshell_proto_msgTypes[139] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FinalizeMainProcessExitRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FinalizeMainProcessExitRequest) ProtoMessage() {} + +func (x *FinalizeMainProcessExitRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[139] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FinalizeMainProcessExitRequest.ProtoReflect.Descriptor instead. +func (*FinalizeMainProcessExitRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{139} +} + +func (x *FinalizeMainProcessExitRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *FinalizeMainProcessExitRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +type FinalizeMainProcessExitResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FinalizeMainProcessExitResponse) Reset() { + *x = FinalizeMainProcessExitResponse{} + mi := &file_openshell_proto_msgTypes[140] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FinalizeMainProcessExitResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FinalizeMainProcessExitResponse) ProtoMessage() {} + +func (x *FinalizeMainProcessExitResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[140] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FinalizeMainProcessExitResponse.ProtoReflect.Descriptor instead. +func (*FinalizeMainProcessExitResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{140} +} + // Gateway requests the supervisor to open a relay channel. // // On receiving this, the supervisor should initiate a RelayStream RPC to @@ -10101,7 +10208,7 @@ type RelayOpen struct { func (x *RelayOpen) Reset() { *x = RelayOpen{} - mi := &file_openshell_proto_msgTypes[139] + mi := &file_openshell_proto_msgTypes[141] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10113,7 +10220,7 @@ func (x *RelayOpen) String() string { func (*RelayOpen) ProtoMessage() {} func (x *RelayOpen) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[139] + mi := &file_openshell_proto_msgTypes[141] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10126,7 +10233,7 @@ func (x *RelayOpen) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayOpen.ProtoReflect.Descriptor instead. func (*RelayOpen) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{139} + return file_openshell_proto_rawDescGZIP(), []int{141} } func (x *RelayOpen) GetChannelId() string { @@ -10193,7 +10300,7 @@ type SshRelayTarget struct { func (x *SshRelayTarget) Reset() { *x = SshRelayTarget{} - mi := &file_openshell_proto_msgTypes[140] + mi := &file_openshell_proto_msgTypes[142] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10205,7 +10312,7 @@ func (x *SshRelayTarget) String() string { func (*SshRelayTarget) ProtoMessage() {} func (x *SshRelayTarget) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[140] + mi := &file_openshell_proto_msgTypes[142] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10218,7 +10325,7 @@ func (x *SshRelayTarget) ProtoReflect() protoreflect.Message { // Deprecated: Use SshRelayTarget.ProtoReflect.Descriptor instead. func (*SshRelayTarget) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{140} + return file_openshell_proto_rawDescGZIP(), []int{142} } // TCP target dialed by the supervisor from inside the sandbox. @@ -10234,7 +10341,7 @@ type TcpRelayTarget struct { func (x *TcpRelayTarget) Reset() { *x = TcpRelayTarget{} - mi := &file_openshell_proto_msgTypes[141] + mi := &file_openshell_proto_msgTypes[143] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10246,7 +10353,7 @@ func (x *TcpRelayTarget) String() string { func (*TcpRelayTarget) ProtoMessage() {} func (x *TcpRelayTarget) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[141] + mi := &file_openshell_proto_msgTypes[143] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10259,7 +10366,7 @@ func (x *TcpRelayTarget) ProtoReflect() protoreflect.Message { // Deprecated: Use TcpRelayTarget.ProtoReflect.Descriptor instead. func (*TcpRelayTarget) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{141} + return file_openshell_proto_rawDescGZIP(), []int{143} } func (x *TcpRelayTarget) GetHost() string { @@ -10287,7 +10394,7 @@ type RelayInit struct { func (x *RelayInit) Reset() { *x = RelayInit{} - mi := &file_openshell_proto_msgTypes[142] + mi := &file_openshell_proto_msgTypes[144] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10299,7 +10406,7 @@ func (x *RelayInit) String() string { func (*RelayInit) ProtoMessage() {} func (x *RelayInit) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[142] + mi := &file_openshell_proto_msgTypes[144] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10312,7 +10419,7 @@ func (x *RelayInit) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayInit.ProtoReflect.Descriptor instead. func (*RelayInit) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{142} + return file_openshell_proto_rawDescGZIP(), []int{144} } func (x *RelayInit) GetChannelId() string { @@ -10339,7 +10446,7 @@ type RelayFrame struct { func (x *RelayFrame) Reset() { *x = RelayFrame{} - mi := &file_openshell_proto_msgTypes[143] + mi := &file_openshell_proto_msgTypes[145] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10351,7 +10458,7 @@ func (x *RelayFrame) String() string { func (*RelayFrame) ProtoMessage() {} func (x *RelayFrame) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[143] + mi := &file_openshell_proto_msgTypes[145] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10364,7 +10471,7 @@ func (x *RelayFrame) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayFrame.ProtoReflect.Descriptor instead. func (*RelayFrame) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{143} + return file_openshell_proto_rawDescGZIP(), []int{145} } func (x *RelayFrame) GetPayload() isRelayFrame_Payload { @@ -10423,7 +10530,7 @@ type RelayOpenResult struct { func (x *RelayOpenResult) Reset() { *x = RelayOpenResult{} - mi := &file_openshell_proto_msgTypes[144] + mi := &file_openshell_proto_msgTypes[146] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10435,7 +10542,7 @@ func (x *RelayOpenResult) String() string { func (*RelayOpenResult) ProtoMessage() {} func (x *RelayOpenResult) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[144] + mi := &file_openshell_proto_msgTypes[146] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10448,7 +10555,7 @@ func (x *RelayOpenResult) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayOpenResult.ProtoReflect.Descriptor instead. func (*RelayOpenResult) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{144} + return file_openshell_proto_rawDescGZIP(), []int{146} } func (x *RelayOpenResult) GetChannelId() string { @@ -10485,7 +10592,7 @@ type RelayClose struct { func (x *RelayClose) Reset() { *x = RelayClose{} - mi := &file_openshell_proto_msgTypes[145] + mi := &file_openshell_proto_msgTypes[147] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10497,7 +10604,7 @@ func (x *RelayClose) String() string { func (*RelayClose) ProtoMessage() {} func (x *RelayClose) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[145] + mi := &file_openshell_proto_msgTypes[147] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10510,7 +10617,7 @@ func (x *RelayClose) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayClose.ProtoReflect.Descriptor instead. func (*RelayClose) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{145} + return file_openshell_proto_rawDescGZIP(), []int{147} } func (x *RelayClose) GetChannelId() string { @@ -10544,7 +10651,7 @@ type L7RequestSample struct { func (x *L7RequestSample) Reset() { *x = L7RequestSample{} - mi := &file_openshell_proto_msgTypes[146] + mi := &file_openshell_proto_msgTypes[148] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10556,7 +10663,7 @@ func (x *L7RequestSample) String() string { func (*L7RequestSample) ProtoMessage() {} func (x *L7RequestSample) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[146] + mi := &file_openshell_proto_msgTypes[148] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10569,7 +10676,7 @@ func (x *L7RequestSample) ProtoReflect() protoreflect.Message { // Deprecated: Use L7RequestSample.ProtoReflect.Descriptor instead. func (*L7RequestSample) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{146} + return file_openshell_proto_rawDescGZIP(), []int{148} } func (x *L7RequestSample) GetMethod() string { @@ -10643,7 +10750,7 @@ type DenialSummary struct { func (x *DenialSummary) Reset() { *x = DenialSummary{} - mi := &file_openshell_proto_msgTypes[147] + mi := &file_openshell_proto_msgTypes[149] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10655,7 +10762,7 @@ func (x *DenialSummary) String() string { func (*DenialSummary) ProtoMessage() {} func (x *DenialSummary) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[147] + mi := &file_openshell_proto_msgTypes[149] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10668,7 +10775,7 @@ func (x *DenialSummary) ProtoReflect() protoreflect.Message { // Deprecated: Use DenialSummary.ProtoReflect.Descriptor instead. func (*DenialSummary) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{147} + return file_openshell_proto_rawDescGZIP(), []int{149} } func (x *DenialSummary) GetSandboxId() string { @@ -10803,7 +10910,7 @@ type DenialGroupCount struct { func (x *DenialGroupCount) Reset() { *x = DenialGroupCount{} - mi := &file_openshell_proto_msgTypes[148] + mi := &file_openshell_proto_msgTypes[150] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10815,7 +10922,7 @@ func (x *DenialGroupCount) String() string { func (*DenialGroupCount) ProtoMessage() {} func (x *DenialGroupCount) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[148] + mi := &file_openshell_proto_msgTypes[150] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10828,7 +10935,7 @@ func (x *DenialGroupCount) ProtoReflect() protoreflect.Message { // Deprecated: Use DenialGroupCount.ProtoReflect.Descriptor instead. func (*DenialGroupCount) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{148} + return file_openshell_proto_rawDescGZIP(), []int{150} } func (x *DenialGroupCount) GetDenyGroup() string { @@ -10861,7 +10968,7 @@ type NetworkActivitySummary struct { func (x *NetworkActivitySummary) Reset() { *x = NetworkActivitySummary{} - mi := &file_openshell_proto_msgTypes[149] + mi := &file_openshell_proto_msgTypes[151] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10873,7 +10980,7 @@ func (x *NetworkActivitySummary) String() string { func (*NetworkActivitySummary) ProtoMessage() {} func (x *NetworkActivitySummary) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[149] + mi := &file_openshell_proto_msgTypes[151] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10886,7 +10993,7 @@ func (x *NetworkActivitySummary) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkActivitySummary.ProtoReflect.Descriptor instead. func (*NetworkActivitySummary) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{149} + return file_openshell_proto_rawDescGZIP(), []int{151} } func (x *NetworkActivitySummary) GetNetworkActivityCount() uint32 { @@ -10974,7 +11081,7 @@ type PolicyChunk struct { func (x *PolicyChunk) Reset() { *x = PolicyChunk{} - mi := &file_openshell_proto_msgTypes[150] + mi := &file_openshell_proto_msgTypes[152] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10986,7 +11093,7 @@ func (x *PolicyChunk) String() string { func (*PolicyChunk) ProtoMessage() {} func (x *PolicyChunk) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[150] + mi := &file_openshell_proto_msgTypes[152] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10999,7 +11106,7 @@ func (x *PolicyChunk) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyChunk.ProtoReflect.Descriptor instead. func (*PolicyChunk) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{150} + return file_openshell_proto_rawDescGZIP(), []int{152} } func (x *PolicyChunk) GetId() string { @@ -11187,7 +11294,7 @@ type DraftPolicyUpdate struct { func (x *DraftPolicyUpdate) Reset() { *x = DraftPolicyUpdate{} - mi := &file_openshell_proto_msgTypes[151] + mi := &file_openshell_proto_msgTypes[153] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11199,7 +11306,7 @@ func (x *DraftPolicyUpdate) String() string { func (*DraftPolicyUpdate) ProtoMessage() {} func (x *DraftPolicyUpdate) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[151] + mi := &file_openshell_proto_msgTypes[153] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11212,7 +11319,7 @@ func (x *DraftPolicyUpdate) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftPolicyUpdate.ProtoReflect.Descriptor instead. func (*DraftPolicyUpdate) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{151} + return file_openshell_proto_rawDescGZIP(), []int{153} } func (x *DraftPolicyUpdate) GetDraftVersion() uint64 { @@ -11270,7 +11377,7 @@ type SubmitPolicyAnalysisRequest struct { func (x *SubmitPolicyAnalysisRequest) Reset() { *x = SubmitPolicyAnalysisRequest{} - mi := &file_openshell_proto_msgTypes[152] + mi := &file_openshell_proto_msgTypes[154] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11282,7 +11389,7 @@ func (x *SubmitPolicyAnalysisRequest) String() string { func (*SubmitPolicyAnalysisRequest) ProtoMessage() {} func (x *SubmitPolicyAnalysisRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[152] + mi := &file_openshell_proto_msgTypes[154] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11295,7 +11402,7 @@ func (x *SubmitPolicyAnalysisRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitPolicyAnalysisRequest.ProtoReflect.Descriptor instead. func (*SubmitPolicyAnalysisRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{152} + return file_openshell_proto_rawDescGZIP(), []int{154} } func (x *SubmitPolicyAnalysisRequest) GetSummaries() []*DenialSummary { @@ -11358,7 +11465,7 @@ type SubmitPolicyAnalysisResponse struct { func (x *SubmitPolicyAnalysisResponse) Reset() { *x = SubmitPolicyAnalysisResponse{} - mi := &file_openshell_proto_msgTypes[153] + mi := &file_openshell_proto_msgTypes[155] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11370,7 +11477,7 @@ func (x *SubmitPolicyAnalysisResponse) String() string { func (*SubmitPolicyAnalysisResponse) ProtoMessage() {} func (x *SubmitPolicyAnalysisResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[153] + mi := &file_openshell_proto_msgTypes[155] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11383,7 +11490,7 @@ func (x *SubmitPolicyAnalysisResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitPolicyAnalysisResponse.ProtoReflect.Descriptor instead. func (*SubmitPolicyAnalysisResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{153} + return file_openshell_proto_rawDescGZIP(), []int{155} } func (x *SubmitPolicyAnalysisResponse) GetAcceptedChunks() uint32 { @@ -11429,7 +11536,7 @@ type GetDraftPolicyRequest struct { func (x *GetDraftPolicyRequest) Reset() { *x = GetDraftPolicyRequest{} - mi := &file_openshell_proto_msgTypes[154] + mi := &file_openshell_proto_msgTypes[156] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11441,7 +11548,7 @@ func (x *GetDraftPolicyRequest) String() string { func (*GetDraftPolicyRequest) ProtoMessage() {} func (x *GetDraftPolicyRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[154] + mi := &file_openshell_proto_msgTypes[156] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11454,7 +11561,7 @@ func (x *GetDraftPolicyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftPolicyRequest.ProtoReflect.Descriptor instead. func (*GetDraftPolicyRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{154} + return file_openshell_proto_rawDescGZIP(), []int{156} } func (x *GetDraftPolicyRequest) GetName() string { @@ -11494,7 +11601,7 @@ type GetDraftPolicyResponse struct { func (x *GetDraftPolicyResponse) Reset() { *x = GetDraftPolicyResponse{} - mi := &file_openshell_proto_msgTypes[155] + mi := &file_openshell_proto_msgTypes[157] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11506,7 +11613,7 @@ func (x *GetDraftPolicyResponse) String() string { func (*GetDraftPolicyResponse) ProtoMessage() {} func (x *GetDraftPolicyResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[155] + mi := &file_openshell_proto_msgTypes[157] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11519,7 +11626,7 @@ func (x *GetDraftPolicyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftPolicyResponse.ProtoReflect.Descriptor instead. func (*GetDraftPolicyResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{155} + return file_openshell_proto_rawDescGZIP(), []int{157} } func (x *GetDraftPolicyResponse) GetChunks() []*PolicyChunk { @@ -11568,7 +11675,7 @@ type ApproveDraftChunkRequest struct { func (x *ApproveDraftChunkRequest) Reset() { *x = ApproveDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[156] + mi := &file_openshell_proto_msgTypes[158] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11580,7 +11687,7 @@ func (x *ApproveDraftChunkRequest) String() string { func (*ApproveDraftChunkRequest) ProtoMessage() {} func (x *ApproveDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[156] + mi := &file_openshell_proto_msgTypes[158] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11593,7 +11700,7 @@ func (x *ApproveDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveDraftChunkRequest.ProtoReflect.Descriptor instead. func (*ApproveDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{156} + return file_openshell_proto_rawDescGZIP(), []int{158} } func (x *ApproveDraftChunkRequest) GetName() string { @@ -11636,7 +11743,7 @@ type ApproveDraftChunkResponse struct { func (x *ApproveDraftChunkResponse) Reset() { *x = ApproveDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[157] + mi := &file_openshell_proto_msgTypes[159] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11648,7 +11755,7 @@ func (x *ApproveDraftChunkResponse) String() string { func (*ApproveDraftChunkResponse) ProtoMessage() {} func (x *ApproveDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[157] + mi := &file_openshell_proto_msgTypes[159] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11661,7 +11768,7 @@ func (x *ApproveDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveDraftChunkResponse.ProtoReflect.Descriptor instead. func (*ApproveDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{157} + return file_openshell_proto_rawDescGZIP(), []int{159} } func (x *ApproveDraftChunkResponse) GetPolicyVersion() uint32 { @@ -11695,7 +11802,7 @@ type RejectDraftChunkRequest struct { func (x *RejectDraftChunkRequest) Reset() { *x = RejectDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[158] + mi := &file_openshell_proto_msgTypes[160] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11707,7 +11814,7 @@ func (x *RejectDraftChunkRequest) String() string { func (*RejectDraftChunkRequest) ProtoMessage() {} func (x *RejectDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[158] + mi := &file_openshell_proto_msgTypes[160] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11720,7 +11827,7 @@ func (x *RejectDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RejectDraftChunkRequest.ProtoReflect.Descriptor instead. func (*RejectDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{158} + return file_openshell_proto_rawDescGZIP(), []int{160} } func (x *RejectDraftChunkRequest) GetName() string { @@ -11759,7 +11866,7 @@ type RejectDraftChunkResponse struct { func (x *RejectDraftChunkResponse) Reset() { *x = RejectDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[159] + mi := &file_openshell_proto_msgTypes[161] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11771,7 +11878,7 @@ func (x *RejectDraftChunkResponse) String() string { func (*RejectDraftChunkResponse) ProtoMessage() {} func (x *RejectDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[159] + mi := &file_openshell_proto_msgTypes[161] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11784,7 +11891,7 @@ func (x *RejectDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RejectDraftChunkResponse.ProtoReflect.Descriptor instead. func (*RejectDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{159} + return file_openshell_proto_rawDescGZIP(), []int{161} } // Approve all pending chunks. @@ -11798,7 +11905,7 @@ type DraftChunkApproval struct { func (x *DraftChunkApproval) Reset() { *x = DraftChunkApproval{} - mi := &file_openshell_proto_msgTypes[160] + mi := &file_openshell_proto_msgTypes[162] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11810,7 +11917,7 @@ func (x *DraftChunkApproval) String() string { func (*DraftChunkApproval) ProtoMessage() {} func (x *DraftChunkApproval) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[160] + mi := &file_openshell_proto_msgTypes[162] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11823,7 +11930,7 @@ func (x *DraftChunkApproval) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftChunkApproval.ProtoReflect.Descriptor instead. func (*DraftChunkApproval) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{160} + return file_openshell_proto_rawDescGZIP(), []int{162} } func (x *DraftChunkApproval) GetChunkId() string { @@ -11857,7 +11964,7 @@ type ApproveAllDraftChunksRequest struct { func (x *ApproveAllDraftChunksRequest) Reset() { *x = ApproveAllDraftChunksRequest{} - mi := &file_openshell_proto_msgTypes[161] + mi := &file_openshell_proto_msgTypes[163] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11869,7 +11976,7 @@ func (x *ApproveAllDraftChunksRequest) String() string { func (*ApproveAllDraftChunksRequest) ProtoMessage() {} func (x *ApproveAllDraftChunksRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[161] + mi := &file_openshell_proto_msgTypes[163] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11882,7 +11989,7 @@ func (x *ApproveAllDraftChunksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveAllDraftChunksRequest.ProtoReflect.Descriptor instead. func (*ApproveAllDraftChunksRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{161} + return file_openshell_proto_rawDescGZIP(), []int{163} } func (x *ApproveAllDraftChunksRequest) GetName() string { @@ -11930,7 +12037,7 @@ type ApproveAllDraftChunksResponse struct { func (x *ApproveAllDraftChunksResponse) Reset() { *x = ApproveAllDraftChunksResponse{} - mi := &file_openshell_proto_msgTypes[162] + mi := &file_openshell_proto_msgTypes[164] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11942,7 +12049,7 @@ func (x *ApproveAllDraftChunksResponse) String() string { func (*ApproveAllDraftChunksResponse) ProtoMessage() {} func (x *ApproveAllDraftChunksResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[162] + mi := &file_openshell_proto_msgTypes[164] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11955,7 +12062,7 @@ func (x *ApproveAllDraftChunksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveAllDraftChunksResponse.ProtoReflect.Descriptor instead. func (*ApproveAllDraftChunksResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{162} + return file_openshell_proto_rawDescGZIP(), []int{164} } func (x *ApproveAllDraftChunksResponse) GetPolicyVersion() uint32 { @@ -12003,7 +12110,7 @@ type EditDraftChunkRequest struct { func (x *EditDraftChunkRequest) Reset() { *x = EditDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[163] + mi := &file_openshell_proto_msgTypes[165] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12015,7 +12122,7 @@ func (x *EditDraftChunkRequest) String() string { func (*EditDraftChunkRequest) ProtoMessage() {} func (x *EditDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[163] + mi := &file_openshell_proto_msgTypes[165] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12028,7 +12135,7 @@ func (x *EditDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EditDraftChunkRequest.ProtoReflect.Descriptor instead. func (*EditDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{163} + return file_openshell_proto_rawDescGZIP(), []int{165} } func (x *EditDraftChunkRequest) GetName() string { @@ -12067,7 +12174,7 @@ type EditDraftChunkResponse struct { func (x *EditDraftChunkResponse) Reset() { *x = EditDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[164] + mi := &file_openshell_proto_msgTypes[166] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12079,7 +12186,7 @@ func (x *EditDraftChunkResponse) String() string { func (*EditDraftChunkResponse) ProtoMessage() {} func (x *EditDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[164] + mi := &file_openshell_proto_msgTypes[166] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12092,7 +12199,7 @@ func (x *EditDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EditDraftChunkResponse.ProtoReflect.Descriptor instead. func (*EditDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{164} + return file_openshell_proto_rawDescGZIP(), []int{166} } // Reverse an approval (remove merged rule from active policy). @@ -12110,7 +12217,7 @@ type UndoDraftChunkRequest struct { func (x *UndoDraftChunkRequest) Reset() { *x = UndoDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[165] + mi := &file_openshell_proto_msgTypes[167] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12122,7 +12229,7 @@ func (x *UndoDraftChunkRequest) String() string { func (*UndoDraftChunkRequest) ProtoMessage() {} func (x *UndoDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[165] + mi := &file_openshell_proto_msgTypes[167] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12135,7 +12242,7 @@ func (x *UndoDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UndoDraftChunkRequest.ProtoReflect.Descriptor instead. func (*UndoDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{165} + return file_openshell_proto_rawDescGZIP(), []int{167} } func (x *UndoDraftChunkRequest) GetName() string { @@ -12171,7 +12278,7 @@ type UndoDraftChunkResponse struct { func (x *UndoDraftChunkResponse) Reset() { *x = UndoDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[166] + mi := &file_openshell_proto_msgTypes[168] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12183,7 +12290,7 @@ func (x *UndoDraftChunkResponse) String() string { func (*UndoDraftChunkResponse) ProtoMessage() {} func (x *UndoDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[166] + mi := &file_openshell_proto_msgTypes[168] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12196,7 +12303,7 @@ func (x *UndoDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UndoDraftChunkResponse.ProtoReflect.Descriptor instead. func (*UndoDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{166} + return file_openshell_proto_rawDescGZIP(), []int{168} } func (x *UndoDraftChunkResponse) GetPolicyVersion() uint32 { @@ -12226,7 +12333,7 @@ type ClearDraftChunksRequest struct { func (x *ClearDraftChunksRequest) Reset() { *x = ClearDraftChunksRequest{} - mi := &file_openshell_proto_msgTypes[167] + mi := &file_openshell_proto_msgTypes[169] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12238,7 +12345,7 @@ func (x *ClearDraftChunksRequest) String() string { func (*ClearDraftChunksRequest) ProtoMessage() {} func (x *ClearDraftChunksRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[167] + mi := &file_openshell_proto_msgTypes[169] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12251,7 +12358,7 @@ func (x *ClearDraftChunksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ClearDraftChunksRequest.ProtoReflect.Descriptor instead. func (*ClearDraftChunksRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{167} + return file_openshell_proto_rawDescGZIP(), []int{169} } func (x *ClearDraftChunksRequest) GetName() string { @@ -12278,7 +12385,7 @@ type ClearDraftChunksResponse struct { func (x *ClearDraftChunksResponse) Reset() { *x = ClearDraftChunksResponse{} - mi := &file_openshell_proto_msgTypes[168] + mi := &file_openshell_proto_msgTypes[170] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12290,7 +12397,7 @@ func (x *ClearDraftChunksResponse) String() string { func (*ClearDraftChunksResponse) ProtoMessage() {} func (x *ClearDraftChunksResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[168] + mi := &file_openshell_proto_msgTypes[170] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12303,7 +12410,7 @@ func (x *ClearDraftChunksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ClearDraftChunksResponse.ProtoReflect.Descriptor instead. func (*ClearDraftChunksResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{168} + return file_openshell_proto_rawDescGZIP(), []int{170} } func (x *ClearDraftChunksResponse) GetChunksCleared() uint32 { @@ -12326,7 +12433,7 @@ type GetDraftHistoryRequest struct { func (x *GetDraftHistoryRequest) Reset() { *x = GetDraftHistoryRequest{} - mi := &file_openshell_proto_msgTypes[169] + mi := &file_openshell_proto_msgTypes[171] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12338,7 +12445,7 @@ func (x *GetDraftHistoryRequest) String() string { func (*GetDraftHistoryRequest) ProtoMessage() {} func (x *GetDraftHistoryRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[169] + mi := &file_openshell_proto_msgTypes[171] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12351,7 +12458,7 @@ func (x *GetDraftHistoryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftHistoryRequest.ProtoReflect.Descriptor instead. func (*GetDraftHistoryRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{169} + return file_openshell_proto_rawDescGZIP(), []int{171} } func (x *GetDraftHistoryRequest) GetName() string { @@ -12385,7 +12492,7 @@ type DraftHistoryEntry struct { func (x *DraftHistoryEntry) Reset() { *x = DraftHistoryEntry{} - mi := &file_openshell_proto_msgTypes[170] + mi := &file_openshell_proto_msgTypes[172] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12397,7 +12504,7 @@ func (x *DraftHistoryEntry) String() string { func (*DraftHistoryEntry) ProtoMessage() {} func (x *DraftHistoryEntry) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[170] + mi := &file_openshell_proto_msgTypes[172] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12410,7 +12517,7 @@ func (x *DraftHistoryEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftHistoryEntry.ProtoReflect.Descriptor instead. func (*DraftHistoryEntry) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{170} + return file_openshell_proto_rawDescGZIP(), []int{172} } func (x *DraftHistoryEntry) GetTimestampMs() int64 { @@ -12451,7 +12558,7 @@ type GetDraftHistoryResponse struct { func (x *GetDraftHistoryResponse) Reset() { *x = GetDraftHistoryResponse{} - mi := &file_openshell_proto_msgTypes[171] + mi := &file_openshell_proto_msgTypes[173] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12463,7 +12570,7 @@ func (x *GetDraftHistoryResponse) String() string { func (*GetDraftHistoryResponse) ProtoMessage() {} func (x *GetDraftHistoryResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[171] + mi := &file_openshell_proto_msgTypes[173] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12476,7 +12583,7 @@ func (x *GetDraftHistoryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftHistoryResponse.ProtoReflect.Descriptor instead. func (*GetDraftHistoryResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{171} + return file_openshell_proto_rawDescGZIP(), []int{173} } func (x *GetDraftHistoryResponse) GetEntries() []*DraftHistoryEntry { @@ -12505,7 +12612,7 @@ type PolicyRevisionPayload struct { func (x *PolicyRevisionPayload) Reset() { *x = PolicyRevisionPayload{} - mi := &file_openshell_proto_msgTypes[172] + mi := &file_openshell_proto_msgTypes[174] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12517,7 +12624,7 @@ func (x *PolicyRevisionPayload) String() string { func (*PolicyRevisionPayload) ProtoMessage() {} func (x *PolicyRevisionPayload) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[172] + mi := &file_openshell_proto_msgTypes[174] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12530,7 +12637,7 @@ func (x *PolicyRevisionPayload) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyRevisionPayload.ProtoReflect.Descriptor instead. func (*PolicyRevisionPayload) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{172} + return file_openshell_proto_rawDescGZIP(), []int{174} } func (x *PolicyRevisionPayload) GetPolicy() *sandboxv1.SandboxPolicy { @@ -12609,7 +12716,7 @@ type DraftChunkPayload struct { func (x *DraftChunkPayload) Reset() { *x = DraftChunkPayload{} - mi := &file_openshell_proto_msgTypes[173] + mi := &file_openshell_proto_msgTypes[175] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12621,7 +12728,7 @@ func (x *DraftChunkPayload) String() string { func (*DraftChunkPayload) ProtoMessage() {} func (x *DraftChunkPayload) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[173] + mi := &file_openshell_proto_msgTypes[175] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12634,7 +12741,7 @@ func (x *DraftChunkPayload) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftChunkPayload.ProtoReflect.Descriptor instead. func (*DraftChunkPayload) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{173} + return file_openshell_proto_rawDescGZIP(), []int{175} } func (x *DraftChunkPayload) GetRuleName() string { @@ -12782,7 +12889,7 @@ type StoredPolicyRevision struct { func (x *StoredPolicyRevision) Reset() { *x = StoredPolicyRevision{} - mi := &file_openshell_proto_msgTypes[174] + mi := &file_openshell_proto_msgTypes[176] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12794,7 +12901,7 @@ func (x *StoredPolicyRevision) String() string { func (*StoredPolicyRevision) ProtoMessage() {} func (x *StoredPolicyRevision) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[174] + mi := &file_openshell_proto_msgTypes[176] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12807,7 +12914,7 @@ func (x *StoredPolicyRevision) ProtoReflect() protoreflect.Message { // Deprecated: Use StoredPolicyRevision.ProtoReflect.Descriptor instead. func (*StoredPolicyRevision) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{174} + return file_openshell_proto_rawDescGZIP(), []int{176} } func (x *StoredPolicyRevision) GetId() string { @@ -12916,7 +13023,7 @@ type StoredDraftChunk struct { func (x *StoredDraftChunk) Reset() { *x = StoredDraftChunk{} - mi := &file_openshell_proto_msgTypes[175] + mi := &file_openshell_proto_msgTypes[177] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12928,7 +13035,7 @@ func (x *StoredDraftChunk) String() string { func (*StoredDraftChunk) ProtoMessage() {} func (x *StoredDraftChunk) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[175] + mi := &file_openshell_proto_msgTypes[177] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12941,7 +13048,7 @@ func (x *StoredDraftChunk) ProtoReflect() protoreflect.Message { // Deprecated: Use StoredDraftChunk.ProtoReflect.Descriptor instead. func (*StoredDraftChunk) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{175} + return file_openshell_proto_rawDescGZIP(), []int{177} } func (x *StoredDraftChunk) GetId() string { @@ -13132,7 +13239,7 @@ type CreateWorkspaceRequest struct { func (x *CreateWorkspaceRequest) Reset() { *x = CreateWorkspaceRequest{} - mi := &file_openshell_proto_msgTypes[176] + mi := &file_openshell_proto_msgTypes[178] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13144,7 +13251,7 @@ func (x *CreateWorkspaceRequest) String() string { func (*CreateWorkspaceRequest) ProtoMessage() {} func (x *CreateWorkspaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[176] + mi := &file_openshell_proto_msgTypes[178] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13157,7 +13264,7 @@ func (x *CreateWorkspaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateWorkspaceRequest.ProtoReflect.Descriptor instead. func (*CreateWorkspaceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{176} + return file_openshell_proto_rawDescGZIP(), []int{178} } func (x *CreateWorkspaceRequest) GetName() string { @@ -13184,7 +13291,7 @@ type CreateWorkspaceResponse struct { func (x *CreateWorkspaceResponse) Reset() { *x = CreateWorkspaceResponse{} - mi := &file_openshell_proto_msgTypes[177] + mi := &file_openshell_proto_msgTypes[179] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13196,7 +13303,7 @@ func (x *CreateWorkspaceResponse) String() string { func (*CreateWorkspaceResponse) ProtoMessage() {} func (x *CreateWorkspaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[177] + mi := &file_openshell_proto_msgTypes[179] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13209,7 +13316,7 @@ func (x *CreateWorkspaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateWorkspaceResponse.ProtoReflect.Descriptor instead. func (*CreateWorkspaceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{177} + return file_openshell_proto_rawDescGZIP(), []int{179} } func (x *CreateWorkspaceResponse) GetWorkspace() *datamodelv1.Workspace { @@ -13230,7 +13337,7 @@ type GetWorkspaceRequest struct { func (x *GetWorkspaceRequest) Reset() { *x = GetWorkspaceRequest{} - mi := &file_openshell_proto_msgTypes[178] + mi := &file_openshell_proto_msgTypes[180] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13242,7 +13349,7 @@ func (x *GetWorkspaceRequest) String() string { func (*GetWorkspaceRequest) ProtoMessage() {} func (x *GetWorkspaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[178] + mi := &file_openshell_proto_msgTypes[180] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13255,7 +13362,7 @@ func (x *GetWorkspaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetWorkspaceRequest.ProtoReflect.Descriptor instead. func (*GetWorkspaceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{178} + return file_openshell_proto_rawDescGZIP(), []int{180} } func (x *GetWorkspaceRequest) GetName() string { @@ -13275,7 +13382,7 @@ type GetWorkspaceResponse struct { func (x *GetWorkspaceResponse) Reset() { *x = GetWorkspaceResponse{} - mi := &file_openshell_proto_msgTypes[179] + mi := &file_openshell_proto_msgTypes[181] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13287,7 +13394,7 @@ func (x *GetWorkspaceResponse) String() string { func (*GetWorkspaceResponse) ProtoMessage() {} func (x *GetWorkspaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[179] + mi := &file_openshell_proto_msgTypes[181] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13300,7 +13407,7 @@ func (x *GetWorkspaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetWorkspaceResponse.ProtoReflect.Descriptor instead. func (*GetWorkspaceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{179} + return file_openshell_proto_rawDescGZIP(), []int{181} } func (x *GetWorkspaceResponse) GetWorkspace() *datamodelv1.Workspace { @@ -13323,7 +13430,7 @@ type ListWorkspacesRequest struct { func (x *ListWorkspacesRequest) Reset() { *x = ListWorkspacesRequest{} - mi := &file_openshell_proto_msgTypes[180] + mi := &file_openshell_proto_msgTypes[182] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13335,7 +13442,7 @@ func (x *ListWorkspacesRequest) String() string { func (*ListWorkspacesRequest) ProtoMessage() {} func (x *ListWorkspacesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[180] + mi := &file_openshell_proto_msgTypes[182] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13348,7 +13455,7 @@ func (x *ListWorkspacesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspacesRequest.ProtoReflect.Descriptor instead. func (*ListWorkspacesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{180} + return file_openshell_proto_rawDescGZIP(), []int{182} } func (x *ListWorkspacesRequest) GetLimit() uint32 { @@ -13382,7 +13489,7 @@ type ListWorkspacesResponse struct { func (x *ListWorkspacesResponse) Reset() { *x = ListWorkspacesResponse{} - mi := &file_openshell_proto_msgTypes[181] + mi := &file_openshell_proto_msgTypes[183] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13394,7 +13501,7 @@ func (x *ListWorkspacesResponse) String() string { func (*ListWorkspacesResponse) ProtoMessage() {} func (x *ListWorkspacesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[181] + mi := &file_openshell_proto_msgTypes[183] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13407,7 +13514,7 @@ func (x *ListWorkspacesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspacesResponse.ProtoReflect.Descriptor instead. func (*ListWorkspacesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{181} + return file_openshell_proto_rawDescGZIP(), []int{183} } func (x *ListWorkspacesResponse) GetWorkspaces() []*datamodelv1.Workspace { @@ -13428,7 +13535,7 @@ type DeleteWorkspaceRequest struct { func (x *DeleteWorkspaceRequest) Reset() { *x = DeleteWorkspaceRequest{} - mi := &file_openshell_proto_msgTypes[182] + mi := &file_openshell_proto_msgTypes[184] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13440,7 +13547,7 @@ func (x *DeleteWorkspaceRequest) String() string { func (*DeleteWorkspaceRequest) ProtoMessage() {} func (x *DeleteWorkspaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[182] + mi := &file_openshell_proto_msgTypes[184] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13453,7 +13560,7 @@ func (x *DeleteWorkspaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteWorkspaceRequest.ProtoReflect.Descriptor instead. func (*DeleteWorkspaceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{182} + return file_openshell_proto_rawDescGZIP(), []int{184} } func (x *DeleteWorkspaceRequest) GetName() string { @@ -13473,7 +13580,7 @@ type DeleteWorkspaceResponse struct { func (x *DeleteWorkspaceResponse) Reset() { *x = DeleteWorkspaceResponse{} - mi := &file_openshell_proto_msgTypes[183] + mi := &file_openshell_proto_msgTypes[185] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13485,7 +13592,7 @@ func (x *DeleteWorkspaceResponse) String() string { func (*DeleteWorkspaceResponse) ProtoMessage() {} func (x *DeleteWorkspaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[183] + mi := &file_openshell_proto_msgTypes[185] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13498,7 +13605,7 @@ func (x *DeleteWorkspaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteWorkspaceResponse.ProtoReflect.Descriptor instead. func (*DeleteWorkspaceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{183} + return file_openshell_proto_rawDescGZIP(), []int{185} } func (x *DeleteWorkspaceResponse) GetDeleted() bool { @@ -13522,7 +13629,7 @@ type WorkspaceMember struct { func (x *WorkspaceMember) Reset() { *x = WorkspaceMember{} - mi := &file_openshell_proto_msgTypes[184] + mi := &file_openshell_proto_msgTypes[186] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13534,7 +13641,7 @@ func (x *WorkspaceMember) String() string { func (*WorkspaceMember) ProtoMessage() {} func (x *WorkspaceMember) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[184] + mi := &file_openshell_proto_msgTypes[186] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13547,7 +13654,7 @@ func (x *WorkspaceMember) ProtoReflect() protoreflect.Message { // Deprecated: Use WorkspaceMember.ProtoReflect.Descriptor instead. func (*WorkspaceMember) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{184} + return file_openshell_proto_rawDescGZIP(), []int{186} } func (x *WorkspaceMember) GetMetadata() *datamodelv1.ObjectMeta { @@ -13586,7 +13693,7 @@ type AddWorkspaceMemberRequest struct { func (x *AddWorkspaceMemberRequest) Reset() { *x = AddWorkspaceMemberRequest{} - mi := &file_openshell_proto_msgTypes[185] + mi := &file_openshell_proto_msgTypes[187] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13598,7 +13705,7 @@ func (x *AddWorkspaceMemberRequest) String() string { func (*AddWorkspaceMemberRequest) ProtoMessage() {} func (x *AddWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[185] + mi := &file_openshell_proto_msgTypes[187] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13611,7 +13718,7 @@ func (x *AddWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AddWorkspaceMemberRequest.ProtoReflect.Descriptor instead. func (*AddWorkspaceMemberRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{185} + return file_openshell_proto_rawDescGZIP(), []int{187} } func (x *AddWorkspaceMemberRequest) GetWorkspace() string { @@ -13645,7 +13752,7 @@ type AddWorkspaceMemberResponse struct { func (x *AddWorkspaceMemberResponse) Reset() { *x = AddWorkspaceMemberResponse{} - mi := &file_openshell_proto_msgTypes[186] + mi := &file_openshell_proto_msgTypes[188] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13657,7 +13764,7 @@ func (x *AddWorkspaceMemberResponse) String() string { func (*AddWorkspaceMemberResponse) ProtoMessage() {} func (x *AddWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[186] + mi := &file_openshell_proto_msgTypes[188] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13670,7 +13777,7 @@ func (x *AddWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AddWorkspaceMemberResponse.ProtoReflect.Descriptor instead. func (*AddWorkspaceMemberResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{186} + return file_openshell_proto_rawDescGZIP(), []int{188} } func (x *AddWorkspaceMemberResponse) GetMember() *WorkspaceMember { @@ -13693,7 +13800,7 @@ type RemoveWorkspaceMemberRequest struct { func (x *RemoveWorkspaceMemberRequest) Reset() { *x = RemoveWorkspaceMemberRequest{} - mi := &file_openshell_proto_msgTypes[187] + mi := &file_openshell_proto_msgTypes[189] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13705,7 +13812,7 @@ func (x *RemoveWorkspaceMemberRequest) String() string { func (*RemoveWorkspaceMemberRequest) ProtoMessage() {} func (x *RemoveWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[187] + mi := &file_openshell_proto_msgTypes[189] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13718,7 +13825,7 @@ func (x *RemoveWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveWorkspaceMemberRequest.ProtoReflect.Descriptor instead. func (*RemoveWorkspaceMemberRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{187} + return file_openshell_proto_rawDescGZIP(), []int{189} } func (x *RemoveWorkspaceMemberRequest) GetWorkspace() string { @@ -13745,7 +13852,7 @@ type RemoveWorkspaceMemberResponse struct { func (x *RemoveWorkspaceMemberResponse) Reset() { *x = RemoveWorkspaceMemberResponse{} - mi := &file_openshell_proto_msgTypes[188] + mi := &file_openshell_proto_msgTypes[190] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13757,7 +13864,7 @@ func (x *RemoveWorkspaceMemberResponse) String() string { func (*RemoveWorkspaceMemberResponse) ProtoMessage() {} func (x *RemoveWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[188] + mi := &file_openshell_proto_msgTypes[190] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13770,7 +13877,7 @@ func (x *RemoveWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveWorkspaceMemberResponse.ProtoReflect.Descriptor instead. func (*RemoveWorkspaceMemberResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{188} + return file_openshell_proto_rawDescGZIP(), []int{190} } func (x *RemoveWorkspaceMemberResponse) GetRemoved() bool { @@ -13793,7 +13900,7 @@ type ListWorkspaceMembersRequest struct { func (x *ListWorkspaceMembersRequest) Reset() { *x = ListWorkspaceMembersRequest{} - mi := &file_openshell_proto_msgTypes[189] + mi := &file_openshell_proto_msgTypes[191] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13805,7 +13912,7 @@ func (x *ListWorkspaceMembersRequest) String() string { func (*ListWorkspaceMembersRequest) ProtoMessage() {} func (x *ListWorkspaceMembersRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[189] + mi := &file_openshell_proto_msgTypes[191] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13818,7 +13925,7 @@ func (x *ListWorkspaceMembersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspaceMembersRequest.ProtoReflect.Descriptor instead. func (*ListWorkspaceMembersRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{189} + return file_openshell_proto_rawDescGZIP(), []int{191} } func (x *ListWorkspaceMembersRequest) GetWorkspace() string { @@ -13852,7 +13959,7 @@ type ListWorkspaceMembersResponse struct { func (x *ListWorkspaceMembersResponse) Reset() { *x = ListWorkspaceMembersResponse{} - mi := &file_openshell_proto_msgTypes[190] + mi := &file_openshell_proto_msgTypes[192] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13864,7 +13971,7 @@ func (x *ListWorkspaceMembersResponse) String() string { func (*ListWorkspaceMembersResponse) ProtoMessage() {} func (x *ListWorkspaceMembersResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[190] + mi := &file_openshell_proto_msgTypes[192] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13877,7 +13984,7 @@ func (x *ListWorkspaceMembersResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspaceMembersResponse.ProtoReflect.Descriptor instead. func (*ListWorkspaceMembersResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{190} + return file_openshell_proto_rawDescGZIP(), []int{192} } func (x *ListWorkspaceMembersResponse) GetMembers() []*WorkspaceMember { @@ -13905,7 +14012,7 @@ type ExtensionServiceCredential struct { func (x *ExtensionServiceCredential) Reset() { *x = ExtensionServiceCredential{} - mi := &file_openshell_proto_msgTypes[191] + mi := &file_openshell_proto_msgTypes[193] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13917,7 +14024,7 @@ func (x *ExtensionServiceCredential) String() string { func (*ExtensionServiceCredential) ProtoMessage() {} func (x *ExtensionServiceCredential) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[191] + mi := &file_openshell_proto_msgTypes[193] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13930,7 +14037,7 @@ func (x *ExtensionServiceCredential) ProtoReflect() protoreflect.Message { // Deprecated: Use ExtensionServiceCredential.ProtoReflect.Descriptor instead. func (*ExtensionServiceCredential) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{191} + return file_openshell_proto_rawDescGZIP(), []int{193} } func (x *ExtensionServiceCredential) GetServiceName() string { @@ -14067,13 +14174,14 @@ const file_openshell_proto_rawDesc = "" + "\bmetadata\x18\x06 \x03(\v2).openshell.v1.PlatformEvent.MetadataEntryR\bmetadata\x1a;\n" + "\rMetadataEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x91\x03\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xd4\x03\n" + "\x14CreateSandboxRequest\x12-\n" + "\x04spec\x18\x01 \x01(\v2\x19.openshell.v1.SandboxSpecR\x04spec\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12F\n" + "\x06labels\x18\x03 \x03(\v2..openshell.v1.CreateSandboxRequest.LabelsEntryR\x06labels\x12U\n" + "\vannotations\x18\x04 \x03(\v23.openshell.v1.CreateSandboxRequest.AnnotationsEntryR\vannotations\x12\x1c\n" + - "\tworkspace\x18\x05 \x01(\tR\tworkspace\x1a9\n" + + "\tworkspace\x18\x05 \x01(\tR\tworkspace\x12A\n" + + "\x1dawait_main_process_attachment\x18\x06 \x01(\bR\x1aawaitMainProcessAttachment\x1a9\n" + "\vLabelsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a>\n" + @@ -14707,7 +14815,13 @@ const file_openshell_proto_rawDesc = "" + "\vinstance_id\x18\x02 \x01(\tR\n" + "instanceId\x12\x1b\n" + "\texit_code\x18\x03 \x01(\x05R\bexitCode\"\x1f\n" + - "\x1dReportMainProcessExitResponse\"\xb7\x01\n" + + "\x1dReportMainProcessExitResponse\"`\n" + + "\x1eFinalizeMainProcessExitRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1f\n" + + "\vinstance_id\x18\x02 \x01(\tR\n" + + "instanceId\"!\n" + + "\x1fFinalizeMainProcessExitResponse\"\xb7\x01\n" + "\tRelayOpen\x12\x1d\n" + "\n" + "channel_id\x18\x01 \x01(\tR\tchannelId\x120\n" + @@ -15029,7 +15143,7 @@ const file_openshell_proto_rawDesc = "" + "\x1aExtensionServiceCredential\x12!\n" + "\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x1a\n" + "\x05token\x18\x02 \x01(\tB\x04\x88\xb5\x18\x01R\x05token\x12\"\n" + - "\rexpires_at_ms\x18\x03 \x01(\x03R\vexpiresAtMs*\x89\x02\n" + + "\rexpires_at_ms\x18\x03 \x01(\x03R\vexpiresAtMs*\xa6\x02\n" + "\fSandboxPhase\x12\x1d\n" + "\x19SANDBOX_PHASE_UNSPECIFIED\x10\x00\x12\x1e\n" + "\x1aSANDBOX_PHASE_PROVISIONING\x10\x01\x12\x17\n" + @@ -15039,7 +15153,8 @@ const file_openshell_proto_rawDesc = "" + "\x15SANDBOX_PHASE_UNKNOWN\x10\x05\x12\x1a\n" + "\x16SANDBOX_PHASE_STOPPING\x10\x06\x12\x19\n" + "\x15SANDBOX_PHASE_STOPPED\x10\a\x12\x1a\n" + - "\x16SANDBOX_PHASE_STARTING\x10\b*\xce\x01\n" + + "\x16SANDBOX_PHASE_STARTING\x10\b\x12\x1b\n" + + "\x17SANDBOX_PHASE_COMPLETED\x10\t*\xce\x01\n" + " ProviderCredentialTokenGrantType\x124\n" + "0PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_UNSPECIFIED\x10\x00\x12;\n" + "7PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_CLIENT_CREDENTIALS\x10\x01\x127\n" + @@ -15081,7 +15196,7 @@ const file_openshell_proto_rawDesc = "" + "1PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_RETRY\x10\x01\x12;\n" + "7PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_REAUTHORIZE\x10\x02\x12A\n" + "=PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_FIX_CONFIGURATION\x10\x03\x12;\n" + - "7PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_INVESTIGATE\x10\x042\xacF\n" + + "7PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_INVESTIGATE\x10\x042\xb4G\n" + "\tOpenShell\x12Z\n" + "\x06Health\x12\x1b.openshell.v1.HealthRequest\x1a\x1c.openshell.v1.HealthResponse\"\x15\x82\xb5\x18\x11\n" + "\x0funauthenticated\x12i\n" + @@ -15181,6 +15296,8 @@ const file_openshell_proto_rawDesc = "" + "\x11ConnectSupervisor\x12\x1f.openshell.v1.SupervisorMessage\x1a\x1c.openshell.v1.GatewayMessage\"\r\x82\xb5\x18\t\n" + "\asandbox(\x010\x01\x12\x7f\n" + "\x15ReportMainProcessExit\x12*.openshell.v1.ReportMainProcessExitRequest\x1a+.openshell.v1.ReportMainProcessExitResponse\"\r\x82\xb5\x18\t\n" + + "\asandbox\x12\x85\x01\n" + + "\x17FinalizeMainProcessExit\x12,.openshell.v1.FinalizeMainProcessExitRequest\x1a-.openshell.v1.FinalizeMainProcessExitResponse\"\r\x82\xb5\x18\t\n" + "\asandbox\x12T\n" + "\vRelayStream\x12\x18.openshell.v1.RelayFrame\x1a\x18.openshell.v1.RelayFrame\"\r\x82\xb5\x18\t\n" + "\asandbox(\x010\x01\x12w\n" + @@ -15236,7 +15353,7 @@ func file_openshell_proto_rawDescGZIP() []byte { } var file_openshell_proto_enumTypes = make([]protoimpl.EnumInfo, 8) -var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 217) +var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 219) var file_openshell_proto_goTypes = []any{ (SandboxPhase)(0), // 0: openshell.v1.SandboxPhase (ProviderCredentialTokenGrantType)(0), // 1: openshell.v1.ProviderCredentialTokenGrantType @@ -15385,155 +15502,157 @@ var file_openshell_proto_goTypes = []any{ (*GatewayHeartbeat)(nil), // 144: openshell.v1.GatewayHeartbeat (*ReportMainProcessExitRequest)(nil), // 145: openshell.v1.ReportMainProcessExitRequest (*ReportMainProcessExitResponse)(nil), // 146: openshell.v1.ReportMainProcessExitResponse - (*RelayOpen)(nil), // 147: openshell.v1.RelayOpen - (*SshRelayTarget)(nil), // 148: openshell.v1.SshRelayTarget - (*TcpRelayTarget)(nil), // 149: openshell.v1.TcpRelayTarget - (*RelayInit)(nil), // 150: openshell.v1.RelayInit - (*RelayFrame)(nil), // 151: openshell.v1.RelayFrame - (*RelayOpenResult)(nil), // 152: openshell.v1.RelayOpenResult - (*RelayClose)(nil), // 153: openshell.v1.RelayClose - (*L7RequestSample)(nil), // 154: openshell.v1.L7RequestSample - (*DenialSummary)(nil), // 155: openshell.v1.DenialSummary - (*DenialGroupCount)(nil), // 156: openshell.v1.DenialGroupCount - (*NetworkActivitySummary)(nil), // 157: openshell.v1.NetworkActivitySummary - (*PolicyChunk)(nil), // 158: openshell.v1.PolicyChunk - (*DraftPolicyUpdate)(nil), // 159: openshell.v1.DraftPolicyUpdate - (*SubmitPolicyAnalysisRequest)(nil), // 160: openshell.v1.SubmitPolicyAnalysisRequest - (*SubmitPolicyAnalysisResponse)(nil), // 161: openshell.v1.SubmitPolicyAnalysisResponse - (*GetDraftPolicyRequest)(nil), // 162: openshell.v1.GetDraftPolicyRequest - (*GetDraftPolicyResponse)(nil), // 163: openshell.v1.GetDraftPolicyResponse - (*ApproveDraftChunkRequest)(nil), // 164: openshell.v1.ApproveDraftChunkRequest - (*ApproveDraftChunkResponse)(nil), // 165: openshell.v1.ApproveDraftChunkResponse - (*RejectDraftChunkRequest)(nil), // 166: openshell.v1.RejectDraftChunkRequest - (*RejectDraftChunkResponse)(nil), // 167: openshell.v1.RejectDraftChunkResponse - (*DraftChunkApproval)(nil), // 168: openshell.v1.DraftChunkApproval - (*ApproveAllDraftChunksRequest)(nil), // 169: openshell.v1.ApproveAllDraftChunksRequest - (*ApproveAllDraftChunksResponse)(nil), // 170: openshell.v1.ApproveAllDraftChunksResponse - (*EditDraftChunkRequest)(nil), // 171: openshell.v1.EditDraftChunkRequest - (*EditDraftChunkResponse)(nil), // 172: openshell.v1.EditDraftChunkResponse - (*UndoDraftChunkRequest)(nil), // 173: openshell.v1.UndoDraftChunkRequest - (*UndoDraftChunkResponse)(nil), // 174: openshell.v1.UndoDraftChunkResponse - (*ClearDraftChunksRequest)(nil), // 175: openshell.v1.ClearDraftChunksRequest - (*ClearDraftChunksResponse)(nil), // 176: openshell.v1.ClearDraftChunksResponse - (*GetDraftHistoryRequest)(nil), // 177: openshell.v1.GetDraftHistoryRequest - (*DraftHistoryEntry)(nil), // 178: openshell.v1.DraftHistoryEntry - (*GetDraftHistoryResponse)(nil), // 179: openshell.v1.GetDraftHistoryResponse - (*PolicyRevisionPayload)(nil), // 180: openshell.v1.PolicyRevisionPayload - (*DraftChunkPayload)(nil), // 181: openshell.v1.DraftChunkPayload - (*StoredPolicyRevision)(nil), // 182: openshell.v1.StoredPolicyRevision - (*StoredDraftChunk)(nil), // 183: openshell.v1.StoredDraftChunk - (*CreateWorkspaceRequest)(nil), // 184: openshell.v1.CreateWorkspaceRequest - (*CreateWorkspaceResponse)(nil), // 185: openshell.v1.CreateWorkspaceResponse - (*GetWorkspaceRequest)(nil), // 186: openshell.v1.GetWorkspaceRequest - (*GetWorkspaceResponse)(nil), // 187: openshell.v1.GetWorkspaceResponse - (*ListWorkspacesRequest)(nil), // 188: openshell.v1.ListWorkspacesRequest - (*ListWorkspacesResponse)(nil), // 189: openshell.v1.ListWorkspacesResponse - (*DeleteWorkspaceRequest)(nil), // 190: openshell.v1.DeleteWorkspaceRequest - (*DeleteWorkspaceResponse)(nil), // 191: openshell.v1.DeleteWorkspaceResponse - (*WorkspaceMember)(nil), // 192: openshell.v1.WorkspaceMember - (*AddWorkspaceMemberRequest)(nil), // 193: openshell.v1.AddWorkspaceMemberRequest - (*AddWorkspaceMemberResponse)(nil), // 194: openshell.v1.AddWorkspaceMemberResponse - (*RemoveWorkspaceMemberRequest)(nil), // 195: openshell.v1.RemoveWorkspaceMemberRequest - (*RemoveWorkspaceMemberResponse)(nil), // 196: openshell.v1.RemoveWorkspaceMemberResponse - (*ListWorkspaceMembersRequest)(nil), // 197: openshell.v1.ListWorkspaceMembersRequest - (*ListWorkspaceMembersResponse)(nil), // 198: openshell.v1.ListWorkspaceMembersResponse - (*ExtensionServiceCredential)(nil), // 199: openshell.v1.ExtensionServiceCredential - nil, // 200: openshell.v1.SandboxSpec.EnvironmentEntry - nil, // 201: openshell.v1.SandboxTemplate.LabelsEntry - nil, // 202: openshell.v1.SandboxTemplate.AnnotationsEntry - nil, // 203: openshell.v1.SandboxTemplate.EnvironmentEntry - nil, // 204: openshell.v1.PlatformEvent.MetadataEntry - nil, // 205: openshell.v1.CreateSandboxRequest.LabelsEntry - nil, // 206: openshell.v1.CreateSandboxRequest.AnnotationsEntry - nil, // 207: openshell.v1.ExecSandboxRequest.EnvironmentEntry - nil, // 208: openshell.v1.SandboxLogLine.FieldsEntry - nil, // 209: openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry - nil, // 210: openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry - nil, // 211: openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry - nil, // 212: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry - nil, // 213: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry - nil, // 214: openshell.v1.ProviderProfile.AnnotationsEntry - nil, // 215: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - nil, // 216: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry - nil, // 217: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - nil, // 218: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry - nil, // 219: openshell.v1.UpdateConfigRequest.AnnotationsEntry - nil, // 220: openshell.v1.UpdateConfigResponse.AnnotationsEntry - nil, // 221: openshell.v1.SandboxPolicyRevision.ProvenanceEntry - nil, // 222: openshell.v1.PolicyRevisionPayload.ProvenanceEntry - nil, // 223: openshell.v1.StoredPolicyRevision.ProvenanceEntry - nil, // 224: openshell.v1.CreateWorkspaceRequest.LabelsEntry - (*datamodelv1.ObjectMeta)(nil), // 225: openshell.datamodel.v1.ObjectMeta - (*sandboxv1.SandboxPolicy)(nil), // 226: openshell.sandbox.v1.SandboxPolicy - (*structpb.Struct)(nil), // 227: google.protobuf.Struct - (*datamodelv1.Provider)(nil), // 228: openshell.datamodel.v1.Provider - (*datamodelv1.CredentialHandle)(nil), // 229: openshell.datamodel.v1.CredentialHandle - (*sandboxv1.NetworkEndpoint)(nil), // 230: openshell.sandbox.v1.NetworkEndpoint - (*sandboxv1.NetworkBinary)(nil), // 231: openshell.sandbox.v1.NetworkBinary - (*sandboxv1.SettingValue)(nil), // 232: openshell.sandbox.v1.SettingValue - (*sandboxv1.NetworkPolicyRule)(nil), // 233: openshell.sandbox.v1.NetworkPolicyRule - (*sandboxv1.L7DenyRule)(nil), // 234: openshell.sandbox.v1.L7DenyRule - (*sandboxv1.L7Rule)(nil), // 235: openshell.sandbox.v1.L7Rule - (*datamodelv1.Workspace)(nil), // 236: openshell.datamodel.v1.Workspace - (*sandboxv1.GetSandboxConfigRequest)(nil), // 237: openshell.sandbox.v1.GetSandboxConfigRequest - (*sandboxv1.GetGatewayConfigRequest)(nil), // 238: openshell.sandbox.v1.GetGatewayConfigRequest - (*sandboxv1.GetSandboxConfigResponse)(nil), // 239: openshell.sandbox.v1.GetSandboxConfigResponse - (*sandboxv1.GetGatewayConfigResponse)(nil), // 240: openshell.sandbox.v1.GetGatewayConfigResponse + (*FinalizeMainProcessExitRequest)(nil), // 147: openshell.v1.FinalizeMainProcessExitRequest + (*FinalizeMainProcessExitResponse)(nil), // 148: openshell.v1.FinalizeMainProcessExitResponse + (*RelayOpen)(nil), // 149: openshell.v1.RelayOpen + (*SshRelayTarget)(nil), // 150: openshell.v1.SshRelayTarget + (*TcpRelayTarget)(nil), // 151: openshell.v1.TcpRelayTarget + (*RelayInit)(nil), // 152: openshell.v1.RelayInit + (*RelayFrame)(nil), // 153: openshell.v1.RelayFrame + (*RelayOpenResult)(nil), // 154: openshell.v1.RelayOpenResult + (*RelayClose)(nil), // 155: openshell.v1.RelayClose + (*L7RequestSample)(nil), // 156: openshell.v1.L7RequestSample + (*DenialSummary)(nil), // 157: openshell.v1.DenialSummary + (*DenialGroupCount)(nil), // 158: openshell.v1.DenialGroupCount + (*NetworkActivitySummary)(nil), // 159: openshell.v1.NetworkActivitySummary + (*PolicyChunk)(nil), // 160: openshell.v1.PolicyChunk + (*DraftPolicyUpdate)(nil), // 161: openshell.v1.DraftPolicyUpdate + (*SubmitPolicyAnalysisRequest)(nil), // 162: openshell.v1.SubmitPolicyAnalysisRequest + (*SubmitPolicyAnalysisResponse)(nil), // 163: openshell.v1.SubmitPolicyAnalysisResponse + (*GetDraftPolicyRequest)(nil), // 164: openshell.v1.GetDraftPolicyRequest + (*GetDraftPolicyResponse)(nil), // 165: openshell.v1.GetDraftPolicyResponse + (*ApproveDraftChunkRequest)(nil), // 166: openshell.v1.ApproveDraftChunkRequest + (*ApproveDraftChunkResponse)(nil), // 167: openshell.v1.ApproveDraftChunkResponse + (*RejectDraftChunkRequest)(nil), // 168: openshell.v1.RejectDraftChunkRequest + (*RejectDraftChunkResponse)(nil), // 169: openshell.v1.RejectDraftChunkResponse + (*DraftChunkApproval)(nil), // 170: openshell.v1.DraftChunkApproval + (*ApproveAllDraftChunksRequest)(nil), // 171: openshell.v1.ApproveAllDraftChunksRequest + (*ApproveAllDraftChunksResponse)(nil), // 172: openshell.v1.ApproveAllDraftChunksResponse + (*EditDraftChunkRequest)(nil), // 173: openshell.v1.EditDraftChunkRequest + (*EditDraftChunkResponse)(nil), // 174: openshell.v1.EditDraftChunkResponse + (*UndoDraftChunkRequest)(nil), // 175: openshell.v1.UndoDraftChunkRequest + (*UndoDraftChunkResponse)(nil), // 176: openshell.v1.UndoDraftChunkResponse + (*ClearDraftChunksRequest)(nil), // 177: openshell.v1.ClearDraftChunksRequest + (*ClearDraftChunksResponse)(nil), // 178: openshell.v1.ClearDraftChunksResponse + (*GetDraftHistoryRequest)(nil), // 179: openshell.v1.GetDraftHistoryRequest + (*DraftHistoryEntry)(nil), // 180: openshell.v1.DraftHistoryEntry + (*GetDraftHistoryResponse)(nil), // 181: openshell.v1.GetDraftHistoryResponse + (*PolicyRevisionPayload)(nil), // 182: openshell.v1.PolicyRevisionPayload + (*DraftChunkPayload)(nil), // 183: openshell.v1.DraftChunkPayload + (*StoredPolicyRevision)(nil), // 184: openshell.v1.StoredPolicyRevision + (*StoredDraftChunk)(nil), // 185: openshell.v1.StoredDraftChunk + (*CreateWorkspaceRequest)(nil), // 186: openshell.v1.CreateWorkspaceRequest + (*CreateWorkspaceResponse)(nil), // 187: openshell.v1.CreateWorkspaceResponse + (*GetWorkspaceRequest)(nil), // 188: openshell.v1.GetWorkspaceRequest + (*GetWorkspaceResponse)(nil), // 189: openshell.v1.GetWorkspaceResponse + (*ListWorkspacesRequest)(nil), // 190: openshell.v1.ListWorkspacesRequest + (*ListWorkspacesResponse)(nil), // 191: openshell.v1.ListWorkspacesResponse + (*DeleteWorkspaceRequest)(nil), // 192: openshell.v1.DeleteWorkspaceRequest + (*DeleteWorkspaceResponse)(nil), // 193: openshell.v1.DeleteWorkspaceResponse + (*WorkspaceMember)(nil), // 194: openshell.v1.WorkspaceMember + (*AddWorkspaceMemberRequest)(nil), // 195: openshell.v1.AddWorkspaceMemberRequest + (*AddWorkspaceMemberResponse)(nil), // 196: openshell.v1.AddWorkspaceMemberResponse + (*RemoveWorkspaceMemberRequest)(nil), // 197: openshell.v1.RemoveWorkspaceMemberRequest + (*RemoveWorkspaceMemberResponse)(nil), // 198: openshell.v1.RemoveWorkspaceMemberResponse + (*ListWorkspaceMembersRequest)(nil), // 199: openshell.v1.ListWorkspaceMembersRequest + (*ListWorkspaceMembersResponse)(nil), // 200: openshell.v1.ListWorkspaceMembersResponse + (*ExtensionServiceCredential)(nil), // 201: openshell.v1.ExtensionServiceCredential + nil, // 202: openshell.v1.SandboxSpec.EnvironmentEntry + nil, // 203: openshell.v1.SandboxTemplate.LabelsEntry + nil, // 204: openshell.v1.SandboxTemplate.AnnotationsEntry + nil, // 205: openshell.v1.SandboxTemplate.EnvironmentEntry + nil, // 206: openshell.v1.PlatformEvent.MetadataEntry + nil, // 207: openshell.v1.CreateSandboxRequest.LabelsEntry + nil, // 208: openshell.v1.CreateSandboxRequest.AnnotationsEntry + nil, // 209: openshell.v1.ExecSandboxRequest.EnvironmentEntry + nil, // 210: openshell.v1.SandboxLogLine.FieldsEntry + nil, // 211: openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry + nil, // 212: openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry + nil, // 213: openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry + nil, // 214: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry + nil, // 215: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + nil, // 216: openshell.v1.ProviderProfile.AnnotationsEntry + nil, // 217: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + nil, // 218: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry + nil, // 219: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + nil, // 220: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry + nil, // 221: openshell.v1.UpdateConfigRequest.AnnotationsEntry + nil, // 222: openshell.v1.UpdateConfigResponse.AnnotationsEntry + nil, // 223: openshell.v1.SandboxPolicyRevision.ProvenanceEntry + nil, // 224: openshell.v1.PolicyRevisionPayload.ProvenanceEntry + nil, // 225: openshell.v1.StoredPolicyRevision.ProvenanceEntry + nil, // 226: openshell.v1.CreateWorkspaceRequest.LabelsEntry + (*datamodelv1.ObjectMeta)(nil), // 227: openshell.datamodel.v1.ObjectMeta + (*sandboxv1.SandboxPolicy)(nil), // 228: openshell.sandbox.v1.SandboxPolicy + (*structpb.Struct)(nil), // 229: google.protobuf.Struct + (*datamodelv1.Provider)(nil), // 230: openshell.datamodel.v1.Provider + (*datamodelv1.CredentialHandle)(nil), // 231: openshell.datamodel.v1.CredentialHandle + (*sandboxv1.NetworkEndpoint)(nil), // 232: openshell.sandbox.v1.NetworkEndpoint + (*sandboxv1.NetworkBinary)(nil), // 233: openshell.sandbox.v1.NetworkBinary + (*sandboxv1.SettingValue)(nil), // 234: openshell.sandbox.v1.SettingValue + (*sandboxv1.NetworkPolicyRule)(nil), // 235: openshell.sandbox.v1.NetworkPolicyRule + (*sandboxv1.L7DenyRule)(nil), // 236: openshell.sandbox.v1.L7DenyRule + (*sandboxv1.L7Rule)(nil), // 237: openshell.sandbox.v1.L7Rule + (*datamodelv1.Workspace)(nil), // 238: openshell.datamodel.v1.Workspace + (*sandboxv1.GetSandboxConfigRequest)(nil), // 239: openshell.sandbox.v1.GetSandboxConfigRequest + (*sandboxv1.GetGatewayConfigRequest)(nil), // 240: openshell.sandbox.v1.GetGatewayConfigRequest + (*sandboxv1.GetSandboxConfigResponse)(nil), // 241: openshell.sandbox.v1.GetSandboxConfigResponse + (*sandboxv1.GetGatewayConfigResponse)(nil), // 242: openshell.sandbox.v1.GetGatewayConfigResponse } var file_openshell_proto_depIdxs = []int32{ - 199, // 0: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential + 201, // 0: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential 5, // 1: openshell.v1.HealthResponse.status:type_name -> openshell.v1.ServiceStatus 5, // 2: openshell.v1.GetGatewayInfoResponse.status:type_name -> openshell.v1.ServiceStatus 18, // 3: openshell.v1.GetGatewayInfoResponse.compute_drivers:type_name -> openshell.v1.ComputeDriverInfo 19, // 4: openshell.v1.ComputeDriverInfo.capabilities:type_name -> openshell.v1.ComputeDriverCapabilities - 225, // 5: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 227, // 5: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta 21, // 6: openshell.v1.Sandbox.spec:type_name -> openshell.v1.SandboxSpec 25, // 7: openshell.v1.Sandbox.status:type_name -> openshell.v1.SandboxStatus - 200, // 8: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry + 202, // 8: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry 24, // 9: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate - 226, // 10: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 228, // 10: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy 22, // 11: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements 23, // 12: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements - 201, // 13: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry - 202, // 14: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry - 203, // 15: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry - 227, // 16: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct - 227, // 17: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct + 203, // 13: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry + 204, // 14: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry + 205, // 15: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry + 229, // 16: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct + 229, // 17: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct 26, // 18: openshell.v1.SandboxStatus.conditions:type_name -> openshell.v1.SandboxCondition 0, // 19: openshell.v1.SandboxStatus.phase:type_name -> openshell.v1.SandboxPhase - 204, // 20: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry + 206, // 20: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry 21, // 21: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec - 205, // 22: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry - 206, // 23: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry + 207, // 22: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry + 208, // 23: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry 20, // 24: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox 20, // 25: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox - 228, // 26: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 230, // 26: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider 20, // 27: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox 20, // 28: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox 52, // 29: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse - 225, // 30: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 227, // 30: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta 51, // 31: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint - 207, // 32: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry + 209, // 32: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry 56, // 33: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout 57, // 34: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr 58, // 35: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit - 148, // 36: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget - 149, // 37: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget + 150, // 36: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget + 151, // 37: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget 60, // 38: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit 55, // 39: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest 63, // 40: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize - 225, // 41: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 227, // 41: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta 20, // 42: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox 67, // 43: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine 27, // 44: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent 68, // 45: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning - 159, // 46: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate - 208, // 47: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry - 228, // 48: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 228, // 49: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 209, // 50: openshell.v1.UpdateProviderRequest.credential_expires_at_ms:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry - 228, // 51: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider - 228, // 52: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 161, // 46: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate + 210, // 47: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry + 230, // 48: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 230, // 49: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 211, // 50: openshell.v1.UpdateProviderRequest.credential_expires_at_ms:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry + 230, // 51: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider + 230, // 52: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider 99, // 53: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile 80, // 54: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride 1, // 55: openshell.v1.ProviderCredentialTokenGrant.grant_type:type_name -> openshell.v1.ProviderCredentialTokenGrantType @@ -15545,26 +15664,26 @@ var file_openshell_proto_depIdxs = []int32{ 85, // 61: openshell.v1.ProviderCredentialRefresh.additional_outputs:type_name -> openshell.v1.ProviderCredentialRefreshOutput 2, // 62: openshell.v1.ProviderCredentialRefreshStatus.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy 7, // 63: openshell.v1.ProviderCredentialRefreshStatus.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction - 225, // 64: openshell.v1.StoredProviderCredentialRefreshState.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 227, // 64: openshell.v1.StoredProviderCredentialRefreshState.metadata:type_name -> openshell.datamodel.v1.ObjectMeta 2, // 65: openshell.v1.StoredProviderCredentialRefreshState.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 210, // 66: openshell.v1.StoredProviderCredentialRefreshState.material:type_name -> openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry - 211, // 67: openshell.v1.StoredProviderCredentialRefreshState.additional_output_keys:type_name -> openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry - 212, // 68: openshell.v1.StoredProviderCredentialRefreshState.secret_material_handles:type_name -> openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry + 212, // 66: openshell.v1.StoredProviderCredentialRefreshState.material:type_name -> openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry + 213, // 67: openshell.v1.StoredProviderCredentialRefreshState.additional_output_keys:type_name -> openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry + 214, // 68: openshell.v1.StoredProviderCredentialRefreshState.secret_material_handles:type_name -> openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry 90, // 69: openshell.v1.StoredProviderCredentialRefreshState.pending_secret_deletions:type_name -> openshell.v1.StoredRefreshMaterialDeletion 7, // 70: openshell.v1.StoredProviderCredentialRefreshState.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction - 229, // 71: openshell.v1.StoredRefreshMaterialDeletion.handle:type_name -> openshell.datamodel.v1.CredentialHandle + 231, // 71: openshell.v1.StoredRefreshMaterialDeletion.handle:type_name -> openshell.datamodel.v1.CredentialHandle 87, // 72: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus 2, // 73: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 213, // 74: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + 215, // 74: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry 87, // 75: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus 87, // 76: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus 3, // 77: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory 83, // 78: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential - 230, // 79: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint - 231, // 80: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 232, // 79: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 233, // 80: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary 88, // 81: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery - 214, // 82: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry - 225, // 83: openshell.v1.StoredProviderProfile.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 216, // 82: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry + 227, // 83: openshell.v1.StoredProviderProfile.metadata:type_name -> openshell.datamodel.v1.ObjectMeta 99, // 84: openshell.v1.StoredProviderProfile.profile:type_name -> openshell.v1.ProviderProfile 99, // 85: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile 99, // 86: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile @@ -15577,74 +15696,74 @@ var file_openshell_proto_depIdxs = []int32{ 78, // 93: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem 79, // 94: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic 113, // 95: openshell.v1.StaticCredentialBinding.endpoints:type_name -> openshell.v1.StaticCredentialEndpointBinding - 215, // 96: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - 216, // 97: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expires_at_ms:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry - 217, // 98: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - 218, // 99: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry - 226, // 100: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 232, // 101: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue + 217, // 96: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + 218, // 97: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expires_at_ms:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry + 219, // 98: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + 220, // 99: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry + 228, // 100: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 234, // 101: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue 119, // 102: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation - 219, // 103: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry + 221, // 103: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry 120, // 104: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule 121, // 105: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint 122, // 106: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule 123, // 107: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules 124, // 108: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules 125, // 109: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary - 233, // 110: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 234, // 111: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule - 235, // 112: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule - 220, // 113: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry + 235, // 110: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 236, // 111: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 237, // 112: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule + 222, // 113: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry 133, // 114: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision 133, // 115: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision 4, // 116: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus 4, // 117: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus - 226, // 118: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 221, // 119: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry + 228, // 118: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 223, // 119: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry 67, // 120: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine 67, // 121: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine 140, // 122: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello 143, // 123: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat - 152, // 124: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult - 153, // 125: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose + 154, // 124: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult + 155, // 125: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose 141, // 126: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted 142, // 127: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected 144, // 128: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat - 147, // 129: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen - 153, // 130: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose - 148, // 131: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget - 149, // 132: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget - 150, // 133: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit - 154, // 134: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample - 156, // 135: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount - 233, // 136: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 226, // 137: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 226, // 138: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 155, // 139: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary - 158, // 140: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk - 157, // 141: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary - 158, // 142: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk - 168, // 143: openshell.v1.ApproveAllDraftChunksRequest.approvals:type_name -> openshell.v1.DraftChunkApproval - 233, // 144: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 178, // 145: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry - 226, // 146: openshell.v1.PolicyRevisionPayload.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 222, // 147: openshell.v1.PolicyRevisionPayload.provenance:type_name -> openshell.v1.PolicyRevisionPayload.ProvenanceEntry - 233, // 148: openshell.v1.DraftChunkPayload.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 226, // 149: openshell.v1.DraftChunkPayload.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 226, // 150: openshell.v1.DraftChunkPayload.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 223, // 151: openshell.v1.StoredPolicyRevision.provenance:type_name -> openshell.v1.StoredPolicyRevision.ProvenanceEntry - 226, // 152: openshell.v1.StoredDraftChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 226, // 153: openshell.v1.StoredDraftChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 224, // 154: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry - 236, // 155: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 236, // 156: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 236, // 157: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace - 225, // 158: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 149, // 129: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen + 155, // 130: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose + 150, // 131: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget + 151, // 132: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget + 152, // 133: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit + 156, // 134: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample + 158, // 135: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount + 235, // 136: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 228, // 137: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 228, // 138: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 157, // 139: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary + 160, // 140: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk + 159, // 141: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary + 160, // 142: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk + 170, // 143: openshell.v1.ApproveAllDraftChunksRequest.approvals:type_name -> openshell.v1.DraftChunkApproval + 235, // 144: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 180, // 145: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry + 228, // 146: openshell.v1.PolicyRevisionPayload.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 224, // 147: openshell.v1.PolicyRevisionPayload.provenance:type_name -> openshell.v1.PolicyRevisionPayload.ProvenanceEntry + 235, // 148: openshell.v1.DraftChunkPayload.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 228, // 149: openshell.v1.DraftChunkPayload.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 228, // 150: openshell.v1.DraftChunkPayload.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 225, // 151: openshell.v1.StoredPolicyRevision.provenance:type_name -> openshell.v1.StoredPolicyRevision.ProvenanceEntry + 228, // 152: openshell.v1.StoredDraftChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 228, // 153: openshell.v1.StoredDraftChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 226, // 154: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry + 238, // 155: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 238, // 156: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 238, // 157: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace + 227, // 158: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta 6, // 159: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole 6, // 160: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole - 192, // 161: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember - 192, // 162: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember - 229, // 163: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry.value:type_name -> openshell.datamodel.v1.CredentialHandle + 194, // 161: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember + 194, // 162: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember + 231, // 163: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry.value:type_name -> openshell.datamodel.v1.CredentialHandle 83, // 164: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential 114, // 165: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding 12, // 166: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest @@ -15683,8 +15802,8 @@ var file_openshell_proto_depIdxs = []int32{ 97, // 199: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest 73, // 200: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest 110, // 201: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest - 237, // 202: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest - 238, // 203: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest + 239, // 202: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest + 240, // 203: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest 118, // 204: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest 127, // 205: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest 129, // 206: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest @@ -15695,96 +15814,98 @@ var file_openshell_proto_depIdxs = []int32{ 135, // 211: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest 138, // 212: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage 145, // 213: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest - 151, // 214: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame - 65, // 215: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest - 160, // 216: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest - 162, // 217: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest - 164, // 218: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest - 166, // 219: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest - 169, // 220: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest - 171, // 221: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest - 173, // 222: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest - 175, // 223: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest - 177, // 224: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest - 8, // 225: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest - 10, // 226: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest - 184, // 227: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest - 186, // 228: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest - 188, // 229: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest - 190, // 230: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest - 193, // 231: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest - 195, // 232: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest - 197, // 233: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest - 13, // 234: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse - 15, // 235: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse - 17, // 236: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse - 37, // 237: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse - 37, // 238: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse - 38, // 239: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse - 39, // 240: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse - 40, // 241: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse - 41, // 242: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse - 42, // 243: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse - 37, // 244: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse - 37, // 245: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse - 44, // 246: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse - 52, // 247: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse - 52, // 248: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse - 48, // 249: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse - 50, // 250: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse - 54, // 251: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse - 59, // 252: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent - 61, // 253: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame - 59, // 254: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent - 74, // 255: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse - 74, // 256: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse - 75, // 257: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse - 102, // 258: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse - 101, // 259: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse - 104, // 260: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse - 106, // 261: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse - 108, // 262: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse - 74, // 263: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse - 92, // 264: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse - 94, // 265: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse - 96, // 266: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse - 98, // 267: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse - 109, // 268: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse - 111, // 269: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse - 239, // 270: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse - 240, // 271: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse - 126, // 272: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse - 128, // 273: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse - 130, // 274: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse - 132, // 275: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse - 115, // 276: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse - 117, // 277: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse - 137, // 278: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse - 136, // 279: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse - 139, // 280: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage - 146, // 281: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse - 151, // 282: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame - 66, // 283: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent - 161, // 284: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse - 163, // 285: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse - 165, // 286: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse - 167, // 287: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse - 170, // 288: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse - 172, // 289: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse - 174, // 290: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse - 176, // 291: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse - 179, // 292: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse - 9, // 293: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse - 11, // 294: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse - 185, // 295: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse - 187, // 296: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse - 189, // 297: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse - 191, // 298: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse - 194, // 299: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse - 196, // 300: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse - 198, // 301: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse - 234, // [234:302] is the sub-list for method output_type - 166, // [166:234] is the sub-list for method input_type + 147, // 214: openshell.v1.OpenShell.FinalizeMainProcessExit:input_type -> openshell.v1.FinalizeMainProcessExitRequest + 153, // 215: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame + 65, // 216: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest + 162, // 217: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest + 164, // 218: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest + 166, // 219: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest + 168, // 220: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest + 171, // 221: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest + 173, // 222: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest + 175, // 223: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest + 177, // 224: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest + 179, // 225: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest + 8, // 226: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest + 10, // 227: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest + 186, // 228: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest + 188, // 229: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest + 190, // 230: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest + 192, // 231: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest + 195, // 232: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest + 197, // 233: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest + 199, // 234: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest + 13, // 235: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse + 15, // 236: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse + 17, // 237: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse + 37, // 238: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse + 37, // 239: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse + 38, // 240: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse + 39, // 241: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse + 40, // 242: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse + 41, // 243: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse + 42, // 244: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse + 37, // 245: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse + 37, // 246: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse + 44, // 247: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse + 52, // 248: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse + 52, // 249: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse + 48, // 250: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse + 50, // 251: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse + 54, // 252: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse + 59, // 253: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent + 61, // 254: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame + 59, // 255: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent + 74, // 256: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse + 74, // 257: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse + 75, // 258: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse + 102, // 259: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse + 101, // 260: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse + 104, // 261: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse + 106, // 262: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse + 108, // 263: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse + 74, // 264: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse + 92, // 265: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse + 94, // 266: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse + 96, // 267: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse + 98, // 268: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse + 109, // 269: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse + 111, // 270: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse + 241, // 271: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse + 242, // 272: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse + 126, // 273: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse + 128, // 274: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse + 130, // 275: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse + 132, // 276: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse + 115, // 277: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse + 117, // 278: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse + 137, // 279: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse + 136, // 280: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse + 139, // 281: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage + 146, // 282: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse + 148, // 283: openshell.v1.OpenShell.FinalizeMainProcessExit:output_type -> openshell.v1.FinalizeMainProcessExitResponse + 153, // 284: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame + 66, // 285: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent + 163, // 286: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse + 165, // 287: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse + 167, // 288: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse + 169, // 289: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse + 172, // 290: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse + 174, // 291: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse + 176, // 292: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse + 178, // 293: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse + 181, // 294: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse + 9, // 295: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse + 11, // 296: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse + 187, // 297: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse + 189, // 298: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse + 191, // 299: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse + 193, // 300: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse + 196, // 301: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse + 198, // 302: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse + 200, // 303: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse + 235, // [235:304] is the sub-list for method output_type + 166, // [166:235] is the sub-list for method input_type 166, // [166:166] is the sub-list for extension type_name 166, // [166:166] is the sub-list for extension extendee 0, // [0:166] is the sub-list for field type_name @@ -15845,23 +15966,23 @@ func file_openshell_proto_init() { (*GatewayMessage_RelayOpen)(nil), (*GatewayMessage_RelayClose)(nil), } - file_openshell_proto_msgTypes[139].OneofWrappers = []any{ + file_openshell_proto_msgTypes[141].OneofWrappers = []any{ (*RelayOpen_Ssh)(nil), (*RelayOpen_Tcp)(nil), } - file_openshell_proto_msgTypes[143].OneofWrappers = []any{ + file_openshell_proto_msgTypes[145].OneofWrappers = []any{ (*RelayFrame_Init)(nil), (*RelayFrame_Data)(nil), } - file_openshell_proto_msgTypes[174].OneofWrappers = []any{} - file_openshell_proto_msgTypes[175].OneofWrappers = []any{} + file_openshell_proto_msgTypes[176].OneofWrappers = []any{} + file_openshell_proto_msgTypes[177].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_openshell_proto_rawDesc), len(file_openshell_proto_rawDesc)), NumEnums: 8, - NumMessages: 217, + NumMessages: 219, NumExtensions: 0, NumServices: 1, }, diff --git a/sdk/go/proto/openshellv1/openshell_grpc.pb.go b/sdk/go/proto/openshellv1/openshell_grpc.pb.go index 663c09aed5..0d98c66ac8 100644 --- a/sdk/go/proto/openshellv1/openshell_grpc.pb.go +++ b/sdk/go/proto/openshellv1/openshell_grpc.pb.go @@ -71,6 +71,7 @@ const ( OpenShell_PushSandboxLogs_FullMethodName = "/openshell.v1.OpenShell/PushSandboxLogs" OpenShell_ConnectSupervisor_FullMethodName = "/openshell.v1.OpenShell/ConnectSupervisor" OpenShell_ReportMainProcessExit_FullMethodName = "/openshell.v1.OpenShell/ReportMainProcessExit" + OpenShell_FinalizeMainProcessExit_FullMethodName = "/openshell.v1.OpenShell/FinalizeMainProcessExit" OpenShell_RelayStream_FullMethodName = "/openshell.v1.OpenShell/RelayStream" OpenShell_WatchSandbox_FullMethodName = "/openshell.v1.OpenShell/WatchSandbox" OpenShell_SubmitPolicyAnalysis_FullMethodName = "/openshell.v1.OpenShell/SubmitPolicyAnalysis" @@ -218,6 +219,8 @@ type OpenShellClient interface { ConnectSupervisor(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[SupervisorMessage, GatewayMessage], error) // Persist the canonical main process result before the supervisor exits. ReportMainProcessExit(ctx context.Context, in *ReportMainProcessExitRequest, opts ...grpc.CallOption) (*ReportMainProcessExitResponse, error) + // Confirm that foreground terminal delivery completed naturally. + FinalizeMainProcessExit(ctx context.Context, in *FinalizeMainProcessExitRequest, opts ...grpc.CallOption) (*FinalizeMainProcessExitResponse, error) // Raw byte relay between supervisor and gateway. // // The supervisor initiates this call after receiving a RelayOpen message @@ -793,6 +796,16 @@ func (c *openShellClient) ReportMainProcessExit(ctx context.Context, in *ReportM return out, nil } +func (c *openShellClient) FinalizeMainProcessExit(ctx context.Context, in *FinalizeMainProcessExitRequest, opts ...grpc.CallOption) (*FinalizeMainProcessExitResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(FinalizeMainProcessExitResponse) + err := c.cc.Invoke(ctx, OpenShell_FinalizeMainProcessExit_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *openShellClient) RelayStream(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[RelayFrame, RelayFrame], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) stream, err := c.cc.NewStream(ctx, &OpenShell_ServiceDesc.Streams[5], OpenShell_RelayStream_FullMethodName, cOpts...) @@ -1130,6 +1143,8 @@ type OpenShellServer interface { ConnectSupervisor(grpc.BidiStreamingServer[SupervisorMessage, GatewayMessage]) error // Persist the canonical main process result before the supervisor exits. ReportMainProcessExit(context.Context, *ReportMainProcessExitRequest) (*ReportMainProcessExitResponse, error) + // Confirm that foreground terminal delivery completed naturally. + FinalizeMainProcessExit(context.Context, *FinalizeMainProcessExitRequest) (*FinalizeMainProcessExitResponse, error) // Raw byte relay between supervisor and gateway. // // The supervisor initiates this call after receiving a RelayOpen message @@ -1348,6 +1363,9 @@ func (UnimplementedOpenShellServer) ConnectSupervisor(grpc.BidiStreamingServer[S func (UnimplementedOpenShellServer) ReportMainProcessExit(context.Context, *ReportMainProcessExitRequest) (*ReportMainProcessExitResponse, error) { return nil, status.Error(codes.Unimplemented, "method ReportMainProcessExit not implemented") } +func (UnimplementedOpenShellServer) FinalizeMainProcessExit(context.Context, *FinalizeMainProcessExitRequest) (*FinalizeMainProcessExitResponse, error) { + return nil, status.Error(codes.Unimplemented, "method FinalizeMainProcessExit not implemented") +} func (UnimplementedOpenShellServer) RelayStream(grpc.BidiStreamingServer[RelayFrame, RelayFrame]) error { return status.Error(codes.Unimplemented, "method RelayStream not implemented") } @@ -2242,6 +2260,24 @@ func _OpenShell_ReportMainProcessExit_Handler(srv interface{}, ctx context.Conte return interceptor(ctx, in, info, handler) } +func _OpenShell_FinalizeMainProcessExit_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FinalizeMainProcessExitRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).FinalizeMainProcessExit(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_FinalizeMainProcessExit_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).FinalizeMainProcessExit(ctx, req.(*FinalizeMainProcessExitRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _OpenShell_RelayStream_Handler(srv interface{}, stream grpc.ServerStream) error { return srv.(OpenShellServer).RelayStream(&grpc.GenericServerStream[RelayFrame, RelayFrame]{ServerStream: stream}) } @@ -2763,6 +2799,10 @@ var OpenShell_ServiceDesc = grpc.ServiceDesc{ MethodName: "ReportMainProcessExit", Handler: _OpenShell_ReportMainProcessExit_Handler, }, + { + MethodName: "FinalizeMainProcessExit", + Handler: _OpenShell_FinalizeMainProcessExit_Handler, + }, { MethodName: "SubmitPolicyAnalysis", Handler: _OpenShell_SubmitPolicyAnalysis_Handler, diff --git a/sdk/typescript/src/client.test.ts b/sdk/typescript/src/client.test.ts index 1c08e423ca..29a7438c69 100644 --- a/sdk/typescript/src/client.test.ts +++ b/sdk/typescript/src/client.test.ts @@ -291,6 +291,30 @@ describe('create', () => { }); describe('waits', () => { + it('waitReady accepts successful main-process completion', async () => { + const sandbox = client({ + getSandbox: () => ({ + sandbox: { + metadata: { id: 'sb-id', name: 'sb' }, + status: { phase: SandboxPhase.COMPLETED, exitCode: 0 }, + }, + }), + }); + await expect(sandbox.waitReady('sb', 1)).resolves.toMatchObject({ phase: 'completed', exitCode: 0 }); + }); + + it('waitReady rejects stopped main-process results without waiting for timeout', async () => { + const sandbox = client({ + getSandbox: () => ({ + sandbox: { + metadata: { id: 'sb-id', name: 'sb' }, + status: { phase: SandboxPhase.STOPPED, exitCode: 7 }, + }, + }), + }); + await expect(sandbox.waitReady('sb', 30)).rejects.toMatchObject({ code: 'connect' }); + }); + it('waitReady rejects rather than hanging when get() never resolves', async () => { const sandbox = client({ // Only settles when the per-poll deadline signal aborts the call. diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index b6db97943f..3f3247ca2a 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -59,7 +59,8 @@ export type SandboxPhaseName = | 'unknown' | 'stopping' | 'stopped' - | 'starting'; + | 'starting' + | 'completed'; /** Lowercase mirror of the generated `ServiceStatus` enum. Hand-maintained. */ export type HealthStatus = 'unspecified' | 'healthy' | 'degraded' | 'unhealthy'; @@ -293,6 +294,7 @@ export const PHASE_NAMES: Record = { [SandboxPhase.STOPPING]: 'stopping', [SandboxPhase.STOPPED]: 'stopped', [SandboxPhase.STARTING]: 'starting', + [SandboxPhase.COMPLETED]: 'completed', }; export const STATUS_NAMES: Record = { [ServiceStatus.UNSPECIFIED]: 'unspecified', @@ -632,7 +634,8 @@ export class SandboxClient { } catch (e) { throw mapWaitError(e, name, deadline, signal); } - if (ref.phase === 'ready') return ref; + if (ref.phase === 'ready' || ref.phase === 'completed') return ref; + if (ref.phase === 'stopped') throw new SdkError('connect', `sandbox '${name}' stopped before becoming ready`); if (ref.phase === 'error') throw new SdkError('connect', `sandbox '${name}' entered error phase`); if (Date.now() >= deadline) throw new SdkError('connect', `timed out waiting for sandbox '${name}'`); await waitSleep(delay, deadline, signal); From 883a1f01ce3d0ff023163fdb4d989c7c433c7974 Mon Sep 17 00:00:00 2001 From: Simon Scatton <44714756+SDAChess@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:03:00 +0000 Subject: [PATCH 050/505] fix(ci): preserve VM runtime embedding inputs (#3040) Closes #3038 Compress platform runtime inputs in Nix and stage the complete bundle outside Cargo's target directory. Reject missing or empty embedding artifacts during the driver build. Signed-off-by: Simon Scatton --- .github/workflows/build-vm-driver.yml | 26 +++------- architecture/build.md | 7 ++- crates/openshell-driver-vm/build.rs | 69 ++++++++++----------------- nix/pkgs/vm-runtime.nix | 26 ++++++++++ 4 files changed, 63 insertions(+), 65 deletions(-) diff --git a/.github/workflows/build-vm-driver.yml b/.github/workflows/build-vm-driver.yml index a83c8c0834..9df710cb82 100644 --- a/.github/workflows/build-vm-driver.yml +++ b/.github/workflows/build-vm-driver.yml @@ -67,31 +67,17 @@ jobs: - name: Build VM runtime run: nix build .#vm-runtime - - name: Compress Linux VM runtime - if: runner.os == 'Linux' + - name: Assemble compressed VM runtime run: | - install -d target/vm-runtime-compressed - zstd -19 -T0 result/libkrun.so -o target/vm-runtime-compressed/libkrun.so.zst - zstd -19 -T0 result/libkrunfw.so.5 -o target/vm-runtime-compressed/libkrunfw.so.5.zst - zstd -19 -T0 result/gvproxy -o target/vm-runtime-compressed/gvproxy.zst - zstd -19 -T0 result/umoci -o target/vm-runtime-compressed/umoci.zst - - - name: Compress macOS VM runtime - if: runner.os == 'macOS' - run: | - install -d target/vm-runtime-compressed - zstd -19 -T0 result/libkrun.dylib -o target/vm-runtime-compressed/libkrun.dylib.zst - zstd -19 -T0 result/libkrunfw.5.dylib -o target/vm-runtime-compressed/libkrunfw.5.dylib.zst - zstd -19 -T0 result/gvproxy -o target/vm-runtime-compressed/gvproxy.zst - zstd -19 -T0 result/umoci -o target/vm-runtime-compressed/umoci.zst - - - name: Add openshell-sandbox to VM runtime - run: zstd -19 -T0 sandbox/openshell-sandbox -o target/vm-runtime-compressed/openshell-sandbox.zst + compressed_dir="${RUNNER_TEMP}/vm-runtime-compressed" + install -d "$compressed_dir" + cp result/compressed/*.zst "$compressed_dir/" + zstd -19 -T1 sandbox/openshell-sandbox -o "$compressed_dir/openshell-sandbox.zst" - name: Build openshell-driver-vm uses: ./.github/actions/build-rust-binary env: - OPENSHELL_VM_RUNTIME_COMPRESSED_DIR: ${{ github.workspace }}/target/vm-runtime-compressed + OPENSHELL_VM_RUNTIME_COMPRESSED_DIR: ${{ runner.temp }}/vm-runtime-compressed with: package: openshell-driver-vm binary: openshell-driver-vm diff --git a/architecture/build.md b/architecture/build.md index c6810feece..4ffec5b712 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -174,7 +174,12 @@ Runtime layout: `/usr/libexec/openshell/openshell-driver-vm` in Linux packages and published as a release artifact. Linux GNU VM driver binaries must not reference `GLIBC_*` symbols newer than `GLIBC_2.28`; release workflows verify this - before publishing artifacts. + before publishing artifacts. Nix produces the platform-specific compressed + runtime inputs. CI combines them with the matching supervisor artifact in a + runner-temporary directory outside Cargo's `target/` before the shared Rust + cache action runs. An explicitly configured VM runtime bundle is required to + contain every non-empty embedding input; the driver build fails before + packaging when an input is absent or empty. - **Supervisor**: Alpine base with `nftables`, static binary at `/openshell-sandbox` (musl by default; see `SUPERVISOR_LIBC` above). Static linkage keeps the binary usable when the image is mounted/extracted into diff --git a/crates/openshell-driver-vm/build.rs b/crates/openshell-driver-vm/build.rs index 1763590545..92532ed7b2 100644 --- a/crates/openshell-driver-vm/build.rs +++ b/crates/openshell-driver-vm/build.rs @@ -62,24 +62,11 @@ fn main() { return; }; - if !compressed_dir.is_dir() { - println!( - "cargo:warning=Compressed runtime dir not found: {}", - compressed_dir.display() - ); - println!("cargo:warning=Run: mise run vm:setup && mise run vm:supervisor"); - generate_stub_resources( - &out_dir, - &[ - &format!("{libkrun_name}.zst"), - &format!("{libkrunfw_name}.zst"), - "gvproxy.zst", - "openshell-sandbox.zst", - "umoci.zst", - ], - ); - return; - } + assert!( + compressed_dir.is_dir(), + "Compressed runtime dir not found: {}. Run: mise run vm:setup && mise run vm:supervisor", + compressed_dir.display() + ); let files = [ (format!("{libkrun_name}.zst"), format!("{libkrun_name}.zst")), @@ -95,20 +82,30 @@ fn main() { ("umoci.zst".to_string(), "umoci.zst".to_string()), ]; - let mut all_found = true; + for (src_name, _) in &files { + let src_path = compressed_dir.join(src_name); + let metadata = fs::metadata(&src_path).unwrap_or_else(|e| { + panic!( + "Required compressed artifact unavailable: {}: {e}", + src_path.display() + ) + }); + assert!( + metadata.is_file(), + "Required compressed artifact is not a file: {}", + src_path.display() + ); + assert!( + metadata.len() != 0, + "Required compressed artifact is empty: {}", + src_path.display() + ); + } + for (src_name, dst_name) in &files { let src_path = compressed_dir.join(src_name); let dst_path = out_dir.join(dst_name); - if !src_path.exists() { - println!( - "cargo:warning=Missing compressed artifact: {}", - src_path.display() - ); - all_found = false; - continue; - } - if dst_path.exists() { let _ = fs::remove_file(&dst_path); } @@ -123,22 +120,6 @@ fn main() { let size = fs::metadata(&dst_path).map_or(0, |m| m.len()); println!("cargo:warning=Embedded {src_name}: {size} bytes"); } - - if !all_found { - println!( - "cargo:warning=Some artifacts missing. Run: mise run vm:setup && mise run vm:supervisor" - ); - generate_stub_resources( - &out_dir, - &[ - &format!("{libkrun_name}.zst"), - &format!("{libkrunfw_name}.zst"), - "gvproxy.zst", - "openshell-sandbox.zst", - "umoci.zst", - ], - ); - } } fn generate_stub_resources(out_dir: &Path, names: &[&str]) { diff --git a/nix/pkgs/vm-runtime.nix b/nix/pkgs/vm-runtime.nix index 49a051e276..963209c8a6 100644 --- a/nix/pkgs/vm-runtime.nix +++ b/nix/pkgs/vm-runtime.nix @@ -3,6 +3,7 @@ { fetchurl, + lib, stdenv, zstd, }: @@ -13,14 +14,32 @@ let x86_64-linux = { platform = "linux-x86_64"; hash = "sha256-dw3Lc7IapCyNeE7j6dnlgd/b8Yc91/7IOi3XJORyILQ="; + artifacts = [ + "libkrun.so" + "libkrunfw.so.5" + "gvproxy" + "umoci" + ]; }; aarch64-linux = { platform = "linux-aarch64"; hash = "sha256-aJDuDb7AsuH9R+AyXA/JIxE9fJmZ5kP0Lkhg6F0Ot5A="; + artifacts = [ + "libkrun.so" + "libkrunfw.so.5" + "gvproxy" + "umoci" + ]; }; aarch64-darwin = { platform = "darwin-aarch64"; hash = "sha256-BDSeY5XGDozaBZzHTiQQX90jzsSc6shJZs5zdzludX0="; + artifacts = [ + "libkrun.dylib" + "libkrunfw.5.dylib" + "gvproxy" + "umoci" + ]; }; } .${stdenv.hostPlatform.system}; @@ -41,6 +60,13 @@ stdenv.mkDerivation { mkdir -p "$out" tar --extract --file ${archive} --directory "$out" + mkdir -p "$out/compressed" + for artifact in ${lib.escapeShellArgs runtime.artifacts}; do + zstd -19 -T1 "$out/$artifact" -o "$out/compressed/$artifact.zst" + test -s "$out/compressed/$artifact.zst" + zstd --test --quiet "$out/compressed/$artifact.zst" + done + runHook postInstall ''; } From eb15e1a4c9d9718530bf5337df2001426a88e97c Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Mon, 31 Aug 2026 16:17:58 +0000 Subject: [PATCH 051/505] feat(sandbox): add --no-login-shell to skip shell startup files on exec (#2852) * feat(sandbox): add --no-login-shell to skip shell startup files on exec Signed-off-by: Artem Lytvyn * chore(sdk/go): regenerate proto bindings for no_login_shell Signed-off-by: Artem Lytvyn * test(supervisor-process): cover login-shell flag selection Signed-off-by: Artem Lytvyn * feat(sandbox): gate --no-login-shell on supervisor SSH banner Signed-off-by: Artem Lytvyn --------- Signed-off-by: Artem Lytvyn --- architecture/sandbox.md | 9 +- crates/openshell-cli/src/main.rs | 11 ++ crates/openshell-cli/src/run.rs | 5 + crates/openshell-sdk/src/client.rs | 2 + crates/openshell-sdk/src/types.rs | 3 + crates/openshell-server/src/grpc/sandbox.rs | 117 +++++++++++++++++- .../openshell-supervisor-process/src/ssh.rs | 75 +++++++++-- docs/sandboxes/manage-sandboxes.mdx | 29 +++-- proto/openshell.proto | 7 ++ python/openshell/sandbox.py | 8 ++ .../openshell/v1/internal/converter/exec.go | 1 + sdk/go/openshell/v1/types/options.go | 4 + sdk/go/proto/openshellv1/openshell.pb.go | 21 +++- sdk/typescript/src/client.ts | 13 ++ 14 files changed, 281 insertions(+), 24 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index b1209167c4..6365ed0631 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -405,7 +405,14 @@ sandbox workload directly. The relay supports: - Attachment to the canonical main process through the `openshell-main` SSH subsystem. The supervisor owns its retained PTY or pipes, a 1 MiB replay buffer, and a single stdin lease across client disconnects. -- Independent shell and command execution sessions. +- Independent interactive shell sessions. +- Command execution. Commands run through a login shell (`bash -lc`) by default, + so the first of the user's `.bash_profile`, `.bash_login`, or `.profile` is + sourced (and `.bashrc` only if that file sources it). Callers set + `ExecSandboxRequest.no_login_shell` to skip those files; the gateway signals + this to the supervisor over the SSH `OPENSHELL_NO_LOGIN_SHELL` env request, + which selects `bash -c` instead of `bash -lc`. Note `bash -c` still reads + `BASH_ENV` when the child environment sets it. - Tar-based file sync. - Port forwarding where supported by the CLI/TUI surface. diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index d17e830969..ad03fb4ec1 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1597,6 +1597,15 @@ enum SandboxCommands { #[arg(long, overrides_with = "tty")] no_tty: bool, + /// Run the command without sourcing shell login/profile startup files. + /// + /// Default sources them so tool-specific env (`VIRTUAL_ENV`, etc.) is + /// available. Use this for automation and managed checks that need + /// predictable startup behavior — sandbox-user startup files cannot run + /// before the requested command. + #[arg(long)] + no_login_shell: bool, + /// Set a non-secret environment variable for the command. /// Do not use this option for API keys, tokens, or other secrets; attach /// a provider to the sandbox instead. Repeatable. @@ -3245,6 +3254,7 @@ async fn run_async() -> Result<()> { no_tty, envs, command, + no_login_shell, } => { let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; // Resolve --tty / --no-tty into an Option override. @@ -3264,6 +3274,7 @@ async fn run_async() -> Result<()> { timeout, tty_override, &env_map, + no_login_shell, &tls, &cli.workspace, ) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index ae139ff665..b89f4f9371 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -1489,6 +1489,7 @@ pub async fn sandbox_exec_grpc( timeout_seconds: u32, tty_override: Option, environment: &HashMap, + no_login_shell: bool, tls: &TlsOptions, workspace: &str, ) -> Result { @@ -1552,6 +1553,7 @@ pub async fn sandbox_exec_grpc( workdir, timeout_seconds, environment, + no_login_shell, ) .await; } @@ -1566,6 +1568,7 @@ pub async fn sandbox_exec_grpc( timeout_seconds, stdin: stdin_payload, tty, + no_login_shell, ..Default::default() }) .await @@ -1915,6 +1918,7 @@ async fn sandbox_exec_interactive_grpc( workdir: Option<&str>, timeout_seconds: u32, environment: &HashMap, + no_login_shell: bool, ) -> Result { #[cfg(unix)] use openshell_core::proto::ExecSandboxWindowResize; @@ -1933,6 +1937,7 @@ async fn sandbox_exec_interactive_grpc( command: command.to_vec(), workdir: workdir.unwrap_or_default().to_string(), environment: environment.clone(), + no_login_shell, timeout_seconds, stdin: Vec::new(), tty: true, diff --git a/crates/openshell-sdk/src/client.rs b/crates/openshell-sdk/src/client.rs index fd2b7d5273..a02b5735ae 100644 --- a/crates/openshell-sdk/src/client.rs +++ b/crates/openshell-sdk/src/client.rs @@ -411,6 +411,7 @@ impl OpenShellClient { tty: false, cols: 0, rows: 0, + no_login_shell: opts.no_login_shell, }; // Open the stream under the same OIDC-aware auth policy as unary RPCs @@ -716,6 +717,7 @@ impl WorkspaceScopedClient { tty: false, cols: 0, rows: 0, + no_login_shell: opts.no_login_shell, }; let mut stream = self diff --git a/crates/openshell-sdk/src/types.rs b/crates/openshell-sdk/src/types.rs index f7f1547115..9cb7171fd4 100644 --- a/crates/openshell-sdk/src/types.rs +++ b/crates/openshell-sdk/src/types.rs @@ -197,6 +197,9 @@ pub struct ExecOptions { pub timeout: Option, /// Optional stdin payload. pub stdin: Option>, + /// Skip sourcing shell login/profile startup files before the command. + /// Default (`false`) preserves login-shell behavior. + pub no_login_shell: bool, } /// Result of a non-streaming exec call. diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 403bece8b9..a8d198bc80 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -60,6 +60,7 @@ use super::{MAX_PAGE_SIZE, MAX_PROVIDERS, MAX_ROUTABLE_NAME_LEN, clamp_limit}; use crate::persistence::current_time_ms; const TCP_FORWARD_CHUNK_SIZE: usize = 64 * 1024; +const NO_LOGIN_SHELL_ENV: (&str, &str) = ("OPENSHELL_NO_LOGIN_SHELL", "1"); #[derive(Debug)] pub struct WatchSandboxStream { @@ -1238,6 +1239,8 @@ pub(super) async fn handle_exec_sandbox( let sandbox_id = sandbox.object_id().to_string(); + let no_login_shell = req.no_login_shell; + let (tx, rx) = mpsc::channel::>(256); tokio::spawn(async move { // Wait for the supervisor's reverse CONNECT to deliver the relay stream. @@ -1256,6 +1259,7 @@ pub(super) async fn handle_exec_sandbox( stdin_payload, timeout_seconds, request_tty, + no_login_shell, ) .await { @@ -1407,7 +1411,7 @@ async fn acquire_forward_connection_guard( Ok(ForwardConnectionGuard { state: state.clone(), token: Some(token.to_string()), - sandbox_id, + sandbox_id: sandbox_id.clone(), }) } @@ -1665,6 +1669,7 @@ pub(super) async fn handle_exec_sandbox_interactive( let command_str = build_remote_exec_command(&req) .map_err(|e| Status::invalid_argument(format!("command construction failed: {e}")))?; let request_tty = req.tty; + let no_login_shell = req.no_login_shell; let timeout_seconds = req.timeout_seconds; let cols = if req.cols == 0 { 80 } else { req.cols }; let rows = if req.rows == 0 { 24 } else { req.rows }; @@ -1693,6 +1698,7 @@ pub(super) async fn handle_exec_sandbox_interactive( &command_str, input_stream, request_tty, + no_login_shell, timeout_seconds, cols, rows, @@ -1920,6 +1926,16 @@ const EXEC_KEEPALIVE_MAX: usize = 4; /// Max wait for a trailing `Close` after `ExitStatus`. const EXEC_POST_EXIT_CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500); +/// Supervisor SSH banner software token that signals no-login-shell support. +const OPENSHELL_SSHID_PREFIX: &[u8] = b"SSH-2.0-OpenShell_"; + +/// A supervisor honors `OPENSHELL_NO_LOGIN_SHELL` only if it identifies as +/// `OpenShell`. Older sandboxes present russh's default banner and silently +/// ignore the env request, so gate the opt-out on the `OpenShell` identity. +fn supervisor_supports_no_login_shell(remote_sshid: &[u8]) -> bool { + remote_sshid.starts_with(OPENSHELL_SSHID_PREFIX) +} + /// russh client config for exec relays. fn exec_ssh_client_config() -> russh::client::Config { russh::client::Config { @@ -1980,6 +1996,7 @@ async fn stream_exec_over_relay( stdin_payload: Vec, timeout_seconds: u32, request_tty: bool, + no_login_shell: bool, ) -> Result<(), Status> { let command_preview: String = command .chars() @@ -2004,6 +2021,7 @@ async fn stream_exec_over_relay( command, stdin_payload, request_tty, + no_login_shell, tx.clone(), ); @@ -2058,6 +2076,7 @@ async fn stream_interactive_exec_over_relay( command: &str, input_stream: tonic::Streaming, request_tty: bool, + no_login_shell: bool, timeout_seconds: u32, cols: u32, rows: u32, @@ -2084,6 +2103,7 @@ async fn stream_interactive_exec_over_relay( command, input_stream, request_tty, + no_login_shell, cols, rows, tx.clone(), @@ -2131,11 +2151,13 @@ async fn stream_interactive_exec_over_relay( Ok(()) } +#[allow(clippy::too_many_arguments)] async fn run_interactive_exec_with_russh( local_proxy_port: u16, command: &str, mut input_stream: tonic::Streaming, request_tty: bool, + no_login_shell: bool, cols: u32, rows: u32, tx: mpsc::Sender>, @@ -2162,7 +2184,11 @@ async fn run_interactive_exec_with_russh( set_tcp_nodelay_best_effort(&stream); let config = Arc::new(exec_ssh_client_config()); - let mut client = russh::client::connect_stream(config, stream, SandboxSshClientHandler) + let remote_sshid = Arc::new(std::sync::Mutex::new(None)); + let handler = SandboxSshClientHandler { + remote_sshid: remote_sshid.clone(), + }; + let mut client = russh::client::connect_stream(config, stream, handler) .await .map_err(|e| Status::internal(format!("failed to establish ssh transport: {e}")))?; @@ -2191,6 +2217,19 @@ async fn run_interactive_exec_with_russh( .map_err(|e| Status::internal(format!("failed to allocate PTY: {e}")))?; } + if no_login_shell { + let banner = remote_sshid.lock().unwrap().clone().unwrap_or_default(); + if !supervisor_supports_no_login_shell(&banner) { + return Err(Status::failed_precondition( + "sandbox supervisor is too old to honor --no-login-shell; recreate the sandbox on a current gateway", + )); + } + channel + .set_env(false, NO_LOGIN_SHELL_ENV.0, NO_LOGIN_SHELL_ENV.1) + .await + .map_err(|e| Status::internal(format!("failed to set login-shell env: {e}")))?; + } + channel .exec(true, command.as_bytes()) .await @@ -2302,8 +2341,10 @@ async fn start_single_use_ssh_proxy_over_relay( Ok((port, task)) } -#[derive(Debug, Clone, Copy)] -struct SandboxSshClientHandler; +#[derive(Debug, Clone)] +struct SandboxSshClientHandler { + remote_sshid: Arc>>>, +} impl russh::client::Handler for SandboxSshClientHandler { type Error = russh::Error; @@ -2314,6 +2355,16 @@ impl russh::client::Handler for SandboxSshClientHandler { ) -> Result { Ok(true) } + + async fn kex_done( + &mut self, + _shared_secret: Option<&[u8]>, + _names: &russh::Names, + session: &mut russh::client::Session, + ) -> Result<(), Self::Error> { + *self.remote_sshid.lock().unwrap() = Some(session.remote_sshid().to_vec()); + Ok(()) + } } async fn run_exec_with_russh( @@ -2321,6 +2372,7 @@ async fn run_exec_with_russh( command: &str, stdin_payload: Vec, request_tty: bool, + no_shell_login: bool, tx: mpsc::Sender>, ) -> Result { // Defense-in-depth: validate command at the transport boundary. @@ -2343,7 +2395,11 @@ async fn run_exec_with_russh( set_tcp_nodelay_best_effort(&stream); let config = Arc::new(exec_ssh_client_config()); - let mut client = russh::client::connect_stream(config, stream, SandboxSshClientHandler) + let remote_sshid = Arc::new(std::sync::Mutex::new(None)); + let handler = SandboxSshClientHandler { + remote_sshid: remote_sshid.clone(), + }; + let mut client = russh::client::connect_stream(config, stream, handler) .await .map_err(|e| Status::internal(format!("failed to establish ssh transport: {e}")))?; @@ -2372,6 +2428,19 @@ async fn run_exec_with_russh( .map_err(|e| Status::internal(format!("failed to allocate PTY: {e}")))?; } + if no_shell_login { + let banner = remote_sshid.lock().unwrap().clone().unwrap_or_default(); + if !supervisor_supports_no_login_shell(&banner) { + return Err(Status::failed_precondition( + "sandbox supervisor is too old to honor --no-login-shell; recreate the sandbox on a current gateway", + )); + } + channel + .set_env(false, NO_LOGIN_SHELL_ENV.0, NO_LOGIN_SHELL_ENV.1) + .await + .map_err(|e| Status::internal(format!("failed to set login-shell env: {e}")))?; + } + channel .exec(true, command.as_bytes()) .await @@ -4847,4 +4916,42 @@ mod tests { assert!(session.revoked); assert_eq!(session.object_workspace(), "default"); } + + // ---- supervisor_supports_no_login_shell ---- + + /// A current supervisor identifies itself with the `OpenShell` banner, so the + /// gateway may forward the login-shell opt-out. + #[test] + fn no_login_shell_gate_accepts_openshell_banner() { + assert!(supervisor_supports_no_login_shell( + b"SSH-2.0-OpenShell_0.0.4-dev.3+g2bf9969" + )); + assert!(supervisor_supports_no_login_shell( + b"SSH-2.0-OpenShell_0.1.0" + )); + } + + /// A supervisor predating this feature presents russh's default banner and + /// silently ignores the env request, so the gate must reject the opt-out. + #[test] + fn no_login_shell_gate_rejects_pre_feature_banner() { + assert!(!supervisor_supports_no_login_shell(b"SSH-2.0-Russh_0.62.5")); + assert!(!supervisor_supports_no_login_shell(b"SSH-2.0-OpenSSH_9.6")); + } + + /// A missing banner (kex callback never populated the slot) must fail + /// closed rather than forwarding the opt-out to an unknown supervisor. + #[test] + fn no_login_shell_gate_rejects_empty_banner() { + assert!(!supervisor_supports_no_login_shell(b"")); + } + + /// The banner prefix must match at the start; an `OpenShell` token appearing + /// only in the comment tail does not signal support. + #[test] + fn no_login_shell_gate_requires_prefix_position() { + assert!(!supervisor_supports_no_login_shell( + b"SSH-2.0-Russh_0.62.5 OpenShell_0.1.0" + )); + } } diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index 12daff8d0c..357969fb52 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -17,6 +17,7 @@ use libc; use miette::{IntoDiagnostic, Result}; use nix::pty::{Winsize, openpty}; use nix::unistd::setsid; +use openshell_core::VERSION; use openshell_core::net::set_tcp_nodelay_best_effort; use openshell_core::policy::SandboxPolicy; use openshell_core::provider_credentials::ProviderCredentialState; @@ -26,6 +27,7 @@ use openshell_ocsf::{ use russh::keys::{Algorithm, PrivateKey}; use russh::server::{Auth, ChannelOpenHandle, Handle, Session}; use russh::{ChannelId, ChannelOpenFailure, Sig}; +use std::borrow::Cow; use std::collections::HashMap; use std::io::{Read, Write}; use std::os::fd::{AsRawFd, RawFd}; @@ -36,6 +38,8 @@ use std::time::Duration; use tokio::net::UnixListener; use tracing::warn; +const NO_LOGIN_SHELL_ENV: (&str, &str) = ("OPENSHELL_NO_LOGIN_SHELL", "1"); + /// Perform SSH server initialization: generate a host key, build the config, /// and bind the Unix socket listener. Extracted so that startup errors can be /// forwarded through the readiness channel rather than being silently logged. @@ -54,7 +58,9 @@ fn ssh_server_init( let mut rng = rand::rng(); let host_key = PrivateKey::random(&mut rng, Algorithm::Ed25519).into_diagnostic()?; + // TODO: while building the SSH config, refactor the server_id to be "SSH-2.0-OpenShell_" from `openshell_core::VERSION` let mut config = russh::server::Config { + server_id: russh::SshId::Standard(Cow::Owned(format!("SSH-2.0-OpenShell_{VERSION}"))), auth_rejection_time: Duration::from_secs(1), ..Default::default() }; @@ -381,11 +387,15 @@ async fn handle_connection( /// sender. This allows `window_change_request` to resize the correct PTY when /// multiple channels are open simultaneously (e.g. parallel shells, shell + /// sftp, etc.). +// Several independent per-channel boolean flags (login-shell opt-out and the +// main-attachment state bits) legitimately live side by side here. +#[allow(clippy::struct_excessive_bools)] #[derive(Default)] struct ChannelState { input_sender: Option, pty_master: Option, pty_request: Option, + no_login_shell: bool, main_input_owner: Option, main_attached: bool, main_read_only: bool, @@ -840,6 +850,7 @@ impl russh::server::Handler for SshHandler { &self.policy, &self.workspace, Some("/usr/lib/openssh/sftp-server".to_string()), + false, session.handle(), channel, self.netns_fd, @@ -877,8 +888,17 @@ impl russh::server::Handler for SshHandler { session: &mut Session, ) -> Result<(), Self::Error> { // Accept the env request so the client knows we handled it, but we - // don't actually propagate the variables — the sandbox environment is - // controlled via policy. We must reply so VSCode doesn't stall. + // don't actually propagate arbitrary variables — the sandbox + // environment is controlled via policy. We must reply so VSCode + // doesn't stall. Two exceptions carry supervisor signals the SSH + // protocol has no native field for: + // - OPENSHELL_NO_LOGIN_SHELL: gateway login-shell opt-out. + // - OPENSHELL_MAIN_READ_ONLY: read-only main attachment. + if variable_name == NO_LOGIN_SHELL_ENV.0 + && let Some(state) = self.channels.get_mut(&channel) + { + state.no_login_shell = variable_value == NO_LOGIN_SHELL_ENV.1; + } if variable_name == "OPENSHELL_MAIN_READ_ONLY" && variable_value == "1" && let Some(state) = self.channels.get_mut(&channel) @@ -1050,6 +1070,7 @@ impl SshHandler { .channels .get_mut(&channel) .ok_or_else(|| anyhow::anyhow!("start_shell on unknown channel {channel:?}"))?; + let no_login_shell = state.no_login_shell; if let Some(pty) = state.pty_request.take() { // PTY was requested — allocate a real PTY (interactive shell or // exec that explicitly asked for a terminal). @@ -1057,6 +1078,7 @@ impl SshHandler { &self.policy, &self.workspace, command, + no_login_shell, &pty, handle, channel, @@ -1078,6 +1100,7 @@ impl SshHandler { &self.policy, &self.workspace, command, + no_login_shell, handle, channel, self.netns_fd, @@ -1215,11 +1238,16 @@ fn apply_child_env( } } +const fn login_shell_flag(no_login_shell: bool) -> &'static str { + if no_login_shell { "-c" } else { "-lc" } +} + #[allow(clippy::too_many_arguments)] fn spawn_pty_shell( policy: &SandboxPolicy, workspace: &ResolvedWorkspace, command: Option, + no_login_shell: bool, pty: &PtyRequest, handle: Handle, channel: ChannelId, @@ -1256,7 +1284,7 @@ fn spawn_pty_shell( }, |command| { let mut c = Command::new("/bin/bash"); - c.arg("-lc").arg(command); + c.arg(login_shell_flag(no_login_shell)).arg(command); c }, ); @@ -1396,6 +1424,7 @@ fn spawn_pipe_exec( policy: &SandboxPolicy, workspace: &ResolvedWorkspace, command: Option, + no_login_shell: bool, handle: Handle, channel: ChannelId, netns_fd: Option, @@ -1418,10 +1447,10 @@ fn spawn_pipe_exec( }, |command| { let mut c = Command::new("/bin/bash"); - // Use login shell (-l) so that .profile/.bashrc are sourced and - // tool-specific env vars (VIRTUAL_ENV, UV_PYTHON_INSTALL_DIR, etc.) - // are available without hardcoding them here. - c.arg("-lc").arg(command); + // Login shell (-l) sources .profile/.bashrc so tool env vars + // (VIRTUAL_ENV, etc.) are available. Callers that need a predictable + // environment opt out via OPENSHELL_NO_LOGIN_SHELL → plain -c. + c.arg(login_shell_flag(no_login_shell)).arg(command); c }, ); @@ -1900,6 +1929,38 @@ mod tests { assert_eq!(output.stdout, b"hello"); } + /// Command execution selects a login shell by default and a non-login shell + /// under `--no-login-shell`, so user startup files are sourced only in the + /// default case. + #[cfg(unix)] + #[test] + fn login_shell_flag_controls_profile_sourcing() { + let home = tempfile::tempdir().unwrap(); + std::fs::write(home.path().join(".bash_profile"), "echo LOGIN_MARKER\n").unwrap(); + + let run = |flag: &str| -> String { + let out = Command::new("bash") + .arg(flag) + .arg("true") + .env("HOME", home.path()) + .env_remove("BASH_ENV") // isolate: -c still reads BASH_ENV if set + .output() + .expect("spawn bash"); + String::from_utf8_lossy(&out.stdout).into_owned() + }; + + assert_eq!(login_shell_flag(true), "-c"); + assert_eq!(login_shell_flag(false), "-lc"); + assert!( + run("-lc").contains("LOGIN_MARKER"), + "login shell must source .bash_profile" + ); + assert!( + !run("-c").contains("LOGIN_MARKER"), + "non-login shell must not source it" + ); + } + /// Verify that the stdin writer delivers all buffered data before exiting /// when the sender is dropped. This ensures channel_eof doesn't cause /// data loss — only signals "no more data after this". diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index b3ed9f6eaa..d8fa185a8c 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -221,14 +221,27 @@ openshell sandbox exec -n my-sandbox --tty -- /bin/bash OpenShell allocates a TTY automatically when both stdin and stdout are terminals. Force the behavior with `--tty` or disable it with `--no-tty`. -| Flag | Purpose | -| -------------- | -------------------------------------------------------- | -| `-n`, `--name` | Sandbox to target. | -| `--workdir` | Working directory for the command inside the sandbox. | -| `--timeout` | Command timeout in seconds. `0` disables the timeout. | -| `--tty` | Force TTY allocation. | -| `--no-tty` | Disable TTY allocation even when attached to a terminal. | -| `--env` | Set an environment variable for the command (`KEY=VALUE`, repeatable). | +| Flag | Purpose | +| ----------------- | -------------------------------------------------------- | +| `-n`, `--name` | Sandbox to target. | +| `--workdir` | Working directory for the command inside the sandbox. | +| `--timeout` | Command timeout in seconds. `0` disables the timeout. | +| `--tty` | Force TTY allocation. | +| `--no-tty` | Disable TTY allocation even when attached to a terminal. | +| `--no-login-shell`| Run the command without sourcing shell login startup files. | +| `--env` | Set an environment variable for the command (`KEY=VALUE`, repeatable). | + +### Skip shell startup files + +By default `sandbox exec` runs the command through a login shell (`bash -lc`), so the sandbox user's first available `.bash_profile`, `.bash_login`, or `.profile` is sourced first (and `.bashrc` only if that login file sources it). This makes tool-specific environment configuration available automatically, which suits interactive and tool-discovery use. + +For automation and managed checks that need predictable output and side effects, pass `--no-login-shell` so those startup files are not sourced before the command runs: + +```shell +openshell sandbox exec -n my-sandbox --no-login-shell -- /usr/local/bin/managed-probe +``` + +In this mode a sandbox user's login startup files cannot write to the command's output, create files, or otherwise affect the requested command before it starts. The command still runs under `bash -c`, which reads `BASH_ENV` if it is set in the command's environment. The default (login-shell) behavior is unchanged when the flag is omitted. ## Set Environment Variables diff --git a/proto/openshell.proto b/proto/openshell.proto index 32f39adf67..42e635927f 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -1262,6 +1262,13 @@ message ExecSandboxRequest { // Initial terminal rows (used when tty=true, 0 = use default). uint32 rows = 9; + + // Skip sourcing shell login/profile startup files before running the command. + // When false (the default), the command runs through a login shell + // (`bash -lc`) so user startup files (.bash_profile/.profile, and .bashrc if + // sourced by them) are applied. When true, the command runs without those + // files (`bash -c`), for automation that needs predictable startup behavior. + bool no_login_shell = 10; } // One stdout chunk from a sandbox exec. diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index 58856de454..5653d8dc5e 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -452,6 +452,7 @@ def exec( env: Mapping[str, str] | None = None, stdin: bytes | None = None, timeout_seconds: int | None = None, + no_login_shell: bool = False, ) -> ExecResult: return self._client.exec( self.sandbox.id, @@ -461,6 +462,7 @@ def exec( env=env, stdin=stdin, timeout_seconds=timeout_seconds, + no_login_shell=no_login_shell, ) def exec_python( @@ -915,6 +917,7 @@ def exec_stream( env: Mapping[str, str] | None = None, stdin: bytes | None = None, timeout_seconds: int | None = None, + no_login_shell: bool = False, ) -> Iterator[ExecChunk | ExecResult]: if not command: raise SandboxError("command must not be empty") @@ -926,6 +929,7 @@ def exec_stream( environment=dict(env or {}), timeout_seconds=timeout_seconds or 0, stdin=stdin or b"", + no_login_shell=no_login_shell, ) # Use whichever is larger: the default client timeout or the command # timeout plus headroom for SSH setup / teardown overhead. @@ -970,6 +974,7 @@ def exec( env: Mapping[str, str] | None = None, stdin: bytes | None = None, timeout_seconds: int | None = None, + no_login_shell: bool = False, ) -> ExecResult: result: ExecResult | None = None for item in self.exec_stream( @@ -979,6 +984,7 @@ def exec( env=env, stdin=stdin, timeout_seconds=timeout_seconds, + no_login_shell=no_login_shell, ): if stream_output and isinstance(item, ExecChunk): if item.stream == "stdout": @@ -1302,6 +1308,7 @@ def exec( env: Mapping[str, str] | None = None, stdin: bytes | None = None, timeout_seconds: int | None = None, + no_login_shell: bool = False, ) -> ExecResult: if self._session is None: raise SandboxError("sandbox context has not been entered") @@ -1312,6 +1319,7 @@ def exec( env=env, stdin=stdin, timeout_seconds=timeout_seconds, + no_login_shell=no_login_shell, ) def exec_python( diff --git a/sdk/go/openshell/v1/internal/converter/exec.go b/sdk/go/openshell/v1/internal/converter/exec.go index 0ca7157265..3c73f0c23a 100644 --- a/sdk/go/openshell/v1/internal/converter/exec.go +++ b/sdk/go/openshell/v1/internal/converter/exec.go @@ -45,6 +45,7 @@ func ExecRequestToProto(sandboxID string, command []string, opts *types.ExecOpti if opts != nil { req.Workdir = opts.WorkDir req.Environment = CopyStringMap(opts.Env) + req.NoLoginShell = opts.NoLoginShell } return req } diff --git a/sdk/go/openshell/v1/types/options.go b/sdk/go/openshell/v1/types/options.go index cfd134b05e..b0f6145999 100644 --- a/sdk/go/openshell/v1/types/options.go +++ b/sdk/go/openshell/v1/types/options.go @@ -34,4 +34,8 @@ type WaitOptions struct { type ExecOptions struct { Env map[string]string WorkDir string + // NoLoginShell skips sourcing shell login/profile startup files before the + // command. The zero value (false) preserves login-shell behavior. Set it + // for automation and managed checks that need predictable startup behavior. + NoLoginShell bool } diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index 1b77e0889e..0d2b86fce0 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -3518,7 +3518,13 @@ type ExecSandboxRequest struct { // Initial terminal columns (used when tty=true, 0 = use default). Cols uint32 `protobuf:"varint,8,opt,name=cols,proto3" json:"cols,omitempty"` // Initial terminal rows (used when tty=true, 0 = use default). - Rows uint32 `protobuf:"varint,9,opt,name=rows,proto3" json:"rows,omitempty"` + Rows uint32 `protobuf:"varint,9,opt,name=rows,proto3" json:"rows,omitempty"` + // Skip sourcing shell login/profile startup files before running the command. + // When false (the default), the command runs through a login shell + // (`bash -lc`) so user startup files (.bash_profile/.profile, and .bashrc if + // sourced by them) are applied. When true, the command runs without those + // files (`bash -c`), for automation that needs predictable startup behavior. + NoLoginShell bool `protobuf:"varint,10,opt,name=no_login_shell,json=noLoginShell,proto3" json:"no_login_shell,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -3616,6 +3622,13 @@ func (x *ExecSandboxRequest) GetRows() uint32 { return 0 } +func (x *ExecSandboxRequest) GetNoLoginShell() bool { + if x != nil { + return x.NoLoginShell + } + return false +} + // One stdout chunk from a sandbox exec. type ExecSandboxStdout struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -14285,7 +14298,7 @@ const file_openshell_proto_rawDesc = "" + "\x17RevokeSshSessionRequest\x12\x1a\n" + "\x05token\x18\x01 \x01(\tB\x04\x88\xb5\x18\x01R\x05token\"4\n" + "\x18RevokeSshSessionResponse\x12\x18\n" + - "\arevoked\x18\x01 \x01(\bR\arevoked\"\xf5\x02\n" + + "\arevoked\x18\x01 \x01(\bR\arevoked\"\x9b\x03\n" + "\x12ExecSandboxRequest\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x18\n" + @@ -14296,7 +14309,9 @@ const file_openshell_proto_rawDesc = "" + "\x05stdin\x18\x06 \x01(\fR\x05stdin\x12\x10\n" + "\x03tty\x18\a \x01(\bR\x03tty\x12\x12\n" + "\x04cols\x18\b \x01(\rR\x04cols\x12\x12\n" + - "\x04rows\x18\t \x01(\rR\x04rows\x1a>\n" + + "\x04rows\x18\t \x01(\rR\x04rows\x12$\n" + + "\x0eno_login_shell\x18\n" + + " \x01(\bR\fnoLoginShell\x1a>\n" + "\x10EnvironmentEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"'\n" + diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 3f3247ca2a..d2bf520800 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -125,6 +125,12 @@ export interface ExecOptions { environment?: Record; timeoutSecs?: number; stdin?: Buffer; + /** + * Skip sourcing shell login/profile startup files before the command. + * Defaults to `false`, which preserves login-shell behavior. Set `true` for + * automation and managed checks that need predictable startup behavior. + */ + noLoginShell?: boolean; /** Abort the exec (and the in-flight stream RPC) early. */ signal?: AbortSignal; } @@ -162,6 +168,11 @@ export interface ExecInteractiveOptions { cols?: number; /** Initial terminal rows (0 = server default). */ rows?: number; + /** + * Skip sourcing shell login/profile startup files before the command. + * Defaults to `false`, which preserves login-shell behavior. + */ + noLoginShell?: boolean; /** Abort the interactive exec (and the in-flight stream RPC) early. */ signal?: AbortSignal; } @@ -686,6 +697,7 @@ export class SandboxClient { timeoutSeconds: options?.timeoutSecs ?? 0, stdin: options?.stdin ? new Uint8Array(options.stdin) : new Uint8Array(), tty: false, + noLoginShell: options?.noLoginShell ?? false, }, { signal: options?.signal }, ); @@ -768,6 +780,7 @@ export class SandboxClient { tty: options?.tty ?? true, cols: options?.cols ?? 0, rows: options?.rows ?? 0, + noLoginShell: options?.noLoginShell ?? false, }, }, }); From c27a3a3ce707b88d9016c9828d38c2c72c904378 Mon Sep 17 00:00:00 2001 From: Ian Miller <75687988+r3v5@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:21:06 +0000 Subject: [PATCH 052/505] fix(compute): recover Error-phase sandboxes on gateway startup (#2269) * fix(compute): recover Error-phase sandboxes on gateway startup Sandboxes whose container exited on its own (e.g. SIGTERM from a Podman or Docker machine restart) were left stuck in the Error phase even though the container could be restarted. The startup sweep skipped every Error-phase sandbox unconditionally. Include Error-phase sandboxes in the startup sweep when their Ready condition reason marks a container exit or stop. If the driver restarts the container, move the sandbox back to Provisioning with a Resumed condition; if the container is gone or the start fails, leave the Error state untouched. Genuine (non-container-exit) errors are still skipped. Container-exit restart is handled by the drivers' existing idempotent start_sandbox path, which already restarts stopped/exited containers. The ContainerExited/ContainerStopped condition reasons are promoted to shared constants in openshell-core so the gateway and drivers agree on the recovery signal. Fixes #2179 Signed-off-by: Ian Miller * fix(compute): keep ordinary container exits terminal during startup recovery Startup recovery treated every non-OOM container exit as recoverable, lumping application crashes and non-zero exits together with machine/daemon-restart signal kills under the shared `ContainerExited` Ready-condition reason. On the next gateway startup it restarted those crashed workloads and replaced the terminal error with `Resumed`, erasing the failure signal and repeatedly reviving crash-prone sandboxes. Introduce a distinct `ContainerRuntimeRestart` reason for containers terminated by an external signal (exit 137/143 = SIGKILL/SIGTERM), which is the signature of a machine/daemon restart. The Podman inspect-based classification emits it; the startup recovery gate now recovers only `ContainerRuntimeRestart` and `ContainerStopped`. Ordinary `ContainerExited` records stay terminal, so genuine failures keep their error signal. Because only the new reason is recoverable and older gateways never persisted it, exits recorded before this change also stay terminal after an upgrade. Add regression coverage: a generic `ContainerExited` error is not relaunched, a signal-kill classifies as `ContainerRuntimeRestart`, and the startup sweep recovers only the runtime-restart record. Signed-off-by: Ian Miller * fix(docker): reclassify signal-killed containers as runtime restart Mirror the Podman classification in the Docker driver so that externally signal-killed containers (exit 137/143, non-OOM) are persisted with the ContainerRuntimeRestart ready reason and become eligible for startup recovery, while ordinary exits and OOM kills stay terminal. current_snapshots now inspects EXITED containers to obtain the exit code and OOM flag, then applies the shared exit classification. Without the inspect step the list-summary path lacks an exit code, so the recovery gate could never fire for Docker. Adds unit tests covering signal-kill reclassification (137/143), ordinary exit staying terminal (exit 1), and OOM staying terminal despite exit 137. Signed-off-by: Ian Miller --------- Signed-off-by: Ian Miller --- crates/openshell-core/src/driver_utils.rs | 24 ++ crates/openshell-driver-docker/src/lib.rs | 75 ++++- crates/openshell-driver-docker/src/tests.rs | 85 +++++ crates/openshell-driver-podman/src/watcher.rs | 61 +++- crates/openshell-server/src/compute/mod.rs | 316 ++++++++++++++++-- 5 files changed, 521 insertions(+), 40 deletions(-) diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index b37b5b6821..f3279d8105 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -40,6 +40,30 @@ pub fn openshell_sandbox_label_selector() -> String { format!("{LABEL_MANAGED_BY}={LABEL_MANAGED_BY_VALUE},{LABEL_SANDBOX_ID}") } +// --------------------------------------------------------------------------- +// Sandbox condition reason strings set by compute drivers. +// --------------------------------------------------------------------------- + +/// Ready-condition reason when a container exits on its own. +/// +/// Covers an ordinary application exit or crash (exit 0, a non-zero error code, +/// or an uncaught fault). This is a terminal reason: gateway startup does NOT +/// auto-restart it, so a genuine failure keeps its error signal instead of +/// being relaunched. +pub const CONDITION_EXITED: &str = "ContainerExited"; + +/// Ready-condition reason when a container was terminated by an external signal. +/// +/// SIGKILL/SIGTERM (exit 137/143) is what a Podman/Docker machine or daemon +/// restart does to running containers. Distinct from `CONDITION_EXITED` so +/// gateway startup can recover machine-restart victims while leaving ordinary +/// application exits terminal. +pub const CONDITION_RUNTIME_RESTART: &str = "ContainerRuntimeRestart"; + +/// Ready-condition reason when a container is explicitly stopped via the +/// runtime API (e.g. `podman stop`, gateway-initiated shutdown). +pub const CONDITION_STOPPED: &str = "ContainerStopped"; + // --------------------------------------------------------------------------- /// Path to the sandbox supervisor binary inside the container image. diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index b1859f54cc..77e962f6ea 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -26,10 +26,10 @@ use openshell_core::config::{ }; use openshell_core::driver_mounts; use openshell_core::driver_utils::{ - LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, - LABEL_SANDBOX_NAMESPACE, LABEL_SANDBOX_WORKSPACE, SUPERVISOR_IMAGE_BINARY_PATH, - extract_first_tar_entry, supervisor_image_should_refresh, temp_extract_container_name, - validate_linux_elf_binary, write_cache_binary_atomic, + CONDITION_EXITED, CONDITION_RUNTIME_RESTART, LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, + LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, LABEL_SANDBOX_NAMESPACE, LABEL_SANDBOX_WORKSPACE, + SUPERVISOR_IMAGE_BINARY_PATH, extract_first_tar_entry, supervisor_image_should_refresh, + temp_extract_container_name, validate_linux_elf_binary, write_cache_binary_atomic, }; use openshell_core::gpu::{ CdiGpuDefaultSelector, CdiGpuInventory, CdiGpuSelectionError, driver_gpu_requirements, @@ -829,10 +829,37 @@ impl DockerComputeDriver { async fn current_snapshots(&self) -> Result, Status> { let containers = self.list_managed_container_summaries().await?; - let container_sandboxes = containers - .iter() - .filter_map(sandbox_from_container_summary) - .collect::>(); + let mut container_sandboxes = Vec::with_capacity(containers.len()); + for summary in &containers { + let Some(mut sandbox) = sandbox_from_container_summary(summary) else { + continue; + }; + // Docker's list summary carries no exit code, so an exited + // container is reported as the generic terminal `ContainerExited`. + // Inspect it to tell a machine/daemon-restart signal kill apart + // from an ordinary application exit, mirroring the Podman driver, + // so startup recovery can revive restart victims while leaving + // crashes terminal. + if summary.state == Some(ContainerSummaryStateEnum::EXITED) + && let Some(container_id) = summary.id.as_deref() + { + match self.docker.inspect_container(container_id, None).await { + Ok(inspected) => { + if let Some(state) = inspected.state.as_ref() { + apply_docker_exit_classification(&mut sandbox, state); + } + } + Err(err) => { + debug!( + container_id, + error = %err, + "Could not inspect exited Docker container to classify its exit" + ); + } + } + } + container_sandboxes.push(sandbox); + } let mut by_id = self.pending_snapshot_map().await; for sandbox in container_sandboxes { by_id.insert(sandbox.id.clone(), sandbox); @@ -3491,6 +3518,34 @@ fn driver_status_from_summary( } } +/// Refine an exited Docker sandbox's `Ready` condition from inspected state. +/// +/// A signal kill (exit 137/143 = SIGKILL/SIGTERM, not OOM) is the signature of +/// a machine/daemon restart terminating a running container. Reclassify it from +/// the generic terminal `ContainerExited` to the recoverable +/// `ContainerRuntimeRestart` so gateway startup can revive it. OOM kills and +/// ordinary application exits stay `ContainerExited` and terminal. +fn apply_docker_exit_classification(sandbox: &mut DriverSandbox, state: &ContainerState) { + if state.oom_killed == Some(true) { + return; + } + let Some(code) = state.exit_code.filter(|&code| matches!(code, 137 | 143)) else { + return; + }; + let Some(condition) = sandbox + .status + .as_mut() + .and_then(|status| status.conditions.iter_mut().find(|c| c.r#type == "Ready")) + else { + return; + }; + if condition.reason != CONDITION_EXITED { + return; + } + condition.reason = CONDITION_RUNTIME_RESTART.to_string(); + condition.message = format!("Container terminated by signal (exit code {code})"); +} + fn container_ready_condition( state: ContainerSummaryStateEnum, ) -> (&'static str, &'static str, &'static str, bool) { @@ -3514,9 +3569,7 @@ fn container_ready_condition( ContainerSummaryStateEnum::PAUSED => { ("False", "ContainerPaused", "Container is paused", false) } - ContainerSummaryStateEnum::EXITED => { - ("False", "ContainerExited", "Container exited", false) - } + ContainerSummaryStateEnum::EXITED => ("False", CONDITION_EXITED, "Container exited", false), ContainerSummaryStateEnum::DEAD => ("False", "ContainerDead", "Container is dead", false), } } diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index fb5bc61e5c..fd81c9b638 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -3104,3 +3104,88 @@ fn lifecycle_fence_rejects_polled_exit_from_before_restart() { fences.remove("sandbox-1"); assert!(fences.previous_exit("sandbox-1").is_none()); } + +fn exited_sandbox_with_ready_reason(reason: &str) -> DriverSandbox { + DriverSandbox { + id: "sbx-exit".to_string(), + name: "demo".to_string(), + namespace: String::new(), + spec: None, + status: Some(DriverSandboxStatus { + sandbox_name: "demo".to_string(), + instance_id: "container-1".to_string(), + agent_fd: String::new(), + sandbox_fd: String::new(), + conditions: vec![DriverCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: reason.to_string(), + message: "Container exited".to_string(), + last_transition_time: String::new(), + }], + deleting: false, + }), + workspace: String::new(), + } +} + +fn ready_reason(sandbox: &DriverSandbox) -> &str { + sandbox + .status + .as_ref() + .and_then(|status| status.conditions.iter().find(|c| c.r#type == "Ready")) + .map(|c| c.reason.as_str()) + .expect("Ready condition present") +} + +#[test] +fn docker_signal_kill_reclassified_as_runtime_restart() { + // 137 (128+SIGKILL) and 143 (128+SIGTERM) mark an external termination — + // the signature of a machine/daemon restart — and become recoverable + // `ContainerRuntimeRestart`. + for exit_code in [137, 143] { + let mut sandbox = exited_sandbox_with_ready_reason(CONDITION_EXITED); + let state = ContainerState { + status: Some(ContainerStateStatusEnum::EXITED), + oom_killed: Some(false), + exit_code: Some(exit_code), + ..Default::default() + }; + apply_docker_exit_classification(&mut sandbox, &state); + assert_eq!( + ready_reason(&sandbox), + CONDITION_RUNTIME_RESTART, + "exit code {exit_code} should reclassify as runtime restart" + ); + } +} + +#[test] +fn docker_ordinary_exit_stays_terminal() { + // An application exit (non-zero error code) stays `ContainerExited` so its + // failure signal survives instead of being relaunched on startup. + let mut sandbox = exited_sandbox_with_ready_reason(CONDITION_EXITED); + let state = ContainerState { + status: Some(ContainerStateStatusEnum::EXITED), + oom_killed: Some(false), + exit_code: Some(1), + ..Default::default() + }; + apply_docker_exit_classification(&mut sandbox, &state); + assert_eq!(ready_reason(&sandbox), CONDITION_EXITED); +} + +#[test] +fn docker_oom_kill_stays_terminal_despite_137() { + // An OOM kill reports exit 137 but must NOT be treated as a recoverable + // restart — it is a genuine failure and stays terminal. + let mut sandbox = exited_sandbox_with_ready_reason(CONDITION_EXITED); + let state = ContainerState { + status: Some(ContainerStateStatusEnum::EXITED), + oom_killed: Some(true), + exit_code: Some(137), + ..Default::default() + }; + apply_docker_exit_classification(&mut sandbox, &state); + assert_eq!(ready_reason(&sandbox), CONDITION_EXITED); +} diff --git a/crates/openshell-driver-podman/src/watcher.rs b/crates/openshell-driver-podman/src/watcher.rs index 3e98d16271..0c94bf72b2 100644 --- a/crates/openshell-driver-podman/src/watcher.rs +++ b/crates/openshell-driver-podman/src/watcher.rs @@ -26,8 +26,9 @@ use tracing::{debug, info, warn}; // Condition reason constants shared across event-building paths. const CONDITION_RUNNING: &str = "ContainerRunning"; const CONDITION_STARTING: &str = "ContainerStarting"; -const CONDITION_EXITED: &str = "ContainerExited"; -const CONDITION_STOPPED: &str = "ContainerStopped"; +use openshell_core::driver_utils::{ + CONDITION_EXITED, CONDITION_RUNTIME_RESTART, CONDITION_STOPPED, +}; pub type WatchStream = Pin> + Send>>; @@ -445,15 +446,29 @@ fn condition_from_state(state: &ContainerState) -> DriverCondition { }, "created" => ("False", "ContainerCreated", String::new()), "exited" | "stopped" => { - let msg = if state.oom_killed { - "Container was killed by the OOM killer".to_string() - } else { - format!("Container exited with code {}", state.exit_code) - }; - let reason = if state.oom_killed { - "OOMKilled" + // Exit codes 137 (128+SIGKILL) and 143 (128+SIGTERM) mean the + // container was terminated by an external signal rather than + // exiting on its own — the signature of a machine/daemon restart + // killing running containers. Those are recoverable at gateway + // startup; ordinary application exits (0, non-zero, faults) are not. + let (reason, msg) = if state.oom_killed { + ( + "OOMKilled", + "Container was killed by the OOM killer".to_string(), + ) + } else if matches!(state.exit_code, 137 | 143) { + ( + CONDITION_RUNTIME_RESTART, + format!( + "Container terminated by signal (exit code {})", + state.exit_code + ), + ) } else { - CONDITION_EXITED + ( + CONDITION_EXITED, + format!("Container exited with code {}", state.exit_code), + ) }; ("False", reason, msg) } @@ -600,6 +615,32 @@ mod tests { assert!(cond.message.contains("code 1")); } + #[test] + fn condition_signal_kill_is_runtime_restart() { + // 137 (128+SIGKILL) and 143 (128+SIGTERM) are external terminations — + // the signature of a machine/daemon restart. They classify as + // recoverable `ContainerRuntimeRestart`, distinct from an ordinary + // application exit. + for exit_code in [137, 143] { + let state = ContainerState { + status: "exited".to_string(), + running: false, + exit_code, + oom_killed: false, + health: None, + started_at: None, + finished_at: Some("2026-04-14T12:30:00Z".to_string()), + }; + let cond = condition_from_state(&state); + assert_eq!(cond.status, "False"); + assert_eq!( + cond.reason, "ContainerRuntimeRestart", + "exit code {exit_code} should classify as runtime restart" + ); + assert!(cond.message.contains(&format!("code {exit_code}"))); + } + } + #[test] fn short_id_truncates() { assert_eq!(short_id("abc123def456789"), "abc123def456"); diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index ef311523a9..309b7ec768 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -2216,8 +2216,13 @@ impl ComputeRuntime { /// /// `StartSandbox` is idempotent, so call it for every persisted phase that /// requires running compute for drivers that request gateway-managed - /// lifecycle. Stable stopped, deleting, and error states are deliberately - /// left alone. + /// lifecycle. Stable stopped and deleting states are deliberately left + /// alone. Error-phase sandboxes are included only when their Ready + /// condition indicates the runtime went away underneath a running container + /// — a signal-kill from a machine/daemon restart or an explicit runtime + /// stop. If the container still exists it is restarted and the sandbox is + /// moved back to `Provisioning`; otherwise it stays in `Error`. Ordinary + /// application exits and crashes stay terminal and are not relaunched. /// /// Should be called once at gateway startup, before watchers spawn, /// so the watch loop sees the post-start state on its first poll. @@ -2230,6 +2235,7 @@ impl ComputeRuntime { let sandbox_ids = self.list_persisted_sandbox_ids("gateway startup").await?; let mut started = 0usize; + let mut recovered = 0usize; let mut missing = 0usize; let mut failed = 0usize; @@ -2250,7 +2256,9 @@ impl ComputeRuntime { }; let phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown); - if !sandbox_phase_should_be_running(phase) { + let recoverable_error = + phase == SandboxPhase::Error && is_recoverable_error_reason(&sandbox); + if !sandbox_phase_should_be_running(phase) && !recoverable_error { continue; } @@ -2272,13 +2280,22 @@ impl ComputeRuntime { .await { Ok(_) => { + let did_recover = if recoverable_error { + self.clear_recoverable_error(&sandbox).await + } else { + false + }; info!( sandbox_id = %sandbox.object_id(), sandbox_name = %sandbox.object_name(), ?phase, + recovered = did_recover, "Started sandbox during gateway startup" ); started += 1; + if did_recover { + recovered += 1; + } } Err(err) if err.code() == Code::NotFound => { // Backend resource is gone but the store still @@ -2291,12 +2308,14 @@ impl ComputeRuntime { sandbox_name = %sandbox.object_name(), "Cannot start sandbox: backend resource is missing" ); - self.mark_sandbox_error( - &sandbox, - "BackendResourceMissing", - "Sandbox compute resource disappeared while the gateway was offline", - ) - .await; + if !recoverable_error { + self.mark_sandbox_error( + &sandbox, + "BackendResourceMissing", + "Sandbox compute resource disappeared while the gateway was offline", + ) + .await; + } missing += 1; } Err(err) => { @@ -2306,15 +2325,17 @@ impl ComputeRuntime { error = %err, "Failed to start sandbox during gateway startup" ); - self.mark_sandbox_error( - &sandbox, - "StartFailed", - &format!( - "Failed to start sandbox during gateway startup: {}", - err.message() - ), - ) - .await; + if !recoverable_error { + self.mark_sandbox_error( + &sandbox, + "StartFailed", + &format!( + "Failed to start sandbox during gateway startup: {}", + err.message() + ), + ) + .await; + } failed += 1; } } @@ -2323,6 +2344,7 @@ impl ComputeRuntime { if started > 0 || missing > 0 || failed > 0 { info!( started, + recovered, missing_backend = missing, failed, "Sandbox start sweep complete" @@ -2497,6 +2519,48 @@ impl ComputeRuntime { } } + /// Clear a recoverable `Error` state after the underlying container has + /// been restarted during the startup sweep. Moves the sandbox back to + /// `Provisioning` with a `Resumed` Ready condition. Returns `true` if the + /// store update succeeded. + async fn clear_recoverable_error(&self, sandbox: &Sandbox) -> bool { + let _guard = self.sync_lock.lock().await; + let sandbox_id = sandbox.object_id().to_string(); + match self + .store + .update_message_cas::(&sandbox_id, 0, |s| { + s.set_phase(SandboxPhase::Provisioning as i32); + let name = s.object_name().to_string(); + upsert_ready_condition( + &mut s.status, + &name, + SandboxCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: "Resumed".to_string(), + message: "Sandbox recovered during gateway startup".to_string(), + last_transition_time: String::new(), + }, + ); + }) + .await + { + Ok(updated) => { + self.sandbox_index.update_from_sandbox(&updated); + self.sandbox_watch_bus.notify(&sandbox_id); + true + } + Err(err) => { + warn!( + sandbox_id = %sandbox_id, + error = %err, + "Failed to clear sandbox error state during startup resume" + ); + false + } + } + } + async fn lease_coordinator(self: Arc, mut shutdown_rx: watch::Receiver) { use lease::{LEASE_ACQUIRE_INTERVAL, LEASE_TTL, ReconcilerLease}; @@ -4251,6 +4315,22 @@ fn sandbox_phase_should_be_running(phase: SandboxPhase) -> bool { ) } +/// Error-phase sandboxes are only eligible for startup recovery when their +/// Ready condition reason indicates the runtime went away underneath a running +/// container — a machine/daemon restart that terminated it by signal +/// (`CONDITION_RUNTIME_RESTART`) or an explicit runtime stop +/// (`CONDITION_STOPPED`). Ordinary application exits (`CONDITION_EXITED`, which +/// covers crashes and non-zero exits) stay terminal so a genuine failure keeps +/// its error signal instead of being relaunched on every gateway startup. +fn is_recoverable_error_reason(sandbox: &Sandbox) -> bool { + use openshell_core::driver_utils::{CONDITION_RUNTIME_RESTART, CONDITION_STOPPED}; + sandbox + .status + .as_ref() + .and_then(|s| s.conditions.iter().find(|c| c.r#type == "Ready")) + .is_some_and(|c| c.reason == CONDITION_RUNTIME_RESTART || c.reason == CONDITION_STOPPED) +} + fn is_terminal_failure_reason(reason: &str) -> bool { let reason = reason.to_ascii_lowercase(); let transient_reasons = [ @@ -5476,6 +5556,19 @@ mod tests { assert_eq!(status.exit_code, Some(1)); } + fn error_sandbox_record(id: &str, name: &str, reason: &str) -> Sandbox { + let mut sandbox = sandbox_record(id, name, SandboxPhase::Error); + let status = sandbox.status.get_or_insert_with(Default::default); + status.conditions.push(SandboxCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: reason.to_string(), + message: String::new(), + last_transition_time: String::new(), + }); + sandbox + } + fn ssh_session_record(id: &str, sandbox_id: &str) -> SshSession { SshSession { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { @@ -9236,11 +9329,40 @@ mod tests { ("sb-stopping", "stopping", SandboxPhase::Stopping), ("sb-stopped", "stopped", SandboxPhase::Stopped), ("sb-deleting", "deleting", SandboxPhase::Deleting), - ("sb-error", "error", SandboxPhase::Error), ] { let sandbox = sandbox_record(id, name, phase); runtime.store.put_message(&sandbox).await.unwrap(); } + // Terminal errors are skipped: a backend-missing error and an ordinary + // container exit (crash) both stay in Error. Only a signal-kill from a + // machine/daemon restart is retried. + runtime + .store + .put_message(&error_sandbox_record( + "sb-error-perm", + "error-perm", + "BackendResourceMissing", + )) + .await + .unwrap(); + runtime + .store + .put_message(&error_sandbox_record( + "sb-error-exit", + "error-exit", + "ContainerExited", + )) + .await + .unwrap(); + runtime + .store + .put_message(&error_sandbox_record( + "sb-error-restart", + "error-restart", + "ContainerRuntimeRestart", + )) + .await + .unwrap(); runtime.start_persisted_sandboxes().await.unwrap(); @@ -9253,6 +9375,7 @@ mod tests { assert_eq!( called_ids, vec![ + "sb-error-restart".to_string(), "sb-prov".to_string(), "sb-ready".to_string(), "sb-unknown".to_string(), @@ -9422,6 +9545,161 @@ mod tests { } } + #[tokio::test] + async fn start_persisted_sandboxes_recovers_error_phase_when_container_exists() { + let driver = ControlledDriver::new(); + // Default start outcome is Ok: the container is restarted successfully. + let runtime = test_runtime_with_gateway_managed_lifecycle(driver.clone(), "podman").await; + + let sandbox = error_sandbox_record("sb-err-recover", "recover", "ContainerRuntimeRestart"); + runtime.store.put_message(&sandbox).await.unwrap(); + + runtime.start_persisted_sandboxes().await.unwrap(); + + assert_eq!(driver.start_calls(), 1); + + let stored = runtime + .store + .get_message::("sb-err-recover") + .await + .unwrap() + .unwrap(); + assert_eq!( + SandboxPhase::try_from(stored.phase()).unwrap(), + SandboxPhase::Provisioning + ); + let ready = stored + .status + .as_ref() + .and_then(|s| s.conditions.iter().find(|c| c.r#type == "Ready")) + .expect("Ready condition present"); + assert_eq!(ready.reason, "Resumed"); + } + + #[tokio::test] + async fn start_persisted_sandboxes_leaves_error_when_container_missing() { + let driver = ControlledDriver::new(); + driver.set_start_outcome(ControlledLifecycleOutcome::NotFound); + let runtime = test_runtime_with_gateway_managed_lifecycle(driver.clone(), "podman").await; + + let sandbox = error_sandbox_record("sb-err-gone", "gone", "ContainerRuntimeRestart"); + runtime.store.put_message(&sandbox).await.unwrap(); + + runtime.start_persisted_sandboxes().await.unwrap(); + + // Recovery was attempted, but the error state is preserved untouched. + assert_eq!(driver.start_calls(), 1); + + let stored = runtime + .store + .get_message::("sb-err-gone") + .await + .unwrap() + .unwrap(); + assert_eq!( + SandboxPhase::try_from(stored.phase()).unwrap(), + SandboxPhase::Error + ); + let ready = stored + .status + .as_ref() + .and_then(|s| s.conditions.iter().find(|c| c.r#type == "Ready")) + .expect("Ready condition present"); + assert_eq!(ready.reason, "ContainerRuntimeRestart"); + } + + #[tokio::test] + async fn start_persisted_sandboxes_leaves_error_when_start_fails() { + let driver = ControlledDriver::new(); + driver.set_start_outcome(ControlledLifecycleOutcome::Error("runtime angry")); + let runtime = test_runtime_with_gateway_managed_lifecycle(driver.clone(), "podman").await; + + let sandbox = error_sandbox_record("sb-err-fail", "fail", "ContainerStopped"); + runtime.store.put_message(&sandbox).await.unwrap(); + + runtime.start_persisted_sandboxes().await.unwrap(); + + assert_eq!(driver.start_calls(), 1); + + let stored = runtime + .store + .get_message::("sb-err-fail") + .await + .unwrap() + .unwrap(); + assert_eq!( + SandboxPhase::try_from(stored.phase()).unwrap(), + SandboxPhase::Error + ); + let ready = stored + .status + .as_ref() + .and_then(|s| s.conditions.iter().find(|c| c.r#type == "Ready")) + .expect("Ready condition present"); + assert_eq!(ready.reason, "ContainerStopped"); + } + + #[tokio::test] + async fn start_persisted_sandboxes_skips_non_recoverable_error() { + let driver = ControlledDriver::new(); + let runtime = test_runtime_with_gateway_managed_lifecycle(driver.clone(), "podman").await; + + let sandbox = error_sandbox_record("sb-err-perm", "perm", "BackendResourceMissing"); + runtime.store.put_message(&sandbox).await.unwrap(); + + runtime.start_persisted_sandboxes().await.unwrap(); + + // A non-container-exit error is never retried. + assert_eq!(driver.start_calls(), 0); + + let stored = runtime + .store + .get_message::("sb-err-perm") + .await + .unwrap() + .unwrap(); + assert_eq!( + SandboxPhase::try_from(stored.phase()).unwrap(), + SandboxPhase::Error + ); + } + + #[tokio::test] + async fn start_persisted_sandboxes_leaves_generic_container_exit_terminal() { + // A container that exited on its own — an ordinary application crash or + // non-zero exit — is stored as Error with `ContainerExited`. Startup + // recovery must NOT relaunch it: doing so would erase the failure + // signal and repeatedly revive a crash-prone workload. Only a + // signal-kill (`ContainerRuntimeRestart`) from a machine/daemon restart + // is recoverable. + let driver = ControlledDriver::new(); + let runtime = test_runtime_with_gateway_managed_lifecycle(driver.clone(), "podman").await; + + let sandbox = error_sandbox_record("sb-err-crash", "crash", "ContainerExited"); + runtime.store.put_message(&sandbox).await.unwrap(); + + runtime.start_persisted_sandboxes().await.unwrap(); + + assert_eq!(driver.start_calls(), 0); + + let stored = runtime + .store + .get_message::("sb-err-crash") + .await + .unwrap() + .unwrap(); + assert_eq!( + SandboxPhase::try_from(stored.phase()).unwrap(), + SandboxPhase::Error + ); + let ready = stored + .status + .as_ref() + .and_then(|s| s.conditions.iter().find(|c| c.r#type == "Ready")) + .expect("Ready condition present"); + assert_eq!(ready.reason, "ContainerExited"); + } + #[test] fn build_platform_config_inverts_user_namespaces_to_host_users() { use prost_types::value::Kind; From 9b6d904e880a20178bb54cb5ede1a60689be71a1 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 31 Aug 2026 17:41:56 +0000 Subject: [PATCH 053/505] feat(compute): delegate sandbox authentication to drivers (#2968) * feat(compute): delegate sandbox authentication to drivers Signed-off-by: Drew Newberry <385+drew@users.noreply.github.com> * fix(kubernetes): align sandbox identity annotation Signed-off-by: Drew Newberry <385+drew@users.noreply.github.com> * test(auth): restore sandbox bootstrap coverage Signed-off-by: Drew Newberry <385+drew@users.noreply.github.com> * fix(kubernetes): satisfy ownership test lint Signed-off-by: Drew Newberry <385+drew@users.noreply.github.com> --------- Signed-off-by: Drew Newberry <385+drew@users.noreply.github.com> --- .../skills/debug-openshell-cluster/SKILL.md | 7 +- .agents/skills/helm-dev-environment/SKILL.md | 4 +- architecture/compute-runtimes.md | 26 +- architecture/gateway.md | 11 +- crates/openshell-driver-docker/src/lib.rs | 24 + crates/openshell-driver-kubernetes/README.md | 6 +- .../openshell-driver-kubernetes/src/config.rs | 2 +- .../openshell-driver-kubernetes/src/driver.rs | 422 ++++++- .../openshell-driver-kubernetes/src/grpc.rs | 29 +- crates/openshell-driver-podman/src/driver.rs | 1 + crates/openshell-driver-podman/src/grpc.rs | 10 + crates/openshell-driver-vm/src/driver.rs | 11 + .../src/auth/authenticator.rs | 4 +- .../src/auth/compute_driver.rs | 176 +++ crates/openshell-server/src/auth/k8s_sa.rs | 1098 ----------------- crates/openshell-server/src/auth/mod.rs | 2 +- crates/openshell-server/src/auth/principal.rs | 7 +- .../src/compute/driver_config.rs | 68 - crates/openshell-server/src/compute/mod.rs | 154 ++- crates/openshell-server/src/grpc/auth_rpc.rs | 31 +- crates/openshell-server/src/lib.rs | 125 +- crates/openshell-server/src/multiplex.rs | 13 +- crates/openshell-server/src/test_support.rs | 11 + .../helm/openshell/ci/values-spire-stack.yaml | 2 +- deploy/helm/openshell/templates/role.yaml | 2 +- docs/kubernetes/access-control.mdx | 4 +- docs/reference/gateway-auth.mdx | 2 +- docs/reference/gateway-config.mdx | 2 + docs/reference/sandbox-compute-drivers.mdx | 2 +- proto/compute_driver.proto | 18 + 30 files changed, 917 insertions(+), 1357 deletions(-) create mode 100644 crates/openshell-server/src/auth/compute_driver.rs delete mode 100644 crates/openshell-server/src/auth/k8s_sa.rs diff --git a/.agents/skills/debug-openshell-cluster/SKILL.md b/.agents/skills/debug-openshell-cluster/SKILL.md index 90b9a38409..d5c996aecf 100644 --- a/.agents/skills/debug-openshell-cluster/SKILL.md +++ b/.agents/skills/debug-openshell-cluster/SKILL.md @@ -392,7 +392,7 @@ kubectl -n openshell get pod -l app.kubernetes.io/name=helm-chart -o jsonpath="{ ``` Sandbox pods using provider token grants should have an -`openshell.io/sandbox-id` annotation, an `openshell.ai/managed-by=openshell` +`openshell.ai/sandbox-id` annotation, an `openshell.ai/managed-by=openshell` label, supervisor env vars `OPENSHELL_K8S_SA_TOKEN_FILE` and `OPENSHELL_PROVIDER_SPIFFE_WORKLOAD_API_SOCKET`, plus both the projected `openshell-sa-token` volume and the `spiffe-workload-api` CSI volume. @@ -467,7 +467,10 @@ Then inspect sandbox resources in that namespace. Check the configured sandbox service account when TokenReview bootstrap or sandbox registration fails. Helm creates a dedicated sandbox service account by default and writes it to `[openshell.drivers.kubernetes].service_account_name`; -the gateway rejects projected tokens from other service accounts. +the selected Kubernetes compute driver rejects projected tokens from other +service accounts. For an external driver, inspect its logs and confirm it +advertises `supports_sandbox_authentication`; the gateway delegates the opaque +credential over the driver socket and never interprets Kubernetes settings. ```bash helm -n openshell get values openshell | grep -A3 sandboxServiceAccount diff --git a/.agents/skills/helm-dev-environment/SKILL.md b/.agents/skills/helm-dev-environment/SKILL.md index c60733e954..c577cd577e 100644 --- a/.agents/skills/helm-dev-environment/SKILL.md +++ b/.agents/skills/helm-dev-environment/SKILL.md @@ -259,7 +259,9 @@ annotations to `spiffe://openshell.local/openshell/sandbox/`. OpenShell mounts the SPIFFE CSI Workload API socket at `/spiffe-workload-api/spire-agent.sock` into sandbox pods for provider token grants. Supervisor-to-gateway authentication remains on the Kubernetes -ServiceAccount bootstrap and gateway-minted sandbox JWT path. +ServiceAccount bootstrap and gateway-minted sandbox JWT path; the selected +Kubernetes compute driver validates the projected token before the gateway +mints its JWT. --- diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index 3821d48afe..b21dd0dc80 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -177,6 +177,15 @@ The driver reports this behavior through in-process and external drivers. Older drivers omit the field and retain the conservative operator-managed behavior. +Drivers that can verify a platform-native sandbox credential advertise +`GetCapabilities.supports_sandbox_authentication`. On the path-scoped +`IssueSandboxToken` exchange, the gateway forwards the opaque bearer credential +to that selected driver through `AuthenticateSandbox`. The driver returns only +the authenticated sandbox ID. The gateway then verifies that its durable +sandbox record exists and mints the gateway JWT. The driver socket is therefore +a sandbox-identity trust boundary, but it does not grant user or administrator +authority. + ## Deletion Lifecycle Lifecycle requests use per-sandbox gates to serialize stop, start, and @@ -465,8 +474,8 @@ watcher emits only sandbox CR changes, not platform events. ### SA Token Authentication -The gateway's `K8sServiceAccountAuthenticator` adapts its `NamespaceValidator` -per mode (`crates/openshell-server/src/auth/k8s_sa.rs`): +The Kubernetes driver's `AuthenticateSandbox` implementation applies its named +`[openshell.drivers.kubernetes]` configuration per mode: - **Shared:** `Exact` — accepts only the single configured namespace. - **Managed:** `Prefix` — accepts any namespace starting with `openshell-{gateway_id}-`. @@ -474,11 +483,14 @@ per mode (`crates/openshell-server/src/auth/k8s_sa.rs`): `BTreeSet` populated by the label/file watchers. Starts empty (fail-closed) until the first watcher update. -These checks rely on an ownership invariant. In shared and managed modes, the -gateway and its trusted Agent Sandbox controller exclusively administer the -sandbox namespace, Sandbox CRs, sandbox pods, and configured sandbox -ServiceAccount. Other principals must not create or mutate those resources or -use that ServiceAccount. In operator mode, the platform operator retains +It validates the projected token with Kubernetes `TokenReview`, checks the live +pod UID, and verifies the pod's controlling Sandbox CR UID and sandbox ID before +returning the identity to the gateway. These checks rely on an ownership +invariant. In shared and managed modes, the Kubernetes driver and its trusted +Agent Sandbox controller exclusively administer the sandbox namespace, Sandbox +CRs, sandbox pods, and configured sandbox ServiceAccount. Other principals must +not create or mutate those resources or use that ServiceAccount. In operator +mode, the platform operator retains namespace lifecycle ownership, but must preserve the same exclusive control of Sandbox CRs and the pods and ServiceAccount used for sandbox token bootstrap. An allowlisted namespace is therefore a trust grant, not a tenant isolation diff --git a/architecture/gateway.md b/architecture/gateway.md index 594a940d7d..0430d95159 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -235,11 +235,12 @@ identity inspection without client-side token decoding. Sandbox secrets are gateway-signed JWTs bound to a single sandbox ID. Docker, Podman, and VM drivers deliver the initial token through supervisor-only runtime material; Kubernetes supervisors exchange a projected ServiceAccount -token through `IssueSandboxToken`. The gateway validates that projected token -with Kubernetes `TokenReview`, requires the configured sandbox service account, -checks the returned pod binding against the live pod UID, and verifies the pod's -controlling `Sandbox` ownerReference against the live Sandbox CR UID and -sandbox-id label before minting the gateway JWT. The bootstrap path accepts +token through `IssueSandboxToken`. The gateway delegates that opaque credential +to the selected compute driver's `AuthenticateSandbox` RPC. A capable driver is +trusted to return the authenticated sandbox ID, while the gateway still requires +a matching durable sandbox record before minting a JWT. The Kubernetes driver +uses its own named configuration to run TokenReview and verify the live pod and +controlling Sandbox CR. The bootstrap path accepts both `agents.x-k8s.io/v1beta1` ownerReferences from newer Agent Sandbox controllers and `agents.x-k8s.io/v1alpha1` ownerReferences from existing deployments. Supervisors renew gateway JWTs in memory before expiry only while diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 77e962f6ea..3941819c61 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -635,6 +635,7 @@ impl DockerComputeDriver { driver_version: self.config.daemon_version.clone(), default_image: self.config.default_image.clone(), gateway_manages_lifecycle: true, + supports_sandbox_authentication: false, } } @@ -1740,6 +1741,19 @@ impl DockerComputeDriver { impl ComputeDriver for ComputeDriverService { type WatchSandboxesStream = WatchStream; + async fn authenticate_sandbox( + &self, + request: Request, + ) -> Result, Status> + { + self.trace_rpc( + "driver.authenticate_sandbox", + "authenticate_sandbox", + ComputeDriver::authenticate_sandbox(&self.driver, request), + ) + .await + } + async fn get_capabilities( &self, request: Request, @@ -1900,6 +1914,16 @@ impl ComputeDriver for ComputeDriverService { #[tonic::async_trait] impl ComputeDriver for DockerComputeDriver { + async fn authenticate_sandbox( + &self, + _request: Request, + ) -> Result, Status> + { + Err(Status::unimplemented( + "docker does not authenticate sandbox credentials", + )) + } + type WatchSandboxesStream = WatchStream; async fn get_capabilities( diff --git a/crates/openshell-driver-kubernetes/README.md b/crates/openshell-driver-kubernetes/README.md index 4ebe93c9a9..0d0186ae11 100644 --- a/crates/openshell-driver-kubernetes/README.md +++ b/crates/openshell-driver-kubernetes/README.md @@ -33,7 +33,7 @@ annotation. An OTLP-enabled Agent Sandbox controller can therefore attach its asynchronous reconciliation spans to the originating OpenShell create trace. Workspace namespace modes assume exclusive control of the sandbox identity -resource chain. In shared and managed modes, only the gateway and its trusted +resource chain. In shared and managed modes, only the driver and its trusted Agent Sandbox controller may administer the sandbox namespace, Sandbox CRs, sandbox pods, or configured sandbox ServiceAccount. In operator mode, the platform operator owns namespace lifecycle but must prevent other principals @@ -103,7 +103,9 @@ Sandbox pods run as `service_account_name` and keep `automountServiceAccountToken: false`. The only Kubernetes token exposed to the supervisor is an explicit, audience-bound projected token mounted at `/var/run/secrets/openshell/token` for the one-shot `IssueSandboxToken` -bootstrap exchange. +bootstrap exchange. The Kubernetes driver authenticates that token through the +compute-driver protocol using its own `service_account_name` and workspace-mode +namespace policy; the gateway receives only the verified sandbox ID. The gateway uses the supervisor relay for connect, exec, and file sync. Sandbox pods do not need direct external ingress for SSH. diff --git a/crates/openshell-driver-kubernetes/src/config.rs b/crates/openshell-driver-kubernetes/src/config.rs index 0cf4965011..aadcb1342a 100644 --- a/crates/openshell-driver-kubernetes/src/config.rs +++ b/crates/openshell-driver-kubernetes/src/config.rs @@ -303,7 +303,7 @@ pub struct KubernetesComputeConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub operator_namespace_file: Option, /// Kubernetes `ServiceAccount` assigned to sandbox pods and accepted by - /// the gateway's `TokenReview` bootstrap authenticator. + /// the driver's `TokenReview` bootstrap authenticator. pub service_account_name: String, pub default_image: String, pub image_pull_policy: String, diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index 41a787b427..f4addd384a 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -8,9 +8,12 @@ use crate::config::{ DEFAULT_PROXY_UID, DEFAULT_SANDBOX_SERVICE_ACCOUNT_NAME, DEFAULT_SANDBOX_UID, DEFAULT_WORKSPACE_STORAGE_SIZE, KubernetesComputeConfig, OperatorNamespaceAllowlist, SupervisorSideloadMethod, SupervisorTopology, WorkspaceMode, is_dns_1123_label, - managed_namespace, validate_managed_namespace_name, + managed_namespace, managed_namespace_prefix, validate_managed_namespace_name, }; use futures::{Stream, StreamExt, TryStreamExt}; +use k8s_openapi::api::authentication::v1::{ + TokenReview, TokenReviewSpec, TokenReviewStatus, UserInfo, +}; use k8s_openapi::api::core::v1::{ Event as KubeEventObj, Namespace, Node, PersistentVolumeClaimVolumeSource, Pod, Secret, ServiceAccount, Volume, VolumeMount, @@ -19,7 +22,7 @@ use k8s_openapi::api::networking::v1::{ NetworkPolicy, NetworkPolicyIngressRule, NetworkPolicyPeer, NetworkPolicyPort, NetworkPolicySpec, }; -use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::{LabelSelector, OwnerReference}; use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString; use kube::api::{ Api, ApiResource, DeleteParams, ListParams, Patch, PatchParams, PostParams, Preconditions, @@ -119,6 +122,9 @@ pub const SANDBOX_KIND: &str = "Sandbox"; const SANDBOX_POD_NAME_ANNOTATION: &str = "agents.x-k8s.io/pod-name"; const SANDBOX_SUSPENDED_CONDITION: &str = "Suspended"; const SANDBOX_SUSPENDED_POD_NOT_OWNED_REASON: &str = "PodNotOwned"; +const SANDBOX_TOKEN_AUDIENCE: &str = "openshell-gateway"; +const POD_NAME_EXTRA: &str = "authentication.kubernetes.io/pod-name"; +const POD_UID_EXTRA: &str = "authentication.kubernetes.io/pod-uid"; const GPU_RESOURCE_NAME: &str = "nvidia.com/gpu"; const SPIFFE_WORKLOAD_API_VOLUME_NAME: &str = "spiffe-workload-api"; @@ -568,9 +574,71 @@ impl KubernetesComputeDriver { driver_version: openshell_core::VERSION.to_string(), default_image: self.config.default_image.clone(), gateway_manages_lifecycle: false, + supports_sandbox_authentication: true, }) } + /// Authenticate the projected `ServiceAccount` token used by a sandbox pod. + pub async fn authenticate_sandbox(&self, credential: &str) -> Result { + let reviews: Api = Api::all(self.client.clone()); + let review = TokenReview { + metadata: ObjectMeta::default(), + spec: TokenReviewSpec { + audiences: Some(vec![SANDBOX_TOKEN_AUDIENCE.to_string()]), + token: Some(credential.to_string()), + }, + status: None, + }; + let review = reviews + .create(&PostParams::default(), &review) + .await + .map_err(|error| { + warn!(%error, "Kubernetes TokenReview failed"); + tonic::Status::internal("Kubernetes TokenReview failed") + })?; + let status = review + .status + .ok_or_else(|| tonic::Status::internal("TokenReview response missing status"))?; + let identity = token_review_identity(&status, &self.config.service_account_name)? + .ok_or_else(|| tonic::Status::unauthenticated("sandbox credential was not accepted"))?; + if !self.accepts_auth_namespace(&identity.namespace) { + return Err(tonic::Status::permission_denied( + "sandbox credential namespace is not accepted by the driver", + )); + } + + let pods: Api = Api::namespaced(self.client.clone(), &identity.namespace); + let pod = pods + .get_opt(&identity.pod_name) + .await + .map_err(|error| { + warn!(pod = %identity.pod_name, %error, "failed to read authenticated sandbox pod"); + tonic::Status::internal("failed to read authenticated sandbox pod") + })? + .ok_or_else(|| { + tonic::Status::permission_denied("authenticated sandbox pod not found") + })?; + validate_pod_uid(&pod, &identity.pod_uid)?; + let sandbox_id = pod_sandbox_id(&pod)?; + let owner = sandbox_owner_reference(&pod)?; + let sandboxes = self + .supported_agent_sandbox_api(self.client.clone(), &identity.namespace) + .await + .map_err(|error| { + tonic::Status::internal(format!("failed to select Sandbox API: {error}")) + })?; + let sandbox = sandboxes.api.get_opt(&owner.name).await.map_err(|error| { + warn!(sandbox = %owner.name, %error, "failed to read authenticated Sandbox resource"); + tonic::Status::internal("failed to read authenticated Sandbox resource") + })?.ok_or_else(|| tonic::Status::permission_denied("sandbox owner not found"))?; + validate_sandbox_owner_identity(owner, &sandbox_id, &sandbox)?; + Ok(sandbox_id) + } + + fn accepts_auth_namespace(&self, namespace: &str) -> bool { + accepts_auth_namespace(&self.config, self.operator_allowlist.as_ref(), namespace) + } + pub fn operator_allowlist(&self) -> Option<&OperatorNamespaceAllowlist> { self.operator_allowlist.as_ref() } @@ -2280,6 +2348,161 @@ fn sandbox_id_from_object(obj: &DynamicObject) -> Result { Err("sandbox id not found on object".to_string()) } +#[derive(Debug)] +struct TokenReviewIdentity { + namespace: String, + pod_name: String, + pod_uid: String, +} + +#[allow(clippy::result_large_err)] +fn token_review_identity( + status: &TokenReviewStatus, + expected_service_account: &str, +) -> Result, tonic::Status> { + if status.authenticated != Some(true) { + return Ok(None); + } + if !status + .audiences + .as_deref() + .unwrap_or_default() + .iter() + .any(|audience| audience == SANDBOX_TOKEN_AUDIENCE) + { + return Err(tonic::Status::unauthenticated( + "sandbox credential audience not accepted", + )); + } + let user = status + .user + .as_ref() + .ok_or_else(|| tonic::Status::permission_denied("TokenReview response missing user"))?; + let rest = user + .username + .as_deref() + .unwrap_or_default() + .strip_prefix("system:serviceaccount:") + .ok_or_else(|| tonic::Status::permission_denied("credential is not a service account"))?; + let (namespace, service_account) = rest + .split_once(':') + .filter(|(namespace, service_account)| !namespace.is_empty() && !service_account.is_empty()) + .ok_or_else(|| tonic::Status::permission_denied("invalid service account identity"))?; + if service_account != expected_service_account { + return Err(tonic::Status::permission_denied( + "credential is not from the configured sandbox service account", + )); + } + Ok(Some(TokenReviewIdentity { + namespace: namespace.to_string(), + pod_name: user_extra_one(user, POD_NAME_EXTRA)?, + pod_uid: user_extra_one(user, POD_UID_EXTRA)?, + })) +} + +#[allow(clippy::result_large_err)] +fn user_extra_one(user: &UserInfo, key: &str) -> Result { + let values = user + .extra + .as_ref() + .and_then(|extra| extra.get(key)) + .ok_or_else(|| tonic::Status::permission_denied("sandbox credential is not pod-bound"))?; + if values.len() != 1 || values[0].is_empty() { + return Err(tonic::Status::permission_denied( + "sandbox credential has invalid pod binding", + )); + } + Ok(values[0].clone()) +} + +#[allow(clippy::result_large_err)] +fn pod_sandbox_id(pod: &Pod) -> Result { + pod.metadata + .annotations + .as_ref() + .and_then(|annotations| annotations.get(LABEL_SANDBOX_ID)) + .filter(|value| !value.is_empty()) + .cloned() + .ok_or_else(|| tonic::Status::permission_denied("pod is not bound to a sandbox identity")) +} + +#[allow(clippy::result_large_err)] +fn validate_pod_uid(pod: &Pod, expected_uid: &str) -> Result<(), tonic::Status> { + if pod.metadata.uid.as_deref() == Some(expected_uid) { + return Ok(()); + } + Err(tonic::Status::permission_denied( + "sandbox credential pod UID mismatch", + )) +} + +#[allow(clippy::result_large_err)] +fn sandbox_owner_reference(pod: &Pod) -> Result<&OwnerReference, tonic::Status> { + let mut owners = pod + .metadata + .owner_references + .as_deref() + .unwrap_or_default() + .iter() + .filter(|owner| { + owner.kind == SANDBOX_KIND + && matches!( + owner.api_version.as_str(), + "agents.x-k8s.io/v1beta1" | "agents.x-k8s.io/v1alpha1" + ) + }); + let owner = owners + .next() + .ok_or_else(|| tonic::Status::permission_denied("pod is not controlled by a Sandbox"))?; + if owners.next().is_some() + || owner.controller != Some(true) + || owner.name.is_empty() + || owner.uid.is_empty() + { + return Err(tonic::Status::permission_denied( + "pod has an invalid Sandbox owner", + )); + } + Ok(owner) +} + +#[allow(clippy::result_large_err)] +fn validate_sandbox_owner_identity( + owner: &OwnerReference, + sandbox_id: &str, + sandbox: &DynamicObject, +) -> Result<(), tonic::Status> { + let uid_matches = sandbox.metadata.uid.as_deref() == Some(owner.uid.as_str()); + let sandbox_id_matches = sandbox + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get(LABEL_SANDBOX_ID)) + .is_some_and(|actual| actual == sandbox_id); + if uid_matches && sandbox_id_matches { + return Ok(()); + } + Err(tonic::Status::permission_denied( + "pod identity does not match its Sandbox owner", + )) +} + +fn accepts_auth_namespace( + config: &KubernetesComputeConfig, + operator_allowlist: Option<&OperatorNamespaceAllowlist>, + namespace: &str, +) -> bool { + match config.workspace_mode { + WorkspaceMode::Shared => namespace == config.namespace, + WorkspaceMode::Managed => { + namespace.starts_with(&managed_namespace_prefix(&config.gateway_id)) + } + WorkspaceMode::Operator => { + operator_allowlist.is_some_and(|allowlist| allowlist.contains(namespace)) + } + } +} + fn annotation_or_label(obj: &DynamicObject, key: &str) -> Option { obj.metadata .annotations @@ -3606,7 +3829,7 @@ fn sandbox_template_to_k8s_with_validated_config( .unwrap_or_default(); if !params.sandbox_id.is_empty() { pod_annotations.insert( - "openshell.io/sandbox-id".to_string(), + LABEL_SANDBOX_ID.to_string(), serde_json::Value::String(params.sandbox_id.to_string()), ); } @@ -4692,6 +4915,7 @@ mod tests { }; use openshell_core::proto::compute::v1::{GpuResourceRequirements, ResourceRequirements}; use prost_types::{Struct, Value, value::Kind}; + use std::collections::BTreeSet; static ENV_LOCK: std::sync::LazyLock> = std::sync::LazyLock::new(|| std::sync::Mutex::new(())); @@ -4905,6 +5129,180 @@ mod tests { ); } + fn authenticated_token_review(username: &str) -> TokenReviewStatus { + TokenReviewStatus { + authenticated: Some(true), + audiences: Some(vec![SANDBOX_TOKEN_AUDIENCE.to_string()]), + user: Some(UserInfo { + username: Some(username.to_string()), + extra: Some(BTreeMap::from([ + (POD_NAME_EXTRA.to_string(), vec!["sandbox-pod".to_string()]), + (POD_UID_EXTRA.to_string(), vec!["pod-uid".to_string()]), + ])), + ..Default::default() + }), + ..Default::default() + } + } + + #[test] + fn token_review_uses_configured_service_account_and_pod_binding() { + let status = authenticated_token_review("system:serviceaccount:workspaces:sandbox-sa"); + let identity = token_review_identity(&status, "sandbox-sa") + .unwrap() + .expect("authenticated identity"); + assert_eq!(identity.namespace, "workspaces"); + assert_eq!(identity.pod_name, "sandbox-pod"); + assert_eq!(identity.pod_uid, "pod-uid"); + } + + #[test] + fn token_review_rejects_a_different_service_account() { + let status = authenticated_token_review("system:serviceaccount:workspaces:other"); + let error = token_review_identity(&status, "sandbox-sa").unwrap_err(); + assert_eq!(error.code(), tonic::Code::PermissionDenied); + } + + #[test] + fn token_review_rejects_wrong_audience_and_missing_pod_binding() { + let mut wrong_audience = + authenticated_token_review("system:serviceaccount:workspaces:sandbox-sa"); + wrong_audience.audiences = Some(vec!["kubernetes.default.svc".to_string()]); + let error = token_review_identity(&wrong_audience, "sandbox-sa").unwrap_err(); + assert_eq!(error.code(), tonic::Code::Unauthenticated); + + let mut missing_binding = + authenticated_token_review("system:serviceaccount:workspaces:sandbox-sa"); + missing_binding.user.as_mut().unwrap().extra = None; + let error = token_review_identity(&missing_binding, "sandbox-sa").unwrap_err(); + assert_eq!(error.code(), tonic::Code::PermissionDenied); + } + + #[test] + fn token_review_returns_none_when_not_authenticated() { + let status = TokenReviewStatus { + authenticated: Some(false), + error: Some("token rejected".to_string()), + ..Default::default() + }; + + assert!( + token_review_identity(&status, "sandbox-sa") + .unwrap() + .is_none() + ); + } + + #[test] + fn authentication_namespace_validation_covers_each_workspace_mode() { + let mut config = KubernetesComputeConfig { + namespace: "openshell".to_string(), + ..Default::default() + }; + assert!(accepts_auth_namespace(&config, None, "openshell")); + assert!(!accepts_auth_namespace(&config, None, "other")); + + config.workspace_mode = WorkspaceMode::Managed; + config.gateway_id = "gateway-a".to_string(); + assert!(accepts_auth_namespace( + &config, + None, + "openshell-gateway-a-workspace-a" + )); + assert!(!accepts_auth_namespace( + &config, + None, + "openshell-gateway-b-workspace-a" + )); + + config.workspace_mode = WorkspaceMode::Operator; + let allowlist = OperatorNamespaceAllowlist::from_set(BTreeSet::from([ + "team-a".to_string(), + "team-b".to_string(), + ])); + assert!(accepts_auth_namespace(&config, Some(&allowlist), "team-a")); + assert!(!accepts_auth_namespace(&config, Some(&allowlist), "team-c")); + assert!(!accepts_auth_namespace(&config, None, "team-a")); + } + + fn sandbox_owner_for_test(name: &str, uid: &str) -> OwnerReference { + OwnerReference { + api_version: "agents.x-k8s.io/v1beta1".to_string(), + block_owner_deletion: None, + controller: Some(true), + kind: SANDBOX_KIND.to_string(), + name: name.to_string(), + uid: uid.to_string(), + } + } + + fn sandbox_object_for_test(uid: &str, sandbox_id: &str) -> DynamicObject { + let resource = ApiResource::from_gvk(&GroupVersionKind::gvk( + SANDBOX_GROUP, + SANDBOX_VERSION_V1BETA1, + SANDBOX_KIND, + )); + let mut sandbox = DynamicObject::new("sandbox-a", &resource); + sandbox.metadata.uid = Some(uid.to_string()); + sandbox.metadata.labels = Some(BTreeMap::from([( + LABEL_SANDBOX_ID.to_string(), + sandbox_id.to_string(), + )])); + sandbox + } + + #[test] + fn pod_identity_requires_matching_uid_annotation_and_controlling_owner() { + let owner = sandbox_owner_for_test("sandbox-a", "sandbox-uid-a"); + let pod = Pod { + metadata: ObjectMeta { + uid: Some("pod-uid-a".to_string()), + annotations: Some(BTreeMap::from([( + LABEL_SANDBOX_ID.to_string(), + "sandbox-id-a".to_string(), + )])), + owner_references: Some(vec![owner.clone()]), + ..Default::default() + }, + ..Default::default() + }; + + validate_pod_uid(&pod, "pod-uid-a").expect("matching pod UID"); + assert_eq!(pod_sandbox_id(&pod).unwrap(), "sandbox-id-a"); + assert_eq!(sandbox_owner_reference(&pod).unwrap(), &owner); + + let error = validate_pod_uid(&pod, "other-pod-uid").unwrap_err(); + assert_eq!(error.code(), tonic::Code::PermissionDenied); + + let mut missing_annotation = pod.clone(); + missing_annotation.metadata.annotations = None; + let error = pod_sandbox_id(&missing_annotation).unwrap_err(); + assert_eq!(error.code(), tonic::Code::PermissionDenied); + + let mut non_controlling = pod; + non_controlling.metadata.owner_references.as_mut().unwrap()[0].controller = Some(false); + let error = sandbox_owner_reference(&non_controlling).unwrap_err(); + assert_eq!(error.code(), tonic::Code::PermissionDenied); + } + + #[test] + fn sandbox_owner_identity_requires_matching_uid_and_sandbox_id() { + let owner = sandbox_owner_for_test("sandbox-a", "sandbox-uid-a"); + let sandbox = sandbox_object_for_test("sandbox-uid-a", "sandbox-id-a"); + validate_sandbox_owner_identity(&owner, "sandbox-id-a", &sandbox) + .expect("matching owner identity"); + + let mismatched_owner = sandbox_object_for_test("sandbox-uid-b", "sandbox-id-a"); + let error = + validate_sandbox_owner_identity(&owner, "sandbox-id-a", &mismatched_owner).unwrap_err(); + assert_eq!(error.code(), tonic::Code::PermissionDenied); + + let mismatched_annotation = sandbox_object_for_test("sandbox-uid-a", "sandbox-id-b"); + let error = validate_sandbox_owner_identity(&owner, "sandbox-id-a", &mismatched_annotation) + .unwrap_err(); + assert_eq!(error.code(), tonic::Code::PermissionDenied); + } + #[test] fn sandbox_api_version_probe_retries_on_structured_and_raw_404() { let structured = kube_api_error(404, "could not find the requested resource"); @@ -7281,6 +7679,24 @@ mod tests { ); } + #[test] + fn sandbox_template_annotation_is_accepted_by_bootstrap_authentication() { + let params = SandboxPodParams { + sandbox_id: "sandbox-a", + ..Default::default() + }; + let pod_template = sandbox_template_to_k8s( + &SandboxTemplate::default(), + false, + &std::collections::HashMap::new(), + true, + ¶ms, + ); + let pod: Pod = serde_json::from_value(pod_template).expect("valid pod template"); + + assert_eq!(pod_sandbox_id(&pod).unwrap(), "sandbox-a"); + } + #[test] fn sandbox_template_omits_empty_image_pull_secrets() { let pod_template = sandbox_template_to_k8s( diff --git a/crates/openshell-driver-kubernetes/src/grpc.rs b/crates/openshell-driver-kubernetes/src/grpc.rs index 383ddebe0c..027c350a26 100644 --- a/crates/openshell-driver-kubernetes/src/grpc.rs +++ b/crates/openshell-driver-kubernetes/src/grpc.rs @@ -5,14 +5,15 @@ use futures::{Stream, StreamExt}; use openshell_core::proto::compute::v1::{ - CreateSandboxRequest, CreateSandboxResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DeleteWorkspaceRequest, DeleteWorkspaceResponse, EnsureWorkspaceRequest, - EnsureWorkspaceResponse, GetCapabilitiesRequest, GetCapabilitiesResponse, - GetGatewayListenerRequirementsRequest, GetGatewayListenerRequirementsResponse, - GetSandboxRequest, GetSandboxResponse, ListSandboxesRequest, ListSandboxesResponse, - StartSandboxRequest, StartSandboxResponse, StopSandboxRequest, StopSandboxResponse, - ValidateSandboxCreateRequest, ValidateSandboxCreateResponse, WatchSandboxesEvent, - WatchSandboxesRequest, compute_driver_server::ComputeDriver, + AuthenticateSandboxRequest, AuthenticateSandboxResponse, CreateSandboxRequest, + CreateSandboxResponse, DeleteSandboxRequest, DeleteSandboxResponse, DeleteWorkspaceRequest, + DeleteWorkspaceResponse, EnsureWorkspaceRequest, EnsureWorkspaceResponse, + GetCapabilitiesRequest, GetCapabilitiesResponse, GetGatewayListenerRequirementsRequest, + GetGatewayListenerRequirementsResponse, GetSandboxRequest, GetSandboxResponse, + ListSandboxesRequest, ListSandboxesResponse, StartSandboxRequest, StartSandboxResponse, + StopSandboxRequest, StopSandboxResponse, ValidateSandboxCreateRequest, + ValidateSandboxCreateResponse, WatchSandboxesEvent, WatchSandboxesRequest, + compute_driver_server::ComputeDriver, }; use std::future::Future; use std::pin::Pin; @@ -122,6 +123,18 @@ impl ComputeDriverService { #[tonic::async_trait] impl ComputeDriver for ComputeDriverService { + async fn authenticate_sandbox( + &self, + request: Request, + ) -> Result, Status> { + let credential = request.into_inner().credential; + if credential.is_empty() { + return Err(Status::invalid_argument("credential is required")); + } + let sandbox_id = self.driver.authenticate_sandbox(&credential).await?; + Ok(Response::new(AuthenticateSandboxResponse { sandbox_id })) + } + async fn get_capabilities( &self, _request: Request, diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index a11189cbc6..8f7c0d32f6 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -507,6 +507,7 @@ impl PodmanComputeDriver { driver_version: openshell_core::VERSION.to_string(), default_image: self.config.default_image.clone(), gateway_manages_lifecycle: true, + supports_sandbox_authentication: false, }) } diff --git a/crates/openshell-driver-podman/src/grpc.rs b/crates/openshell-driver-podman/src/grpc.rs index 2cb59f086f..222c617c2f 100644 --- a/crates/openshell-driver-podman/src/grpc.rs +++ b/crates/openshell-driver-podman/src/grpc.rs @@ -127,6 +127,16 @@ impl ComputeDriverService { #[tonic::async_trait] impl ComputeDriver for ComputeDriverService { + async fn authenticate_sandbox( + &self, + _request: Request, + ) -> Result, Status> + { + Err(Status::unimplemented( + "podman does not authenticate sandbox credentials", + )) + } + async fn get_capabilities( &self, _request: Request, diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index 13e57f546d..2de65c3add 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -524,6 +524,7 @@ impl VmDriver { driver_version: openshell_core::VERSION.to_string(), default_image: self.config.default_image.clone(), gateway_manages_lifecycle: true, + supports_sandbox_authentication: false, } } @@ -3325,6 +3326,16 @@ impl VmDriver { #[tonic::async_trait] impl ComputeDriver for VmDriver { + async fn authenticate_sandbox( + &self, + _request: Request, + ) -> Result, Status> + { + Err(Status::unimplemented( + "VM driver does not authenticate sandbox credentials", + )) + } + async fn get_capabilities( &self, _request: Request, diff --git a/crates/openshell-server/src/auth/authenticator.rs b/crates/openshell-server/src/auth/authenticator.rs index f5d5c7b2af..5511ef79db 100644 --- a/crates/openshell-server/src/auth/authenticator.rs +++ b/crates/openshell-server/src/auth/authenticator.rs @@ -14,8 +14,8 @@ //! //! Live authenticators slotting into the chain: //! - [`super::sandbox_jwt::SandboxJwtAuthenticator`] — gateway-minted JWTs -//! - [`super::k8s_sa::K8sServiceAccountAuthenticator`] — K8s projected SA -//! tokens (path-scoped to `IssueSandboxToken`) +//! - [`super::compute_driver::ComputeDriverAuthenticator`] — driver-native +//! sandbox bootstrap credentials (path-scoped to `IssueSandboxToken`) //! - [`super::oidc::OidcAuthenticator`] — user OIDC Bearer tokens use super::principal::Principal; use async_trait::async_trait; diff --git a/crates/openshell-server/src/auth/compute_driver.rs b/crates/openshell-server/src/auth/compute_driver.rs new file mode 100644 index 0000000000..cedc2115b5 --- /dev/null +++ b/crates/openshell-server/src/auth/compute_driver.rs @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Compute-driver delegated sandbox bootstrap authentication. + +use super::authenticator::Authenticator; +use super::principal::{Principal, SandboxIdentitySource, SandboxPrincipal}; +use crate::compute::ComputeRuntime; +use async_trait::async_trait; +use tonic::Status; + +/// The only public gateway method on which driver-native credentials apply. +pub const ISSUE_SANDBOX_TOKEN_PATH: &str = "/openshell.v1.OpenShell/IssueSandboxToken"; + +#[derive(Clone, Debug)] +pub struct ComputeDriverAuthenticator { + compute: ComputeRuntime, +} + +impl ComputeDriverAuthenticator { + pub fn new(compute: ComputeRuntime) -> Self { + Self { compute } + } +} + +#[async_trait] +impl Authenticator for ComputeDriverAuthenticator { + async fn authenticate( + &self, + headers: &http::HeaderMap, + path: &str, + ) -> Result, Status> { + if path != ISSUE_SANDBOX_TOKEN_PATH { + return Ok(None); + } + + let Some(credential) = headers + .get(http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + else { + return Ok(None); + }; + + let sandbox_id = self.compute.authenticate_sandbox(credential).await?; + if sandbox_id.is_empty() { + return Err(Status::permission_denied( + "compute driver returned an empty sandbox identity", + )); + } + + Ok(Some(Principal::Sandbox(SandboxPrincipal { + sandbox_id, + source: SandboxIdentitySource::ComputeDriver { + driver_name: self.compute.selected_driver_name().to_string(), + }, + trust_domain: Some("openshell".to_string()), + }))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::principal::SandboxIdentitySource; + use crate::compute::{NoopTestDriver, new_test_runtime_with_driver}; + use crate::persistence::Store; + use std::sync::Arc; + use tonic::Code; + + fn bearer_headers(token: &str) -> http::HeaderMap { + let mut headers = http::HeaderMap::new(); + headers.insert( + http::header::AUTHORIZATION, + http::HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), + ); + headers + } + + async fn authenticator(driver: NoopTestDriver) -> ComputeDriverAuthenticator { + let store = Arc::new(Store::connect("sqlite::memory:").await.unwrap()); + let compute = + new_test_runtime_with_driver(store, "external-kubernetes", Arc::new(driver)).await; + ComputeDriverAuthenticator::new(compute) + } + + #[tokio::test] + async fn authenticates_driver_credential_on_issue_path() { + let auth = authenticator(NoopTestDriver::authenticating_sandbox("sandbox-a")).await; + + let principal = auth + .authenticate( + &bearer_headers("driver-credential"), + ISSUE_SANDBOX_TOKEN_PATH, + ) + .await + .unwrap() + .expect("driver credential should authenticate"); + + let Principal::Sandbox(principal) = principal else { + panic!("expected sandbox principal"); + }; + assert_eq!(principal.sandbox_id, "sandbox-a"); + assert!(matches!( + principal.source, + SandboxIdentitySource::ComputeDriver { ref driver_name } + if driver_name == "external-kubernetes" + )); + } + + #[tokio::test] + async fn authenticator_is_scoped_to_issue_path() { + let auth = authenticator(NoopTestDriver::failing_sandbox_authentication( + Code::Unavailable, + "driver must not be called", + )) + .await; + + let result = auth + .authenticate( + &bearer_headers("driver-credential"), + "/openshell.v1.OpenShell/GetSandboxConfig", + ) + .await + .unwrap(); + + assert!(result.is_none()); + } + + #[tokio::test] + async fn missing_bearer_credential_falls_through() { + let auth = authenticator(NoopTestDriver::authenticating_sandbox("sandbox-a")).await; + + let result = auth + .authenticate(&http::HeaderMap::new(), ISSUE_SANDBOX_TOKEN_PATH) + .await + .unwrap(); + + assert!(result.is_none()); + } + + #[tokio::test] + async fn empty_driver_identity_is_rejected() { + let auth = authenticator(NoopTestDriver::authenticating_sandbox("")).await; + + let error = auth + .authenticate( + &bearer_headers("driver-credential"), + ISSUE_SANDBOX_TOKEN_PATH, + ) + .await + .expect_err("empty identity must fail closed"); + + assert_eq!(error.code(), Code::PermissionDenied); + } + + #[tokio::test] + async fn driver_authentication_error_propagates() { + let auth = authenticator(NoopTestDriver::failing_sandbox_authentication( + Code::Unavailable, + "driver unavailable", + )) + .await; + + let error = auth + .authenticate( + &bearer_headers("driver-credential"), + ISSUE_SANDBOX_TOKEN_PATH, + ) + .await + .expect_err("driver errors must propagate"); + + assert_eq!(error.code(), Code::Unavailable); + assert_eq!(error.message(), "driver unavailable"); + } +} diff --git a/crates/openshell-server/src/auth/k8s_sa.rs b/crates/openshell-server/src/auth/k8s_sa.rs deleted file mode 100644 index 131dbaba47..0000000000 --- a/crates/openshell-server/src/auth/k8s_sa.rs +++ /dev/null @@ -1,1098 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Kubernetes `ServiceAccount` bootstrap authenticator. -//! -//! Path-scoped to `IssueSandboxToken`. Validates a projected SA token -//! presented by a sandbox pod, reads the pod's `openshell.io/sandbox-id` -//! annotation, verifies the pod is controlled by the corresponding Sandbox CR, -//! and returns a [`Principal::Sandbox`] with -//! [`SandboxIdentitySource::K8sServiceAccount`]. The `IssueSandboxToken` handler -//! then mints a gateway-signed JWT for that sandbox id; subsequent gRPC calls -//! from the supervisor use the gateway-minted JWT validated by -//! [`super::sandbox_jwt::SandboxJwtAuthenticator`]. -//! -//! This is the only authenticator that talks to the K8s apiserver. It is -//! optional — the gateway boots without it in singleplayer deployments. - -use super::authenticator::Authenticator; -use super::principal::{Principal, SandboxIdentitySource, SandboxPrincipal}; -use async_trait::async_trait; -use k8s_openapi::api::{ - authentication::v1::{TokenReview, TokenReviewSpec, TokenReviewStatus, UserInfo}, - core::v1::Pod, -}; -use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; -use kube::Error as KubeError; -use kube::api::{Api, ApiResource, PostParams}; -use kube::core::{DynamicObject, gvk::GroupVersionKind}; -use openshell_core::OperatorNamespaceAllowlist; -use std::sync::Arc; -use tonic::Status; -use tracing::{debug, info, warn}; - -/// gRPC method path that this authenticator accepts. All other paths fall -/// through (return `Ok(None)`) so a gateway-minted JWT is required there. -pub const ISSUE_SANDBOX_TOKEN_PATH: &str = "/openshell.v1.OpenShell/IssueSandboxToken"; - -/// Pod annotation that binds a sandbox pod to its UUID. Set by the -/// Kubernetes compute driver at pod-create time. The gateway accepts this -/// annotation only after validating the pod's `TokenReview` binding, live UID, -/// and owning Sandbox CR. The K8s `Role` granted to the gateway must not -/// include `patch pods` (see plan §11.8). -pub const SANDBOX_ID_ANNOTATION: &str = "openshell.io/sandbox-id"; -const SANDBOX_API_GROUP: &str = "agents.x-k8s.io"; -const SANDBOX_API_VERSION_V1BETA1: &str = "v1beta1"; -const SANDBOX_API_VERSION_V1ALPHA1: &str = "v1alpha1"; -const SANDBOX_API_VERSION_FULL_V1BETA1: &str = "agents.x-k8s.io/v1beta1"; -const SANDBOX_API_VERSION_FULL_V1ALPHA1: &str = "agents.x-k8s.io/v1alpha1"; -const SANDBOX_KIND: &str = "Sandbox"; -const SANDBOX_ID_LABEL: &str = "openshell.ai/sandbox-id"; -const POD_NAME_EXTRA: &str = "authentication.kubernetes.io/pod-name"; -const POD_UID_EXTRA: &str = "authentication.kubernetes.io/pod-uid"; - -/// Resolved identity extracted from a validated SA token + pod lookup. -#[derive(Debug, Clone)] -pub struct ResolvedK8sIdentity { - pub sandbox_id: String, - pub pod_name: String, - pub pod_uid: String, -} - -/// Apiserver-facing operations the authenticator depends on. Split out so -/// tests can fake the apiserver without standing up a kube cluster. -#[async_trait] -pub trait K8sIdentityResolver: Send + Sync + 'static { - /// Validate `token` via `TokenReview` (`aud == openshell-gateway`), - /// extract the pod name/uid, then `GET` the pod and read - /// `openshell.io/sandbox-id`. Returns `Ok(None)` when the token is - /// well-formed but does not authenticate (e.g. wrong audience); returns - /// `Err` for transport/server errors. - async fn resolve(&self, token: &str) -> Result, Status>; -} - -/// Authenticator wrapper around a [`K8sIdentityResolver`]. -pub struct K8sServiceAccountAuthenticator { - resolver: Arc, -} - -impl std::fmt::Debug for K8sServiceAccountAuthenticator { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("K8sServiceAccountAuthenticator") - .finish_non_exhaustive() - } -} - -impl K8sServiceAccountAuthenticator { - pub fn new(resolver: Arc) -> Self { - Self { resolver } - } -} - -#[async_trait] -impl Authenticator for K8sServiceAccountAuthenticator { - async fn authenticate( - &self, - headers: &http::HeaderMap, - path: &str, - ) -> Result, Status> { - // Scope: only the bootstrap RPC. Other paths fall through so the - // SandboxJwtAuthenticator (or OIDC) handles them. - if path != ISSUE_SANDBOX_TOKEN_PATH { - return Ok(None); - } - - let Some(token) = headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ")) - else { - return Ok(None); - }; - - let Some(resolved) = self.resolver.resolve(token).await? else { - debug!("K8s SA token did not authenticate; falling through"); - return Ok(None); - }; - - if resolved.sandbox_id.is_empty() { - warn!( - pod = %resolved.pod_name, - "pod missing openshell.io/sandbox-id annotation; rejecting" - ); - return Err(Status::permission_denied( - "pod is not bound to a sandbox identity", - )); - } - - Ok(Some(Principal::Sandbox(SandboxPrincipal { - sandbox_id: resolved.sandbox_id, - source: SandboxIdentitySource::K8sServiceAccount { - pod_name: resolved.pod_name, - pod_uid: resolved.pod_uid, - }, - trust_domain: Some("openshell".to_string()), - }))) - } -} - -/// Validates the namespace extracted from an SA token username against the -/// expected set for the active workspace mode. -#[derive(Debug, Clone)] -pub enum NamespaceValidator { - /// Shared mode: accept only the single configured namespace. - Exact(String), - /// Managed mode: accept any namespace with the managed prefix - /// (`openshell-{gateway_id}-`). - Prefix(String), - /// Operator mode: accept namespaces in the dynamic allowlist. - Allowlist(OperatorNamespaceAllowlist), -} - -impl NamespaceValidator { - pub fn accepts(&self, namespace: &str) -> bool { - match self { - Self::Exact(expected) => namespace == expected, - Self::Prefix(prefix) => namespace.starts_with(prefix.as_str()), - Self::Allowlist(al) => al.contains(namespace), - } - } -} - -#[derive(Debug)] -struct TokenReviewIdentity { - namespace: String, - pod_name: String, - pod_uid: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct SandboxOwnerReference { - api_version: String, - name: String, - uid: String, -} - -/// Resolver backed by the apiserver's `TokenReview` API and `kube::Client` -/// for the per-pod annotation lookup. -pub struct LiveK8sResolver { - client: kube::Client, - token_reviews_api: Api, - expected_audience: String, - namespace_validator: NamespaceValidator, - expected_service_account: String, -} - -impl LiveK8sResolver { - pub fn new( - client: kube::Client, - namespace_validator: NamespaceValidator, - expected_audience: String, - expected_service_account: String, - ) -> Self { - let token_reviews_api: Api = Api::all(client.clone()); - Self { - client, - token_reviews_api, - expected_audience, - namespace_validator, - expected_service_account, - } - } - - fn pods_api(&self, namespace: &str) -> Api { - Api::namespaced(self.client.clone(), namespace) - } - - fn sandboxes_api(&self, namespace: &str, api_version: &str) -> Api { - let gvk = GroupVersionKind::gvk(SANDBOX_API_GROUP, api_version, SANDBOX_KIND); - let resource = ApiResource::from_gvk(&gvk); - Api::namespaced_with(self.client.clone(), namespace, &resource) - } - - async fn get_sandbox_cr_for_owner( - &self, - namespace: &str, - owner: &SandboxOwnerReference, - ) -> Result, KubeError> { - let versions = if owner.api_version == SANDBOX_API_VERSION_FULL_V1ALPHA1 { - [SANDBOX_API_VERSION_V1ALPHA1, SANDBOX_API_VERSION_V1BETA1] - } else { - [SANDBOX_API_VERSION_V1BETA1, SANDBOX_API_VERSION_V1ALPHA1] - }; - - for version in versions { - let api = self.sandboxes_api(namespace, version); - match api.get_opt(&owner.name).await { - Ok(Some(sandbox_cr)) => return Ok(Some(sandbox_cr)), - Ok(None) => {} - Err(err) if should_try_next_sandbox_api_version(&err) => {} - Err(err) => return Err(err), - } - } - - Ok(None) - } -} - -#[async_trait] -impl K8sIdentityResolver for LiveK8sResolver { - async fn resolve(&self, token: &str) -> Result, Status> { - let review = TokenReview { - metadata: ObjectMeta::default(), - spec: TokenReviewSpec { - audiences: Some(vec![self.expected_audience.clone()]), - token: Some(token.to_string()), - }, - status: None, - }; - - let review = self - .token_reviews_api - .create(&PostParams::default(), &review) - .await - .map_err(|e| { - warn!(error = %e, "K8s TokenReview failed"); - Status::internal(format!("tokenreview failed: {e}")) - })?; - let status = review - .status - .ok_or_else(|| Status::internal("TokenReview response missing status"))?; - let Some(identity) = token_review_identity( - &status, - &self.expected_audience, - &self.namespace_validator, - &self.expected_service_account, - )? - else { - return Ok(None); - }; - - info!( - pod_name = %identity.pod_name, - pod_uid = %identity.pod_uid, - namespace = %identity.namespace, - service_account = %self.expected_service_account, - "validated K8s SA token via TokenReview" - ); - - let pods_api = self.pods_api(&identity.namespace); - let pod = pods_api.get_opt(&identity.pod_name).await.map_err(|e| { - warn!( - pod = %identity.pod_name, - namespace = %identity.namespace, - error = %e, - "failed to fetch sandbox pod for annotation lookup" - ); - Status::internal(format!("pod GET failed: {e}")) - })?; - let Some(pod) = pod else { - warn!( - pod = %identity.pod_name, - namespace = %identity.namespace, - "sandbox pod referenced by SA token not found" - ); - return Err(Status::not_found("sandbox pod not found")); - }; - - let actual_uid = pod.metadata.uid.as_deref().unwrap_or_default(); - if actual_uid != identity.pod_uid { - warn!( - pod = %identity.pod_name, - claimed_uid = %identity.pod_uid, - actual_uid = %actual_uid, - "SA token pod UID does not match live pod; rejecting" - ); - return Err(Status::permission_denied("SA token pod UID mismatch")); - } - - let sandbox_id = pod_sandbox_id(&pod)?; - - let owner = sandbox_owner_reference(&pod)?; - let sandbox_cr = self - .get_sandbox_cr_for_owner(&identity.namespace, &owner) - .await - .map_err(|e| { - warn!( - pod = %identity.pod_name, - sandbox_owner = %owner.name, - sandbox_owner_api_version = %owner.api_version, - error = %e, - "failed to fetch owning Sandbox CR for pod identity validation" - ); - Status::internal(format!("sandbox GET failed: {e}")) - })?; - let Some(sandbox_cr) = sandbox_cr else { - warn!( - pod = %identity.pod_name, - sandbox_owner = %owner.name, - sandbox_owner_api_version = %owner.api_version, - "pod ownerReference points to a Sandbox CR that does not exist" - ); - return Err(Status::permission_denied("sandbox owner not found")); - }; - validate_sandbox_owner_reference(&owner, &sandbox_id, &sandbox_cr)?; - - Ok(Some(ResolvedK8sIdentity { - sandbox_id, - pod_name: identity.pod_name, - pod_uid: identity.pod_uid, - })) - } -} - -#[allow(clippy::result_large_err)] -fn token_review_identity( - status: &TokenReviewStatus, - expected_audience: &str, - namespace_validator: &NamespaceValidator, - expected_service_account: &str, -) -> Result, Status> { - if status.authenticated != Some(true) { - debug!( - error = status.error.as_deref().unwrap_or_default(), - "K8s TokenReview did not authenticate token" - ); - return Ok(None); - } - - let audiences = status.audiences.as_deref().unwrap_or_default(); - if !audiences.iter().any(|aud| aud == expected_audience) { - warn!( - expected_audience = %expected_audience, - audiences = ?audiences, - "K8s TokenReview authenticated token without expected audience" - ); - return Err(Status::unauthenticated("SA token audience not accepted")); - } - - let user = status - .user - .as_ref() - .ok_or_else(|| Status::permission_denied("TokenReview response missing user info"))?; - let username = user - .username - .as_deref() - .ok_or_else(|| Status::permission_denied("TokenReview response missing username"))?; - - let (namespace, sa_name) = parse_sa_username(username).ok_or_else(|| { - warn!( - username = %username, - "K8s TokenReview username is not a service account" - ); - Status::permission_denied("SA token username format not recognized") - })?; - - if sa_name != expected_service_account { - warn!( - username = %username, - service_account = %sa_name, - expected = %expected_service_account, - "K8s TokenReview principal is not the configured sandbox service account" - ); - return Err(Status::permission_denied( - "SA token is not from the configured sandbox service account", - )); - } - - if !namespace_validator.accepts(&namespace) { - warn!( - username = %username, - namespace = %namespace, - "K8s TokenReview SA namespace not accepted by workspace mode validator" - ); - return Err(Status::permission_denied( - "SA token is not from an accepted sandbox namespace", - )); - } - - let pod_name = user_extra_one(user, POD_NAME_EXTRA)?; - let pod_uid = user_extra_one(user, POD_UID_EXTRA)?; - Ok(Some(TokenReviewIdentity { - namespace, - pod_name, - pod_uid, - })) -} - -fn parse_sa_username(username: &str) -> Option<(String, String)> { - let rest = username.strip_prefix("system:serviceaccount:")?; - let (namespace, sa_name) = rest.split_once(':')?; - if namespace.is_empty() || sa_name.is_empty() { - return None; - } - Some((namespace.to_string(), sa_name.to_string())) -} - -#[allow(clippy::result_large_err)] -fn user_extra_one(user: &UserInfo, key: &str) -> Result { - let Some(values) = user.extra.as_ref().and_then(|extra| extra.get(key)) else { - return Err(Status::permission_denied("SA token is not pod-bound")); - }; - if values.len() != 1 || values[0].is_empty() { - return Err(Status::permission_denied( - "SA token has invalid pod binding", - )); - } - Ok(values[0].clone()) -} - -#[allow(clippy::result_large_err)] -fn pod_sandbox_id(pod: &Pod) -> Result { - let sandbox_id = pod - .metadata - .annotations - .as_ref() - .and_then(|a| a.get(SANDBOX_ID_ANNOTATION)) - .cloned() - .unwrap_or_default(); - if sandbox_id.is_empty() { - return Err(Status::permission_denied( - "pod is not bound to a sandbox identity", - )); - } - Ok(sandbox_id) -} - -#[allow(clippy::result_large_err)] -fn sandbox_owner_reference(pod: &Pod) -> Result { - let owner_refs = pod.metadata.owner_references.as_deref().unwrap_or_default(); - let mut sandbox_refs = owner_refs - .iter() - .filter(|owner| is_supported_sandbox_owner_reference(owner)); - let Some(owner) = sandbox_refs.next() else { - let unsupported_sandbox_api_versions = owner_refs - .iter() - .filter(|owner| owner.kind == SANDBOX_KIND) - .map(|owner| owner.api_version.as_str()) - .collect::>(); - if !unsupported_sandbox_api_versions.is_empty() { - warn!( - api_versions = ?unsupported_sandbox_api_versions, - supported_api_versions = ?[ - SANDBOX_API_VERSION_FULL_V1BETA1, - SANDBOX_API_VERSION_FULL_V1ALPHA1, - ], - "pod Sandbox ownerReference uses unsupported apiVersion" - ); - } - return Err(Status::permission_denied( - "pod is not controlled by an OpenShell Sandbox", - )); - }; - if sandbox_refs.next().is_some() { - return Err(Status::permission_denied( - "pod has multiple OpenShell Sandbox owners", - )); - } - if owner.controller != Some(true) { - return Err(Status::permission_denied( - "pod Sandbox ownerReference is not controlling", - )); - } - if owner.name.is_empty() || owner.uid.is_empty() { - return Err(Status::permission_denied( - "pod Sandbox ownerReference is incomplete", - )); - } - Ok(SandboxOwnerReference { - api_version: owner.api_version.clone(), - name: owner.name.clone(), - uid: owner.uid.clone(), - }) -} - -fn is_supported_sandbox_owner_reference( - owner: &k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference, -) -> bool { - owner.kind == SANDBOX_KIND - && matches!( - owner.api_version.as_str(), - SANDBOX_API_VERSION_FULL_V1BETA1 | SANDBOX_API_VERSION_FULL_V1ALPHA1 - ) -} - -fn should_try_next_sandbox_api_version(err: &KubeError) -> bool { - // Kubernetes returns a structured 404 for some missing API resources and a - // raw "404 page not found" body for others. Both mean the probed - // group/version is unavailable and the next supported Sandbox API version - // should be tried. - matches!(err, KubeError::Api(api) if api.code == 404) -} - -#[allow(clippy::result_large_err)] -fn validate_sandbox_owner_reference( - owner: &SandboxOwnerReference, - sandbox_id: &str, - sandbox_cr: &DynamicObject, -) -> Result<(), Status> { - let actual_uid = sandbox_cr.metadata.uid.as_deref().unwrap_or_default(); - if actual_uid != owner.uid { - warn!( - sandbox_owner = %owner.name, - owner_uid = %owner.uid, - actual_uid = %actual_uid, - "pod Sandbox ownerReference UID does not match live Sandbox CR" - ); - return Err(Status::permission_denied("sandbox owner UID mismatch")); - } - - let actual_sandbox_id = sandbox_cr - .metadata - .labels - .as_ref() - .and_then(|labels| labels.get(SANDBOX_ID_LABEL)) - .map(String::as_str) - .unwrap_or_default(); - if actual_sandbox_id != sandbox_id { - warn!( - sandbox_owner = %owner.name, - owner_uid = %owner.uid, - pod_sandbox_id = %sandbox_id, - cr_sandbox_id = %actual_sandbox_id, - "pod sandbox annotation does not match owning Sandbox CR label" - ); - return Err(Status::permission_denied("sandbox owner ID mismatch")); - } - - Ok(()) -} - -#[cfg(test)] -pub mod test_support { - use super::*; - use std::sync::Mutex; - - /// Fake resolver for unit tests. Returns the configured outcome on - /// every call and records the tokens it observed. - pub struct FakeResolver { - pub outcome: Result, Status>, - pub seen_tokens: Mutex>, - } - - impl FakeResolver { - pub fn returning(outcome: Result, Status>) -> Self { - Self { - outcome, - seen_tokens: Mutex::new(Vec::new()), - } - } - } - - #[async_trait] - impl K8sIdentityResolver for FakeResolver { - async fn resolve(&self, token: &str) -> Result, Status> { - self.seen_tokens.lock().unwrap().push(token.to_string()); - match &self.outcome { - Ok(opt) => Ok(opt.clone()), - Err(s) => Err(Status::new(s.code(), s.message())), - } - } - } -} - -#[cfg(test)] -mod tests { - use super::test_support::FakeResolver; - use super::*; - use k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference; - use std::collections::BTreeMap; - - fn bearer_headers(token: &str) -> http::HeaderMap { - let mut h = http::HeaderMap::new(); - h.insert( - "authorization", - http::HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), - ); - h - } - - fn kube_api_error(code: u16, message: &str) -> KubeError { - KubeError::Api(kube::core::ErrorResponse { - status: if code == 404 { - "404 Not Found".to_string() - } else { - "Failure".to_string() - }, - message: message.to_string(), - reason: "Failed to parse error data".to_string(), - code, - }) - } - - #[test] - fn sandbox_api_version_probe_retries_on_structured_and_raw_404() { - let structured = kube_api_error(404, "could not find the requested resource"); - assert!(should_try_next_sandbox_api_version(&structured)); - - let raw = kube_api_error(404, "404 page not found\n"); - assert!(should_try_next_sandbox_api_version(&raw)); - } - - #[test] - fn sandbox_api_version_probe_keeps_non_404_errors() { - let err = kube_api_error(403, "sandboxes.agents.x-k8s.io is forbidden"); - assert!(!should_try_next_sandbox_api_version(&err)); - } - - fn token_review_status( - authenticated: bool, - audiences: Vec<&str>, - username: &str, - extra: Vec<(&str, &str)>, - ) -> TokenReviewStatus { - TokenReviewStatus { - authenticated: Some(authenticated), - audiences: Some(audiences.into_iter().map(str::to_string).collect()), - error: None, - user: Some(UserInfo { - username: Some(username.to_string()), - uid: Some("sa-uid".to_string()), - groups: Some(vec![ - "system:serviceaccounts".to_string(), - "system:serviceaccounts:openshell".to_string(), - "system:authenticated".to_string(), - ]), - extra: Some( - extra - .into_iter() - .map(|(k, v)| (k.to_string(), vec![v.to_string()])) - .collect::>(), - ), - }), - } - } - - fn sandbox_owner(name: &str, uid: &str) -> OwnerReference { - sandbox_owner_with_api_version(SANDBOX_API_VERSION_FULL_V1BETA1, name, uid) - } - - fn sandbox_owner_with_api_version(api_version: &str, name: &str, uid: &str) -> OwnerReference { - OwnerReference { - api_version: api_version.to_string(), - block_owner_deletion: None, - controller: Some(true), - kind: SANDBOX_KIND.to_string(), - name: name.to_string(), - uid: uid.to_string(), - } - } - - fn pod_with_owner_refs(owner_references: Vec) -> Pod { - Pod { - metadata: ObjectMeta { - owner_references: Some(owner_references), - ..Default::default() - }, - ..Default::default() - } - } - - fn pod_with_sandbox_id(sandbox_id: Option<&str>) -> Pod { - Pod { - metadata: ObjectMeta { - annotations: sandbox_id.map(|id| { - BTreeMap::from([(SANDBOX_ID_ANNOTATION.to_string(), id.to_string())]) - }), - ..Default::default() - }, - ..Default::default() - } - } - - fn sandbox_cr(name: &str, uid: &str, sandbox_id: &str) -> DynamicObject { - let sandbox_gvk = - GroupVersionKind::gvk(SANDBOX_API_GROUP, SANDBOX_API_VERSION_V1BETA1, SANDBOX_KIND); - let sandbox_resource = ApiResource::from_gvk(&sandbox_gvk); - let mut cr = DynamicObject::new(name, &sandbox_resource); - cr.metadata.uid = Some(uid.to_string()); - cr.metadata.labels = Some(BTreeMap::from([( - SANDBOX_ID_LABEL.to_string(), - sandbox_id.to_string(), - )])); - cr - } - - fn exact_validator(ns: &str) -> NamespaceValidator { - NamespaceValidator::Exact(ns.to_string()) - } - - #[test] - fn token_review_identity_extracts_pod_binding() { - let status = token_review_status( - true, - vec!["openshell-gateway"], - "system:serviceaccount:openshell:default", - vec![ - (POD_NAME_EXTRA, "openshell-sandbox-a"), - (POD_UID_EXTRA, "uid-a"), - ], - ); - - let validator = exact_validator("openshell"); - let identity = token_review_identity(&status, "openshell-gateway", &validator, "default") - .unwrap() - .expect("authenticated token should resolve"); - - assert_eq!(identity.namespace, "openshell"); - assert_eq!(identity.pod_name, "openshell-sandbox-a"); - assert_eq!(identity.pod_uid, "uid-a"); - } - - #[test] - fn token_review_identity_returns_none_when_not_authenticated() { - let status = TokenReviewStatus { - authenticated: Some(false), - error: Some("invalid audience".to_string()), - ..Default::default() - }; - let validator = exact_validator("openshell"); - - assert!( - token_review_identity(&status, "openshell-gateway", &validator, "default") - .unwrap() - .is_none() - ); - } - - #[test] - fn token_review_identity_requires_expected_audience() { - let status = token_review_status( - true, - vec!["kubernetes.default.svc"], - "system:serviceaccount:openshell:default", - vec![ - (POD_NAME_EXTRA, "openshell-sandbox-a"), - (POD_UID_EXTRA, "uid-a"), - ], - ); - let validator = exact_validator("openshell"); - - let err = token_review_identity(&status, "openshell-gateway", &validator, "default") - .expect_err("wrong audience must fail closed"); - assert_eq!(err.code(), tonic::Code::Unauthenticated); - } - - #[test] - fn token_review_identity_requires_sandbox_namespace() { - let status = token_review_status( - true, - vec!["openshell-gateway"], - "system:serviceaccount:other:default", - vec![ - (POD_NAME_EXTRA, "openshell-sandbox-a"), - (POD_UID_EXTRA, "uid-a"), - ], - ); - let validator = exact_validator("openshell"); - - let err = token_review_identity(&status, "openshell-gateway", &validator, "default") - .expect_err("other namespace must be rejected"); - assert_eq!(err.code(), tonic::Code::PermissionDenied); - } - - #[test] - fn token_review_identity_requires_configured_service_account() { - let status = token_review_status( - true, - vec!["openshell-gateway"], - "system:serviceaccount:openshell:other", - vec![ - (POD_NAME_EXTRA, "openshell-sandbox-a"), - (POD_UID_EXTRA, "uid-a"), - ], - ); - let validator = exact_validator("openshell"); - - let err = token_review_identity(&status, "openshell-gateway", &validator, "default") - .expect_err("other service account must be rejected"); - assert_eq!(err.code(), tonic::Code::PermissionDenied); - } - - #[test] - fn token_review_identity_requires_pod_bound_extras() { - let status = token_review_status( - true, - vec!["openshell-gateway"], - "system:serviceaccount:openshell:default", - vec![], - ); - let validator = exact_validator("openshell"); - - let err = token_review_identity(&status, "openshell-gateway", &validator, "default") - .expect_err("non pod-bound tokens must be rejected"); - assert_eq!(err.code(), tonic::Code::PermissionDenied); - } - - #[test] - fn namespace_validator_exact_accepts_matching() { - let v = NamespaceValidator::Exact("openshell".to_string()); - assert!(v.accepts("openshell")); - assert!(!v.accepts("other")); - } - - #[test] - fn namespace_validator_prefix_accepts_managed_namespaces() { - let v = NamespaceValidator::Prefix("openshell-gw1-".to_string()); - assert!(v.accepts("openshell-gw1-workspace-a")); - assert!(v.accepts("openshell-gw1-default")); - assert!(!v.accepts("openshell-gw2-workspace-a")); - assert!(!v.accepts("other")); - } - - #[test] - fn namespace_validator_allowlist_accepts_known_namespaces() { - let al = OperatorNamespaceAllowlist::from_set(std::collections::BTreeSet::from([ - "ns-a".to_string(), - "ns-b".to_string(), - ])); - let v = NamespaceValidator::Allowlist(al); - assert!(v.accepts("ns-a")); - assert!(v.accepts("ns-b")); - assert!(!v.accepts("ns-c")); - } - - #[test] - fn token_review_identity_prefix_validator_accepts_managed_namespace() { - let status = token_review_status( - true, - vec!["openshell-gateway"], - "system:serviceaccount:openshell-gw1-workspace-a:default", - vec![ - (POD_NAME_EXTRA, "openshell-sandbox-a"), - (POD_UID_EXTRA, "uid-a"), - ], - ); - let validator = NamespaceValidator::Prefix("openshell-gw1-".to_string()); - - let identity = token_review_identity(&status, "openshell-gateway", &validator, "default") - .unwrap() - .expect("managed namespace token should resolve"); - assert_eq!(identity.namespace, "openshell-gw1-workspace-a"); - } - - #[test] - fn parse_sa_username_extracts_namespace_and_sa() { - let (ns, sa) = parse_sa_username("system:serviceaccount:openshell:default").unwrap(); - assert_eq!(ns, "openshell"); - assert_eq!(sa, "default"); - - assert!(parse_sa_username("system:node:nodename").is_none()); - assert!(parse_sa_username("system:serviceaccount::default").is_none()); - assert!(parse_sa_username("system:serviceaccount:ns:").is_none()); - } - - #[test] - fn pod_sandbox_id_requires_annotation() { - assert_eq!( - pod_sandbox_id(&pod_with_sandbox_id(Some("sandbox-id-a"))).unwrap(), - "sandbox-id-a" - ); - - let err = pod_sandbox_id(&pod_with_sandbox_id(None)) - .expect_err("missing sandbox-id annotation must fail"); - assert_eq!(err.code(), tonic::Code::PermissionDenied); - } - - #[test] - fn sandbox_owner_reference_extracts_controlling_sandbox_owner() { - let pod = pod_with_owner_refs(vec![sandbox_owner("sandbox-a", "cr-uid-a")]); - - let owner = sandbox_owner_reference(&pod).expect("expected Sandbox owner"); - - assert_eq!( - owner, - SandboxOwnerReference { - api_version: SANDBOX_API_VERSION_FULL_V1BETA1.to_string(), - name: "sandbox-a".to_string(), - uid: "cr-uid-a".to_string(), - } - ); - } - - #[test] - fn sandbox_owner_reference_accepts_v1alpha1_owner() { - let pod = pod_with_owner_refs(vec![sandbox_owner_with_api_version( - SANDBOX_API_VERSION_FULL_V1ALPHA1, - "sandbox-a", - "cr-uid-a", - )]); - - let owner = sandbox_owner_reference(&pod).expect("expected v1alpha1 Sandbox owner"); - - assert_eq!( - owner, - SandboxOwnerReference { - api_version: SANDBOX_API_VERSION_FULL_V1ALPHA1.to_string(), - name: "sandbox-a".to_string(), - uid: "cr-uid-a".to_string(), - } - ); - } - - #[test] - fn sandbox_owner_reference_rejects_missing_owner() { - let pod = pod_with_owner_refs(vec![]); - - let err = sandbox_owner_reference(&pod).expect_err("missing owner must fail"); - - assert_eq!(err.code(), tonic::Code::PermissionDenied); - } - - #[test] - fn sandbox_owner_reference_rejects_unsupported_sandbox_api_version() { - let pod = pod_with_owner_refs(vec![sandbox_owner_with_api_version( - "agents.x-k8s.io/v1", - "sandbox-a", - "cr-uid-a", - )]); - - let err = - sandbox_owner_reference(&pod).expect_err("unsupported apiVersion must fail closed"); - - assert_eq!(err.code(), tonic::Code::PermissionDenied); - } - - #[test] - fn sandbox_owner_reference_requires_controlling_owner() { - let mut owner = sandbox_owner("sandbox-a", "cr-uid-a"); - owner.controller = Some(false); - let pod = pod_with_owner_refs(vec![owner]); - - let err = sandbox_owner_reference(&pod).expect_err("non-controller owner must fail"); - - assert_eq!(err.code(), tonic::Code::PermissionDenied); - } - - #[test] - fn sandbox_owner_reference_rejects_ambiguous_sandbox_owners() { - let pod = pod_with_owner_refs(vec![ - sandbox_owner("sandbox-a", "cr-uid-a"), - sandbox_owner("sandbox-b", "cr-uid-b"), - ]); - - let err = sandbox_owner_reference(&pod).expect_err("multiple owners must fail"); - - assert_eq!(err.code(), tonic::Code::PermissionDenied); - } - - #[test] - fn validate_sandbox_owner_reference_requires_matching_cr_uid_and_label() { - let owner = SandboxOwnerReference { - api_version: SANDBOX_API_VERSION_FULL_V1BETA1.to_string(), - name: "sandbox-a".to_string(), - uid: "cr-uid-a".to_string(), - }; - let cr = sandbox_cr("sandbox-a", "cr-uid-a", "sandbox-id-a"); - validate_sandbox_owner_reference(&owner, "sandbox-id-a", &cr) - .expect("matching CR should be accepted"); - - let wrong_uid = sandbox_cr("sandbox-a", "cr-uid-b", "sandbox-id-a"); - let err = validate_sandbox_owner_reference(&owner, "sandbox-id-a", &wrong_uid) - .expect_err("wrong CR UID must fail"); - assert_eq!(err.code(), tonic::Code::PermissionDenied); - - let wrong_label = sandbox_cr("sandbox-a", "cr-uid-a", "sandbox-id-b"); - let err = validate_sandbox_owner_reference(&owner, "sandbox-id-a", &wrong_label) - .expect_err("wrong sandbox-id label must fail"); - assert_eq!(err.code(), tonic::Code::PermissionDenied); - } - - #[tokio::test] - async fn authenticates_on_issue_path_only() { - let resolved = ResolvedK8sIdentity { - sandbox_id: "sandbox-a".to_string(), - pod_name: "openshell-sandbox-a".to_string(), - pod_uid: "uid-a".to_string(), - }; - let fake = Arc::new(FakeResolver::returning(Ok(Some(resolved)))); - let auth = K8sServiceAccountAuthenticator::new(fake.clone()); - - let on_issue = auth - .authenticate(&bearer_headers("sa-jwt"), ISSUE_SANDBOX_TOKEN_PATH) - .await - .unwrap() - .expect("expected principal"); - match on_issue { - Principal::Sandbox(p) => { - assert_eq!(p.sandbox_id, "sandbox-a"); - assert!(matches!( - p.source, - SandboxIdentitySource::K8sServiceAccount { .. } - )); - } - _ => panic!("expected sandbox principal"), - } - - let off_issue = auth - .authenticate( - &bearer_headers("sa-jwt"), - "/openshell.v1.OpenShell/GetSandboxConfig", - ) - .await - .unwrap(); - assert!( - off_issue.is_none(), - "K8s SA authenticator must be scoped to IssueSandboxToken" - ); - assert_eq!( - fake.seen_tokens.lock().unwrap().len(), - 1, - "off-path call must not consult the apiserver" - ); - } - - #[tokio::test] - async fn missing_bearer_yields_none() { - let fake = Arc::new(FakeResolver::returning(Ok(None))); - let auth = K8sServiceAccountAuthenticator::new(fake); - let result = auth - .authenticate(&http::HeaderMap::new(), ISSUE_SANDBOX_TOKEN_PATH) - .await - .unwrap(); - assert!(result.is_none()); - } - - #[tokio::test] - async fn resolver_returning_none_falls_through() { - let fake = Arc::new(FakeResolver::returning(Ok(None))); - let auth = K8sServiceAccountAuthenticator::new(fake); - let result = auth - .authenticate( - &bearer_headers("not-a-real-sa-token"), - ISSUE_SANDBOX_TOKEN_PATH, - ) - .await - .unwrap(); - assert!(result.is_none(), "non-authenticating tokens fall through"); - } - - #[tokio::test] - async fn pod_without_annotation_is_rejected() { - let resolved = ResolvedK8sIdentity { - sandbox_id: String::new(), - pod_name: "stray-pod".to_string(), - pod_uid: "uid".to_string(), - }; - let fake = Arc::new(FakeResolver::returning(Ok(Some(resolved)))); - let auth = K8sServiceAccountAuthenticator::new(fake); - let err = auth - .authenticate(&bearer_headers("sa-jwt"), ISSUE_SANDBOX_TOKEN_PATH) - .await - .expect_err("unbound pod must be rejected"); - assert_eq!(err.code(), tonic::Code::PermissionDenied); - } - - #[tokio::test] - async fn resolver_error_propagates() { - let fake = Arc::new(FakeResolver::returning(Err(Status::unavailable( - "apiserver down", - )))); - let auth = K8sServiceAccountAuthenticator::new(fake); - let err = auth - .authenticate(&bearer_headers("sa-jwt"), ISSUE_SANDBOX_TOKEN_PATH) - .await - .expect_err("resolver error must propagate"); - assert_eq!(err.code(), tonic::Code::Unavailable); - } -} diff --git a/crates/openshell-server/src/auth/mod.rs b/crates/openshell-server/src/auth/mod.rs index bedbebe015..b39ff7bfa9 100644 --- a/crates/openshell-server/src/auth/mod.rs +++ b/crates/openshell-server/src/auth/mod.rs @@ -10,12 +10,12 @@ pub mod authenticator; pub mod authz; +pub mod compute_driver; pub mod descriptor_authz; pub mod extension_mint_limit; pub mod guard; mod http; pub mod identity; -pub mod k8s_sa; pub mod method_authz; pub mod oidc; pub mod principal; diff --git a/crates/openshell-server/src/auth/principal.rs b/crates/openshell-server/src/auth/principal.rs index 1d4cb7276c..9567cc62d2 100644 --- a/crates/openshell-server/src/auth/principal.rs +++ b/crates/openshell-server/src/auth/principal.rs @@ -70,7 +70,8 @@ pub enum SandboxIdentitySource { /// Per-sandbox client certificate. Reserved for channel-bound sandbox /// identity. BootstrapCert { fingerprint: String }, - /// K8s `ServiceAccount` token used to bootstrap a gateway-minted JWT - /// via `IssueSandboxToken`. Populated only on that one RPC path. - K8sServiceAccount { pod_name: String, pod_uid: String }, + /// Driver-native credential used to bootstrap a gateway-minted JWT via + /// `IssueSandboxToken`. The named compute driver authenticated only the + /// sandbox identity; the gateway still authorizes the exchange. + ComputeDriver { driver_name: String }, } diff --git a/crates/openshell-server/src/compute/driver_config.rs b/crates/openshell-server/src/compute/driver_config.rs index daf25af4c6..c25c63ded2 100644 --- a/crates/openshell-server/src/compute/driver_config.rs +++ b/crates/openshell-server/src/compute/driver_config.rs @@ -88,51 +88,6 @@ pub struct RemoteDriverConfig { pub socket_path: PathBuf, } -#[derive(Debug, Clone, Deserialize)] -#[serde(default)] -pub struct KubernetesSaBootstrapConfig { - pub namespace: String, - pub service_account_name: String, - pub workspace_mode: String, - pub gateway_id: String, -} - -impl Default for KubernetesSaBootstrapConfig { - fn default() -> Self { - Self { - namespace: "openshell".to_string(), - service_account_name: "default".to_string(), - workspace_mode: "shared".to_string(), - gateway_id: "openshell".to_string(), - } - } -} - -pub fn kubernetes_sa_bootstrap_config( - file: Option<&config_file::ConfigFile>, -) -> Result { - let Some(file) = file else { - return Err(Error::config( - "K8s ServiceAccount bootstrap requires [openshell.drivers.kubernetes] when sandbox JWT issuing is enabled in-cluster", - )); - }; - if !file.openshell.drivers.contains_key("kubernetes") { - return Err(Error::config( - "K8s ServiceAccount bootstrap requires [openshell.drivers.kubernetes] when sandbox JWT issuing is enabled in-cluster", - )); - } - let merged = config_file::driver_table( - "kubernetes", - &file.openshell.gateway, - file.openshell.drivers.get("kubernetes"), - ); - merged.try_into().map_err(|error| { - Error::config(format!( - "invalid Kubernetes ServiceAccount bootstrap config: {error}" - )) - }) -} - pub fn driver_config_from_context( context: DriverStartupContext<'_>, driver_name: &str, @@ -247,29 +202,6 @@ service_account_name = "sandbox-sa" ); } - #[test] - fn kubernetes_sa_bootstrap_uses_public_gateway_config() { - let file: config_file::ConfigFile = toml::from_str( - r#" -[openshell.gateway] -sandbox_namespace = "sandboxes" - -[openshell.drivers.kubernetes] -socket_path = "/run/openshell/kubernetes.sock" -workspace_mode = "managed" -gateway_id = "gateway-a" -service_account_name = "sandbox-sa" -"#, - ) - .expect("valid config"); - - let cfg = kubernetes_sa_bootstrap_config(Some(&file)).expect("bootstrap config"); - assert_eq!(cfg.namespace, "sandboxes"); - assert_eq!(cfg.workspace_mode, "managed"); - assert_eq!(cfg.gateway_id, "gateway-a"); - assert_eq!(cfg.service_account_name, "sandbox-sa"); - } - #[test] fn remote_driver_config_uses_endpoint_override_without_file() { let endpoint_overrides = diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 309b7ec768..3181f339a9 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -34,17 +34,18 @@ use openshell_core::ComputeDriverKind; #[cfg(target_os = "windows")] use openshell_core::proto::SandboxPolicy; use openshell_core::proto::compute::v1::{ - CreateSandboxRequest, DeleteSandboxRequest, DeleteWorkspaceRequest, DeleteWorkspaceResponse, - DriverCondition, DriverPlatformEvent, DriverResourceRequirements, DriverSandbox, - DriverSandboxSpec, DriverSandboxStatus, DriverSandboxTemplate, EnsureWorkspaceRequest, - EnsureWorkspaceResponse, GatewayListenerRequirement as ProtoGatewayListenerRequirement, - GetCapabilitiesRequest, GetGatewayListenerRequirementsRequest, - GetGatewayListenerRequirementsResponse, GetSandboxRequest, - GpuResourceRequirements as DriverGpuResourceRequirements, ListSandboxesRequest, - ResourceRequirements as DriverSandboxResourceRequirements, StartSandboxRequest, - StopSandboxRequest, ValidateSandboxCreateRequest, WatchSandboxesEvent, WatchSandboxesRequest, - compute_driver_client::ComputeDriverClient, compute_driver_server::ComputeDriver, - gateway_listener_requirement::Selector, watch_sandboxes_event, + AuthenticateSandboxRequest, CreateSandboxRequest, DeleteSandboxRequest, DeleteWorkspaceRequest, + DeleteWorkspaceResponse, DriverCondition, DriverPlatformEvent, DriverResourceRequirements, + DriverSandbox, DriverSandboxSpec, DriverSandboxStatus, DriverSandboxTemplate, + EnsureWorkspaceRequest, EnsureWorkspaceResponse, + GatewayListenerRequirement as ProtoGatewayListenerRequirement, GetCapabilitiesRequest, + GetGatewayListenerRequirementsRequest, GetGatewayListenerRequirementsResponse, + GetSandboxRequest, GpuResourceRequirements as DriverGpuResourceRequirements, + ListSandboxesRequest, ResourceRequirements as DriverSandboxResourceRequirements, + StartSandboxRequest, StopSandboxRequest, ValidateSandboxCreateRequest, WatchSandboxesEvent, + WatchSandboxesRequest, compute_driver_client::ComputeDriverClient, + compute_driver_server::ComputeDriver, gateway_listener_requirement::Selector, + watch_sandboxes_event, }; use openshell_core::proto::{ PlatformEvent, Sandbox, SandboxCondition, SandboxPhase, SandboxSpec, SandboxStatus, @@ -284,6 +285,8 @@ pub struct ComputeDriverInfoSnapshot { pub driver_version: String, /// Whether the driver asks the gateway to reconcile compute across restarts. pub gateway_manages_lifecycle: bool, + /// Whether the driver authenticates driver-native sandbox credentials. + pub supports_sandbox_authentication: bool, } /// Interval between store-vs-backend reconciliation sweeps. @@ -457,6 +460,17 @@ impl ComputeDriver for RemoteComputeDriver { client.get_capabilities(request).await } + async fn authenticate_sandbox( + &self, + request: Request, + ) -> Result< + tonic::Response, + Status, + > { + let mut client = self.client(); + client.authenticate_sandbox(request).await + } + async fn get_gateway_listener_requirements( &self, request: Request, @@ -629,6 +643,7 @@ impl ComputeRuntime { driver_name: capabilities.driver_name, driver_version: capabilities.driver_version, gateway_manages_lifecycle: capabilities.gateway_manages_lifecycle, + supports_sandbox_authentication: capabilities.supports_sandbox_authentication, }; let default_image = capabilities.default_image; let gateway_listener_requirements = match driver @@ -879,6 +894,33 @@ impl ComputeRuntime { self.driver_info.name.parse().ok() } + #[must_use] + pub fn selected_driver_name(&self) -> &str { + &self.driver_info.name + } + + #[must_use] + pub fn supports_sandbox_authentication(&self) -> bool { + self.driver_info.supports_sandbox_authentication + } + + pub(crate) async fn authenticate_sandbox(&self, credential: &str) -> Result { + if !self.supports_sandbox_authentication() { + return Err(Status::unimplemented( + "selected compute driver does not authenticate sandbox credentials", + )); + } + let request = AuthenticateSandboxRequest { + credential: credential.to_string(), + }; + self.driver + .call("driver.authenticate_sandbox", None, |driver| async move { + driver.authenticate_sandbox(Request::new(request)).await + }) + .await + .map(|response| response.into_inner().sandbox_id) + } + #[must_use] pub(crate) fn gateway_listener_requirements(&self) -> &[GatewayListenerRequirement] { &self.gateway_listener_requirements @@ -4347,9 +4389,10 @@ fn is_terminal_failure_reason(reason: &str) -> bool { } #[cfg(test)] -#[derive(Debug, Default)] +#[derive(Debug)] pub struct NoopTestDriver { workspace_delete_failures: std::sync::atomic::AtomicUsize, + sandbox_authentication: Option>, } #[cfg(test)] @@ -4357,6 +4400,31 @@ impl NoopTestDriver { pub fn failing_workspace_deletes(count: usize) -> Self { Self { workspace_delete_failures: std::sync::atomic::AtomicUsize::new(count), + sandbox_authentication: None, + } + } + + pub fn authenticating_sandbox(sandbox_id: impl Into) -> Self { + Self { + workspace_delete_failures: std::sync::atomic::AtomicUsize::new(0), + sandbox_authentication: Some(Ok(sandbox_id.into())), + } + } + + pub fn failing_sandbox_authentication(code: Code, message: impl Into) -> Self { + Self { + workspace_delete_failures: std::sync::atomic::AtomicUsize::new(0), + sandbox_authentication: Some(Err((code, message.into()))), + } + } +} + +#[cfg(test)] +impl Default for NoopTestDriver { + fn default() -> Self { + Self { + workspace_delete_failures: std::sync::atomic::AtomicUsize::new(0), + sandbox_authentication: None, } } } @@ -4364,6 +4432,26 @@ impl NoopTestDriver { #[cfg(test)] #[tonic::async_trait] impl ComputeDriver for NoopTestDriver { + async fn authenticate_sandbox( + &self, + _request: Request, + ) -> Result< + tonic::Response, + Status, + > { + match &self.sandbox_authentication { + Some(Ok(sandbox_id)) => Ok(tonic::Response::new( + openshell_core::proto::compute::v1::AuthenticateSandboxResponse { + sandbox_id: sandbox_id.clone(), + }, + )), + Some(Err((code, message))) => Err(Status::new(*code, message.clone())), + None => Err(Status::unimplemented( + "test driver does not authenticate sandbox credentials", + )), + } + } + type WatchSandboxesStream = DriverWatchStream; async fn get_capabilities( @@ -4377,6 +4465,7 @@ impl ComputeDriver for NoopTestDriver { driver_version: "test".to_string(), default_image: "openshell/sandbox:test".to_string(), gateway_manages_lifecycle: false, + supports_sandbox_authentication: self.sandbox_authentication.is_some(), }, )) } @@ -4511,6 +4600,7 @@ pub async fn new_test_runtime_with_driver( driver_name: &str, driver: Arc, ) -> ComputeRuntime { + let supports_sandbox_authentication = driver.sandbox_authentication.is_some(); ComputeRuntime { driver: TracedDriver::new(driver, "test".to_string()), driver_info: ComputeDriverInfoSnapshot { @@ -4518,6 +4608,7 @@ pub async fn new_test_runtime_with_driver( driver_name: driver_name.to_string(), driver_version: "test".to_string(), gateway_manages_lifecycle: false, + supports_sandbox_authentication, }, driver_process: None, default_image: "openshell/sandbox:test".to_string(), @@ -4672,6 +4763,18 @@ mod tests { #[tonic::async_trait] impl ComputeDriver for TestDriver { + async fn authenticate_sandbox( + &self, + _request: Request, + ) -> Result< + tonic::Response, + Status, + > { + Err(Status::unimplemented( + "test driver does not authenticate sandbox credentials", + )) + } + type WatchSandboxesStream = DriverWatchStream; async fn get_capabilities( @@ -4683,6 +4786,7 @@ mod tests { driver_version: "test".to_string(), default_image: "openshell/sandbox:test".to_string(), gateway_manages_lifecycle: false, + supports_sandbox_authentication: false, })) } @@ -4990,6 +5094,18 @@ mod tests { #[tonic::async_trait] impl ComputeDriver for ControlledDriver { + async fn authenticate_sandbox( + &self, + _request: Request, + ) -> Result< + tonic::Response, + Status, + > { + Err(Status::unimplemented( + "test driver does not authenticate sandbox credentials", + )) + } + type WatchSandboxesStream = DriverWatchStream; async fn get_capabilities( @@ -5001,6 +5117,7 @@ mod tests { driver_version: "test".to_string(), default_image: "openshell/sandbox:test".to_string(), gateway_manages_lifecycle: false, + supports_sandbox_authentication: false, })) } @@ -5204,6 +5321,7 @@ mod tests { driver_name: driver_name.to_string(), driver_version: "test".to_string(), gateway_manages_lifecycle: false, + supports_sandbox_authentication: false, }, driver_process: None, default_image: "openshell/sandbox:test".to_string(), @@ -6197,6 +6315,18 @@ mod tests { #[tonic::async_trait] impl ComputeDriver for FailingDriver { + async fn authenticate_sandbox( + &self, + _request: Request, + ) -> Result< + tonic::Response, + Status, + > { + Err(Status::unimplemented( + "test driver does not authenticate sandbox credentials", + )) + } + type WatchSandboxesStream = DriverWatchStream; async fn create_sandbox( diff --git a/crates/openshell-server/src/grpc/auth_rpc.rs b/crates/openshell-server/src/grpc/auth_rpc.rs index dca563a715..104d639584 100644 --- a/crates/openshell-server/src/grpc/auth_rpc.rs +++ b/crates/openshell-server/src/grpc/auth_rpc.rs @@ -5,7 +5,7 @@ //! //! Hosts authenticated identity RPCs: //! - `GetCurrentUser` — report the gateway-validated caller identity -//! - `IssueSandboxToken` — bootstrap exchange (K8s SA token → gateway JWT) +//! - `IssueSandboxToken` — driver-native bootstrap exchange → gateway JWT //! - `RefreshSandboxToken` — renew a still-valid gateway JWT //! //! Both end in a fresh gateway-signed JWT minted by @@ -71,13 +71,9 @@ pub async fn handle_issue_sandbox_token( )); }; - // Only the bootstrap K8s ServiceAccount path can mint a fresh gateway JWT - // via this RPC. Sandboxes already holding a gateway JWT use - // `RefreshSandboxToken` instead. - if !matches!( - sandbox.source, - SandboxIdentitySource::K8sServiceAccount { .. } - ) { + // Only a selected compute driver may establish the bootstrap sandbox + // identity. Sandboxes already holding a gateway JWT use refresh instead. + if !matches!(sandbox.source, SandboxIdentitySource::ComputeDriver { .. }) { debug!( sandbox_id = %sandbox.sandbox_id, "IssueSandboxToken rejected: non-bootstrap principal source" @@ -518,9 +514,8 @@ mod tests { req.extensions_mut() .insert(Principal::Sandbox(SandboxPrincipal { sandbox_id: "sandbox-a".to_string(), - source: SandboxIdentitySource::K8sServiceAccount { - pod_name: "pod-a".to_string(), - pod_uid: "uid-a".to_string(), + source: SandboxIdentitySource::ComputeDriver { + driver_name: "kubernetes".to_string(), }, trust_domain: Some("openshell".to_string()), })); @@ -541,9 +536,8 @@ mod tests { req.extensions_mut() .insert(Principal::Sandbox(SandboxPrincipal { sandbox_id: "sandbox-deleted".to_string(), - source: SandboxIdentitySource::K8sServiceAccount { - pod_name: "pod-a".to_string(), - pod_uid: "uid-a".to_string(), + source: SandboxIdentitySource::ComputeDriver { + driver_name: "kubernetes".to_string(), }, trust_domain: Some("openshell".to_string()), })); @@ -576,8 +570,8 @@ mod tests { } #[tokio::test] - async fn refresh_rejects_k8s_sa_principal() { - // K8s SA-bootstrap principals must use IssueSandboxToken, not + async fn refresh_rejects_compute_driver_principal() { + // Driver-bootstrap principals must use IssueSandboxToken, not // RefreshSandboxToken — the refresh path assumes a still-valid // gateway-minted JWT exists. use crate::auth::principal::SandboxIdentitySource; @@ -588,9 +582,8 @@ mod tests { req.extensions_mut() .insert(Principal::Sandbox(SandboxPrincipal { sandbox_id: "sandbox-a".to_string(), - source: SandboxIdentitySource::K8sServiceAccount { - pod_name: "pod-a".to_string(), - pod_uid: "uid-a".to_string(), + source: SandboxIdentitySource::ComputeDriver { + driver_name: "kubernetes".to_string(), }, trust_domain: Some("openshell".to_string()), })); diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 3dc2acec06..aafc0cd369 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -319,10 +319,9 @@ pub struct ServerState { /// presenting a freshly minted token are recognized. pub sandbox_jwt_authenticator: Option>, - /// Optional K8s `ServiceAccount` authenticator that backs the - /// `IssueSandboxToken` bootstrap path. Only present when the gateway - /// runs in-cluster. - pub k8s_sa_authenticator: Option>, + /// Optional selected-driver authenticator for the `IssueSandboxToken` + /// bootstrap path. + pub compute_driver_authenticator: Option>, /// Gateway-wide gRPC request rate limiter shared by every multiplex path. pub(crate) grpc_rate_limiter: Option, @@ -424,7 +423,7 @@ impl ServerState { oidc_cache, sandbox_jwt_issuer: None, sandbox_jwt_authenticator: None, - k8s_sa_authenticator: None, + compute_driver_authenticator: None, grpc_rate_limiter, gateway_interceptors: None, provider_profile_sources: @@ -593,7 +592,7 @@ pub(crate) async fn run_server( let supervisor_sessions = Arc::new(supervisor_session::SupervisorSessionRegistry::new()); let driver_startup = compute_driver_startup_context(&config, config_file.as_ref(), guest_tls.as_ref()); - let (compute, operator_allowlist) = build_compute_runtime( + let (compute, _operator_allowlist) = build_compute_runtime( &config, driver_startup, compute_driver, @@ -663,43 +662,14 @@ pub(crate) async fn run_server( spawn_gateway_extension_token_refresh(issuer, gateway_extension_credentials); } - // K8s ServiceAccount bootstrap authenticator. Only constructed when - // the gateway is running in-cluster (kubelet provides the API host - // env var) and has a sandbox JWT issuer to mint replacements against; - // outside the cluster we can't call the apiserver's TokenReview API, - // and without the issuer there's nothing to exchange the SA token for. - #[cfg(not(target_os = "windows"))] - if state.sandbox_jwt_issuer.is_some() && std::env::var_os("KUBERNETES_SERVICE_HOST").is_some() { - // Pod lookups and TokenReview identity checks must match the sandbox - // namespace and service account used by the Kubernetes driver. - let kubernetes_config = - compute::driver_config::kubernetes_sa_bootstrap_config(config_file.as_ref())?; - let sandbox_namespace = kubernetes_config.namespace.clone(); - let sandbox_service_account = kubernetes_config.service_account_name.clone(); - let namespace_validator = - kubernetes_namespace_validator(&kubernetes_config, &operator_allowlist)?; - match kube::Client::try_default().await { - Ok(client) => { - let resolver = Arc::new(auth::k8s_sa::LiveK8sResolver::new( - client, - namespace_validator, - "openshell-gateway".to_string(), - sandbox_service_account.clone(), - )); - let authenticator = auth::k8s_sa::K8sServiceAccountAuthenticator::new(resolver); - state.k8s_sa_authenticator = Some(Arc::new(authenticator)); - info!( - namespace = %sandbox_namespace, - service_account = %sandbox_service_account, - "K8s ServiceAccount bootstrap authenticator enabled" - ); - } - Err(e) => warn!( - error = %e, - "in-cluster K8s client construction failed; \ - K8s ServiceAccount bootstrap is disabled" - ), - } + if state.sandbox_jwt_issuer.is_some() && state.compute.supports_sandbox_authentication() { + state.compute_driver_authenticator = Some(Arc::new( + auth::compute_driver::ComputeDriverAuthenticator::new(state.compute.clone()), + )); + info!( + driver = state.compute.selected_driver_name(), + "compute-driver sandbox bootstrap authenticator enabled" + ); } let state = Arc::new(state); @@ -1071,51 +1041,6 @@ fn unsupported_builtin_compute_driver(driver: ComputeDriverKind) -> compute::Com type OperatorAllowlistArc = Option; pub use compute::{DriverWatchStream, SharedComputeDriver}; -fn kubernetes_namespace_validator( - config: &compute::driver_config::KubernetesSaBootstrapConfig, - operator_allowlist: &OperatorAllowlistArc, -) -> Result { - match config.workspace_mode.as_str() { - "shared" => Ok(auth::k8s_sa::NamespaceValidator::Exact( - config.namespace.clone(), - )), - "managed" => Ok(auth::k8s_sa::NamespaceValidator::Prefix(format!( - "openshell-{}-", - config.gateway_id - ))), - "operator" => operator_allowlist - .clone() - .map(auth::k8s_sa::NamespaceValidator::Allowlist) - .ok_or_else(|| { - Error::config("Kubernetes operator namespace allowlist was not initialized") - }), - mode => Err(Error::config(format!( - "invalid Kubernetes workspace_mode '{mode}' for ServiceAccount bootstrap" - ))), - } -} - -fn validate_remote_compute_driver_config( - name: &str, - file: Option<&config_file::ConfigFile>, -) -> Result<()> { - if name != "kubernetes" - || !file.is_some_and(|file| file.openshell.drivers.contains_key("kubernetes")) - { - return Ok(()); - } - - let config = compute::driver_config::kubernetes_sa_bootstrap_config(file)?; - if config.workspace_mode == "operator" { - return Err(Error::config( - "Kubernetes workspace_mode 'operator' requires an in-process Kubernetes driver; \ - external Kubernetes compute drivers do not support operator mode", - )); - } - - Ok(()) -} - /// Opaque result returned by a compiled compute-driver factory. pub struct ComputeDriverBuildOutput { runtime: ComputeRuntime, @@ -1644,7 +1569,6 @@ async fn build_compute_runtime( (output.runtime, output.operator_allowlist) } ConfiguredComputeDriver::Remote { name } => { - validate_remote_compute_driver_config(&name, driver_startup.file)?; let remote_config = compute::driver_config::remote_driver_config_from_context(driver_startup, &name)?; info!( @@ -1823,7 +1747,6 @@ mod tests { allow_plaintext_service_http, bind_gateway_listeners, classify_initial_bytes, is_benign_tls_handshake_failure, kubernetes_sandbox_jwt_expiry_disabled, mint_gateway_extension_credential, serve_gateway_listener, - validate_remote_compute_driver_config, }; use openshell_core::{ ComputeDriverKind, Config, @@ -1860,28 +1783,6 @@ mod tests { ) } - #[test] - fn external_kubernetes_operator_workspace_mode_is_rejected() { - let file: crate::config_file::ConfigFile = toml::from_str( - r#" -[openshell.drivers.kubernetes] -socket_path = "/run/openshell/kubernetes.sock" -workspace_mode = "operator" -operator_namespace_label = "openshell.ai/workspace=true" -"#, - ) - .expect("valid config"); - - let error = validate_remote_compute_driver_config("kubernetes", Some(&file)) - .expect_err("external operator mode must fail closed"); - - assert!( - error - .to_string() - .contains("external Kubernetes compute drivers do not support operator mode") - ); - } - #[test] fn plaintext_extension_endpoint_is_rejected_unless_explicitly_opted_out() { let issuer = extension_test_issuer(); diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index fc7963f2e1..3ef774e4cb 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -745,7 +745,7 @@ fn gateway_principal_fields(principal: &Principal) -> BTreeMap { match &sandbox.source { SandboxIdentitySource::BootstrapJwt { .. } => "bootstrap_jwt", SandboxIdentitySource::BootstrapCert { .. } => "bootstrap_cert", - SandboxIdentitySource::K8sServiceAccount { .. } => "k8s_service_account", + SandboxIdentitySource::ComputeDriver { .. } => "compute_driver", } .to_string(), ); @@ -976,10 +976,9 @@ where /// Assemble the authenticator chain for the gateway. /// /// Chain order (first-match-wins): -/// 1. `K8sServiceAccountAuthenticator` (path-scoped to `IssueSandboxToken`) -/// — exchanges a projected SA token for a `Principal::Sandbox` so the -/// `IssueSandboxToken` handler can mint a gateway JWT. No-op on every -/// other path; only present when the gateway runs in-cluster. +/// 1. `ComputeDriverAuthenticator` (path-scoped to `IssueSandboxToken`) +/// — delegates a driver-native credential and receives a sandbox identity +/// so the handler can mint a gateway JWT. No-op on every other path. /// 2. `SandboxJwtAuthenticator` — validates gateway-minted JWTs. Recognized /// via a distinctive `kid` so non-matching Bearer tokens fall through. /// 3. `OidcAuthenticator` — validates user Bearer tokens against the @@ -997,8 +996,8 @@ where /// to pass-through unless mTLS or local unauthenticated users are enabled. fn build_authenticator_chain(state: &ServerState) -> Option { let mut authenticators: Vec> = Vec::new(); - if let Some(k8s) = state.k8s_sa_authenticator.clone() { - authenticators.push(k8s); + if let Some(driver) = state.compute_driver_authenticator.clone() { + authenticators.push(driver); } if let Some(jwt) = state.sandbox_jwt_authenticator.clone() { authenticators.push(jwt); diff --git a/crates/openshell-server/src/test_support.rs b/crates/openshell-server/src/test_support.rs index 957db46fab..5f4063ae45 100644 --- a/crates/openshell-server/src/test_support.rs +++ b/crates/openshell-server/src/test_support.rs @@ -95,6 +95,7 @@ impl FakeComputeDriver { driver_version: "test".to_string(), default_image: "openshell/sandbox:test".to_string(), gateway_manages_lifecycle: false, + supports_sandbox_authentication: false, }, gateway_listener_requirements: Vec::new(), gateway_listener_requirements_supported: true, @@ -235,6 +236,16 @@ impl Stream for UnixIncoming { #[tonic::async_trait] impl ComputeDriver for FakeComputeDriver { + async fn authenticate_sandbox( + &self, + _request: Request, + ) -> Result, Status> + { + Err(Status::unimplemented( + "fake driver does not authenticate sandbox credentials", + )) + } + type WatchSandboxesStream = WatchStream; async fn get_capabilities( diff --git a/deploy/helm/openshell/ci/values-spire-stack.yaml b/deploy/helm/openshell/ci/values-spire-stack.yaml index b55f7cfc57..8a1e648829 100644 --- a/deploy/helm/openshell/ci/values-spire-stack.yaml +++ b/deploy/helm/openshell/ci/values-spire-stack.yaml @@ -15,7 +15,7 @@ spire-server: clusterSPIFFEIDs: openshell-sandboxes: enabled: true - spiffeIDTemplate: 'spiffe://{{ .TrustDomain }}/openshell/sandbox/{{ index .PodMeta.Annotations "openshell.io/sandbox-id" }}' + spiffeIDTemplate: 'spiffe://{{ .TrustDomain }}/openshell/sandbox/{{ index .PodMeta.Annotations "openshell.ai/sandbox-id" }}' namespaceSelector: matchLabels: kubernetes.io/metadata.name: openshell diff --git a/deploy/helm/openshell/templates/role.yaml b/deploy/helm/openshell/templates/role.yaml index af80989072..8def13f310 100644 --- a/deploy/helm/openshell/templates/role.yaml +++ b/deploy/helm/openshell/templates/role.yaml @@ -33,7 +33,7 @@ rules: - watch # Per-sandbox identity: TokenReview authenticates the projected token from # the configured sandbox service account, then the gateway resolves the - # returned pod name and UID to the pod's `openshell.io/sandbox-id` + # returned pod name and UID to the pod's `openshell.ai/sandbox-id` # annotation. patch is intentionally NOT granted — the annotation is set # once at pod create and must remain immutable for the lifetime of the # sandbox. diff --git a/docs/kubernetes/access-control.mdx b/docs/kubernetes/access-control.mdx index e3addad032..d6cb2140bd 100644 --- a/docs/kubernetes/access-control.mdx +++ b/docs/kubernetes/access-control.mdx @@ -21,11 +21,11 @@ For how the CLI resolves gateways and stores credentials, refer to [Gateway Auth ## Sandbox Supervisor Identity -Kubernetes sandbox supervisors authenticate back to the gateway as sandbox workloads. By default, the gateway mints its own sandbox JWTs and Kubernetes sandboxes bootstrap them with a projected ServiceAccount token. +Kubernetes sandbox supervisors authenticate back to the gateway as sandbox workloads. By default, the Kubernetes compute driver validates each projected ServiceAccount token and returns the authenticated sandbox ID to the gateway. The gateway verifies the sandbox still exists and mints its own sandbox JWT. Dynamic provider token grants can use SPIFFE without changing supervisor-to-gateway authentication. Set `server.providerTokenGrants.spiffe.enabled=true` to mount the SPIFFE CSI Workload API socket into gateway and sandbox pods while keeping the projected ServiceAccount token bootstrap and gateway-minted sandbox JWT path. -Provider token grants require a SPIFFE implementation such as SPIRE and identities for the gateway and sandbox pods. The repository's local SPIRE overlay assigns sandbox IDs from the pod's `openshell.io/sandbox-id` annotation, but the gateway validation path only requires the supervisor SVID to be valid and in the same SPIFFE trust domain as the gateway SVID. Provider profiles with `token_grant` metadata cause the sandbox supervisor to request JWT-SVIDs and exchange them for upstream OAuth2 access tokens. Token-exchange profiles also require a gateway SPIFFE identity because the gateway brokers the intermediate token exchange with its own JWT-SVID. +Provider token grants require a SPIFFE implementation such as SPIRE and identities for the gateway and sandbox pods. The repository's local SPIRE overlay assigns sandbox IDs from the pod's `openshell.ai/sandbox-id` annotation, but the gateway validation path only requires the supervisor SVID to be valid and in the same SPIFFE trust domain as the gateway SVID. Provider profiles with `token_grant` metadata cause the sandbox supervisor to request JWT-SVIDs and exchange them for upstream OAuth2 access tokens. Token-exchange profiles also require a gateway SPIFFE identity because the gateway brokers the intermediate token exchange with its own JWT-SVID. The gateway verifies supervisor JWT-SVIDs with JWT bundles fetched from the SPIFFE Workload API, so intermediate token exchange does not require gateway access to the SPIRE OIDC discovery endpoint or its TLS CA. diff --git a/docs/reference/gateway-auth.mdx b/docs/reference/gateway-auth.mdx index b5157b92a5..5969c43e64 100644 --- a/docs/reference/gateway-auth.mdx +++ b/docs/reference/gateway-auth.mdx @@ -191,7 +191,7 @@ to [Manage Workspaces and Access](/sandboxes/manage-workspaces). If `OPENSHELL_OIDC_SCOPES_CLAIM` is set, the gateway also enforces scopes. It accepts space-delimited scope strings such as `scope: "openid sandbox:read"` and JSON arrays such as `scp: ["sandbox:read"]`. Standard OIDC scopes such as `openid`, `profile`, `email`, and `offline_access` are ignored for authorization. `openshell:all` grants access to all scoped methods. -Supervisor-to-gateway RPCs do not use user OIDC tokens or mTLS user identity. Each sandbox supervisor presents a gateway-minted `Authorization: Bearer` token scoped to its sandbox ID. On Kubernetes, the gateway mints that token only after TokenReview validates the projected ServiceAccount token, the pod UID matches the live pod, and the pod's controlling `Sandbox` ownerReference matches the live Sandbox CR. Log upload, policy status, credential environment lookup, inference bundle lookup, and sandbox config sync run with sandbox-restricted scope, while CLI users authenticate with OIDC, edge auth, local mTLS user authentication, or an explicitly enabled unauthenticated local developer mode. `GetInferenceBundle` returns route material that includes provider credentials, so it requires a sandbox principal; user callers manage inference configuration through the user-facing inference APIs instead. +Supervisor-to-gateway RPCs do not use user OIDC tokens or mTLS user identity. Each sandbox supervisor presents a gateway-minted `Authorization: Bearer` token scoped to its sandbox ID. On Kubernetes, the Kubernetes compute driver validates the projected ServiceAccount token with TokenReview, verifies the live pod UID and controlling `Sandbox` ownerReference, and returns the authenticated sandbox ID to the gateway. The gateway verifies that sandbox still exists before minting its JWT. Log upload, policy status, credential environment lookup, inference bundle lookup, and sandbox config sync run with sandbox-restricted scope, while CLI users authenticate with OIDC, edge auth, local mTLS user authentication, or an explicitly enabled unauthenticated local developer mode. `GetInferenceBundle` returns route material that includes provider credentials, so it requires a sandbox principal; user callers manage inference configuration through the user-facing inference APIs instead. Re-authenticate an OIDC gateway with: diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 32d93aa2e6..22b64e9e97 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -258,6 +258,8 @@ gateway under the worktree-specific k3d cluster name; select it with `openshell gateway select `. The local Podman, Docker, and VM gateway tasks export to the forwarded receiver automatically. +In-process compute drivers read their backend-specific settings from `[openshell.drivers.]`. An external driver's gateway table supplies only its `socket_path`; configure the external driver process itself through that binary's flags or environment variables. A driver that advertises `supports_sandbox_authentication` may authenticate an opaque bootstrap credential through the compute-driver protocol. The gateway trusts the returned sandbox ID only for `IssueSandboxToken`, verifies that the sandbox still exists, and then mints its own JWT. The in-process Kubernetes driver reads `service_account_name`, `workspace_mode`, and namespace discovery from `[openshell.drivers.kubernetes]`; an external Kubernetes driver receives the equivalent values through its own CLI or environment contract. + ### Tuning This table decides whether and where to export. How the SDK exports is controlled by the standard OpenTelemetry environment variables, which the gateway reads through the SDK rather than mirroring as TOML keys: diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index e453f7f219..03da703ee4 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -375,7 +375,7 @@ For maintainer-level implementation details, refer to the [Kubernetes driver REA |---|---|---| | `compute_drivers = ["kubernetes"]` | Not applicable | Select the Kubernetes compute driver. | | `[openshell.drivers.kubernetes].namespace` | `server.sandboxNamespace` | Set the namespace for sandbox resources. The Helm chart defaults to the release namespace when left empty. | -| `service_account_name` | `sandboxServiceAccount.name` | Set the Kubernetes service account assigned to sandbox pods and accepted by the gateway TokenReview bootstrap path. The Helm chart creates a dedicated sandbox service account by default. | +| `service_account_name` | `sandboxServiceAccount.name` | Set the Kubernetes service account assigned to sandbox pods and accepted by the Kubernetes driver's TokenReview bootstrap path. The Helm chart creates a dedicated sandbox service account by default. | | `default_image` | `server.sandboxImage` | Set the default sandbox image. | | `image_pull_policy` | `server.sandboxImagePullPolicy` | Set the Kubernetes image pull policy for sandbox pods. | | `image_pull_secrets` | `server.sandboxImagePullSecrets` | Attach Kubernetes image-pull Secrets to sandbox pods. Managed mode copies these explicitly named Secrets from the configured source namespace into each workspace namespace. In shared and operator modes, the Secrets must already exist in the sandbox namespace. | diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index e26fafdd6a..eff5cfa5ea 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -23,6 +23,12 @@ service ComputeDriver { // Report driver capabilities and defaults. rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse); + // Authenticate a driver-native bootstrap credential and return the stable + // sandbox identity it represents. The gateway remains responsible for + // checking that the sandbox exists and minting gateway credentials. + rpc AuthenticateSandbox(AuthenticateSandboxRequest) + returns (AuthenticateSandboxResponse); + // Report additional gateway listeners required by this driver instance. // // A requirement is not authorization to expose the gateway. The gateway @@ -78,6 +84,18 @@ message GetCapabilitiesResponse { // Whether the gateway should stop running sandbox compute during graceful // shutdown and restart the retained running intent on startup. bool gateway_manages_lifecycle = 6; + // Whether AuthenticateSandbox is implemented by this driver. + bool supports_sandbox_authentication = 7; +} + +message AuthenticateSandboxRequest { + // Opaque credential whose format and verification are owned by the driver. + string credential = 1 [(openshell.options.v1.secret) = true]; +} + +message AuthenticateSandboxResponse { + // Stable gateway-assigned sandbox ID authenticated by the driver. + string sandbox_id = 1; } message GetGatewayListenerRequirementsRequest {} From 9d449ef21665d6ef89215cae46b4e2eae0a4962e Mon Sep 17 00:00:00 2001 From: natedemoss Date: Mon, 31 Aug 2026 18:37:20 +0000 Subject: [PATCH 054/505] fix(cli): reject out-of-range durations instead of overflowing (#3044) parse_duration_to_ms multiplied the parsed number by its unit multiplier without a range check. A large --since value on `openshell logs` overflows i64: a debug build panics with "attempt to multiply with overflow", and a release build wraps to a nonsense log cutoff. Use checked_mul and return a diagnostic naming the offending input. Every in-range result is unchanged, and the largest accepted duration still leaves the caller's `now_ms - dur_ms` well inside i64. Signed-off-by: Nathan DeMoss --- crates/openshell-cli/src/commands/common.rs | 22 ++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/openshell-cli/src/commands/common.rs b/crates/openshell-cli/src/commands/common.rs index 2af950cd01..03cb3260cf 100644 --- a/crates/openshell-cli/src/commands/common.rs +++ b/crates/openshell-cli/src/commands/common.rs @@ -743,7 +743,9 @@ pub fn parse_duration_to_ms(s: &str) -> Result { )); } }; - Ok(num * multiplier) + num.checked_mul(multiplier).ok_or_else(|| { + miette::miette!("duration out of range: {s} (must fit in milliseconds as a 64-bit integer)") + }) } // --------------------------------------------------------------------------- @@ -1073,6 +1075,24 @@ mod tests { assert!(err.to_string().contains("invalid duration")); } + #[test] + fn parse_duration_to_ms_rejects_out_of_range_values_without_overflowing() { + let err = parse_duration_to_ms("9223372036854775807h").expect_err("overflow should error"); + assert!(err.to_string().contains("duration out of range")); + + let err = parse_duration_to_ms("-9223372036854775808h").expect_err("overflow should error"); + assert!(err.to_string().contains("duration out of range")); + } + + #[test] + fn parse_duration_to_ms_accepts_the_largest_representable_duration() { + let max_hours = i64::MAX / 3_600_000; + assert_eq!( + parse_duration_to_ms(&format!("{max_hours}h")).expect("parse"), + max_hours * 3_600_000 + ); + } + #[test] fn platform_progress_events_update_borrowed_display_without_duplicate_steps() { let event = PlatformEvent { From 4eaa1051f86d6e09689a9cbf02da7b27d19cdf19 Mon Sep 17 00:00:00 2001 From: krishicks Date: Mon, 31 Aug 2026 18:39:53 +0000 Subject: [PATCH 055/505] docs: correct some comments and references (#3059) OCSF_VERSION has been 1.8.0; two descriptions still said 1.7.0. The default_gateway_id doc comment described a hostname fallback the implementation never had. A per-replica default would be wrong here: gateway_id is embedded in the JWT iss/aud, so it has to be stable across replicas. Signed-off-by: Kris Hicks --- AGENTS.md | 2 +- crates/openshell-core/src/config.rs | 4 ++-- crates/openshell-core/src/settings.rs | 2 +- crates/openshell-ocsf/Cargo.toml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8c88c94814..2f2a166f55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ These pipelines connect skills into end-to-end workflows. Individual skill files | `crates/openshell-router/` | Privacy router | Privacy-aware LLM routing | | `crates/openshell-bootstrap/` | Gateway metadata | Gateway registration metadata, auth token storage, mTLS bundle storage | | `crates/openshell-gateway-interceptors/` | Gateway interceptors | Intercepts and transforms configured gRPC requests at the gateway routing boundary | -| `crates/openshell-ocsf/` | OCSF logging | OCSF v1.7.0 event types, builders, shorthand/JSONL formatters, tracing layers | +| `crates/openshell-ocsf/` | OCSF logging | OCSF v1.8.0 event types, builders, shorthand/JSONL formatters, tracing layers | | `crates/openshell-otel/` | OpenTelemetry support | Shared OTLP trace provider, resource, and tracing-layer construction | | `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 | diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index e04e056033..a1f1a6c84f 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -1140,8 +1140,8 @@ pub struct GatewayJwtConfig { pub public_key_path: PathBuf, /// Path to the `kid` value (plain text, one line). pub kid_path: PathBuf, - /// Stable gateway identity embedded in `iss`/`aud`. Defaults to the - /// hostname-or-`openshell` placeholder if unset. + /// Stable gateway identity embedded in `iss`/`aud`. Defaults to + /// `openshell`. #[serde(default = "default_gateway_id")] pub gateway_id: String, /// Token lifetime in seconds. A value of 0 disables expiration and is diff --git a/crates/openshell-core/src/settings.rs b/crates/openshell-core/src/settings.rs index 156e4c3845..6407071581 100644 --- a/crates/openshell-core/src/settings.rs +++ b/crates/openshell-core/src/settings.rs @@ -115,7 +115,7 @@ pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[ kind: SettingValueKind::Bool, allowed_string_values: None, }, - // When true the sandbox writes OCSF v1.7.0 JSONL records to + // When true the sandbox writes OCSF v1.8.0 JSONL records to // `/var/log/openshell-ocsf*.log` (daily rotation, 3 files) in addition // to the human-readable shorthand log. Defaults to false (no JSONL written). RegisteredSetting { diff --git a/crates/openshell-ocsf/Cargo.toml b/crates/openshell-ocsf/Cargo.toml index 14cc93ba31..be91b1547a 100644 --- a/crates/openshell-ocsf/Cargo.toml +++ b/crates/openshell-ocsf/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "openshell-ocsf" -description = "OCSF v1.7.0 event types, formatters, and tracing layers for OpenShell sandbox logging" +description = "OCSF v1.8.0 event types, formatters, and tracing layers for OpenShell sandbox logging" version.workspace = true edition.workspace = true rust-version.workspace = true From 22073fcaaeeb6ccefeb3b45145c18de5d3a36b05 Mon Sep 17 00:00:00 2001 From: krishicks Date: Mon, 31 Aug 2026 23:02:30 +0000 Subject: [PATCH 056/505] test: backfill coverage for OCSF logging (#3065) Characterization tests for behavior that later work changes. No behavior change. - Pin the `openshell logs` output format, so a change to how that text is produced shows up as a diff rather than passing silently. - Cover the supervisor log push layer. - Assert OcsfEvent survives a JSON round trip. Serialization is written by hand and deserialization dispatches on class_uid into independent per-variant paths, so a field can serialize correctly and still be dropped or rejected on the way back in. Refs #1055 Signed-off-by: Kris Hicks --- crates/openshell-cli/src/run.rs | 116 ++++++++- crates/openshell-ocsf/tests/roundtrip.rs | 221 ++++++++++++++++++ .../src/log_push.rs | 217 ++++++++++++++++- 3 files changed, 545 insertions(+), 9 deletions(-) create mode 100644 crates/openshell-ocsf/tests/roundtrip.rs diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index b89f4f9371..bead4b4319 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -7117,6 +7117,10 @@ pub async fn sandbox_logs( } fn print_log_line(log: &openshell_core::proto::SandboxLogLine) { + println!("{}", format_log_line(log)); +} + +fn format_log_line(log: &openshell_core::proto::SandboxLogLine) -> String { let source = if log.source.is_empty() { "gateway" } else { @@ -7125,10 +7129,10 @@ fn print_log_line(log: &openshell_core::proto::SandboxLogLine) { let secs = log.timestamp_ms / 1000; let millis = log.timestamp_ms % 1000; if log.fields.is_empty() { - println!( + format!( "[{secs}.{millis:03}] [{source:<7}] [{:<5}] [{}] {}", log.level, log.target, log.message - ); + ) } else { let mut fields_str = String::new(); let mut entries: Vec<_> = log.fields.iter().collect(); @@ -7141,10 +7145,10 @@ fn print_log_line(log: &openshell_core::proto::SandboxLogLine) { fields_str.push('='); fields_str.push_str(v); } - println!( + format!( "[{secs}.{millis:03}] [{source:<7}] [{:<5}] [{}] {} {}", log.level, log.target, log.message, fields_str - ); + ) } } @@ -7509,7 +7513,7 @@ fn format_endpoint(endpoint: &openshell_core::proto::NetworkEndpoint) -> String mod tests { use super::{ PolicyGetView, ProvisioningStep, build_sandbox_resource_limits, - dockerfile_sources_supported_for_gateway, format_endpoint, + dockerfile_sources_supported_for_gateway, format_endpoint, format_log_line, format_provider_attachment_table, git_sync_files, has_main_process_result, inferred_provider_type, parse_cli_setting_value, parse_credential_expiry_cli_value, parse_credential_expiry_pairs, parse_credential_pairs, parse_driver_config_json, @@ -8974,4 +8978,106 @@ mod tests { assert!(json["revision"].is_null()); assert!(json["policy"].is_null()); } + + fn log_line( + level: &str, + target: &str, + message: &str, + source: &str, + fields: &[(&str, &str)], + ) -> openshell_core::proto::SandboxLogLine { + openshell_core::proto::SandboxLogLine { + sandbox_id: "sb-1".to_string(), + timestamp_ms: 1_234_567, + level: level.to_string(), + target: target.to_string(), + message: message.to_string(), + source: source.to_string(), + fields: fields + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(), + } + } + + #[test] + fn format_log_line_without_fields() { + let log = log_line("INFO", "openshell_server", "hello world", "sandbox", &[]); + assert_eq!( + format_log_line(&log), + "[1234.567] [sandbox] [INFO ] [openshell_server] hello world" + ); + } + + #[test] + fn format_log_line_empty_source_defaults_to_gateway() { + let log = log_line("WARN", "t", "msg", "", &[]); + assert_eq!( + format_log_line(&log), + "[1234.567] [gateway] [WARN ] [t] msg" + ); + } + + #[test] + fn format_log_line_pads_source_and_level() { + let log = log_line("OCSF", "ocsf", "NET:OPEN", "gateway", &[]); + assert_eq!( + format_log_line(&log), + "[1234.567] [gateway] [OCSF ] [ocsf] NET:OPEN" + ); + + let short_source = log_line("ERROR", "t", "m", "vm", &[]); + assert_eq!( + format_log_line(&short_source), + "[1234.567] [vm ] [ERROR] [t] m" + ); + } + + #[test] + fn format_log_line_sorts_fields_alphabetically() { + let log = log_line( + "INFO", + "ocsf", + "CONNECT", + "sandbox", + &[("dst_port", "443"), ("action", "allow"), ("dst_host", "x")], + ); + assert_eq!( + format_log_line(&log), + "[1234.567] [sandbox] [INFO ] [ocsf] CONNECT action=allow dst_host=x dst_port=443" + ); + } + + #[test] + fn format_log_line_keeps_empty_field_values() { + let log = log_line("INFO", "t", "m", "sandbox", &[("a", ""), ("b", "1")]); + assert_eq!( + format_log_line(&log), + "[1234.567] [sandbox] [INFO ] [t] m a= b=1" + ); + } + + #[test] + fn format_log_line_renders_empty_target_as_empty_brackets() { + let log = log_line("INFO", "", "m", "sandbox", &[]); + assert_eq!(format_log_line(&log), "[1234.567] [sandbox] [INFO ] [] m"); + } + + #[test] + fn format_log_line_zero_pads_millis() { + let mut log = log_line("INFO", "t", "m", "sandbox", &[]); + log.timestamp_ms = 1_000_007; + assert_eq!(format_log_line(&log), "[1000.007] [sandbox] [INFO ] [t] m"); + + log.timestamp_ms = 0; + assert_eq!(format_log_line(&log), "[0.000] [sandbox] [INFO ] [t] m"); + } + + #[test] + fn format_log_line_preserves_message_verbatim() { + // OCSF shorthand must pass through unchanged. + let message = "NET:OPEN [MED] DENIED /usr/bin/curl(4711) -> api.example.com:443"; + let log = log_line("OCSF", "ocsf", message, "sandbox", &[]); + assert!(format_log_line(&log).ends_with(message)); + } } diff --git a/crates/openshell-ocsf/tests/roundtrip.rs b/crates/openshell-ocsf/tests/roundtrip.rs new file mode 100644 index 0000000000..1c8a8bb307 --- /dev/null +++ b/crates/openshell-ocsf/tests/roundtrip.rs @@ -0,0 +1,221 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! `OcsfEvent` JSON round-trip fidelity. +//! +//! `Serialize` is written by hand and `Deserialize` dispatches on `class_uid` +//! into independent per-variant paths, so a field can serialize correctly and +//! still be dropped or rejected on the way back in. + +use std::net::{IpAddr, Ipv4Addr}; + +use openshell_ocsf::{ + ActionId, ActivityId, AiModel, ApiActivityBuilder, AppLifecycleBuilder, Attack, AuthTypeId, + BaseEventBuilder, ConfidenceId, ConfigStateChangeBuilder, ConnectionInfo, + DetectionFindingBuilder, DispositionId, Endpoint, FindingInfo, HttpActivityBuilder, HttpMethod, + HttpRequest, HttpResponse, LaunchTypeId, NetworkActivityBuilder, OcsfEvent, Process, + ProcessActivityBuilder, RiskLevelId, SandboxContext, SecurityLevelId, SeverityId, + SshActivityBuilder, StateId, StatusId, Url, +}; + +fn ctx() -> SandboxContext { + SandboxContext { + sandbox_id: "sb-7f3a9c2e14b8".to_string(), + sandbox_name: "agent-workspace-01".to_string(), + container_image: "ghcr.io/nvidia/openshell/sandbox:0.42.1".to_string(), + hostname: "openshell-sb-7f3a9c2e14b8".to_string(), + product_version: "0.42.1".to_string(), + proxy_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), + proxy_port: 8888, + } +} + +/// Assert an event survives `to_json -> from_value -> to_json` unchanged. +fn assert_round_trips(label: &str, event: &OcsfEvent) { + let json = event.to_json().expect("serialize"); + + let decoded: OcsfEvent = serde_json::from_value(json.clone()) + .unwrap_or_else(|e| panic!("{label}: deserialize failed: {e}\njson: {json}")); + + let reserialized = decoded.to_json().expect("re-serialize"); + assert_eq!( + json, reserialized, + "{label}: JSON changed across round trip" + ); +} + +#[test] +fn network_activity_round_trips() { + let event = NetworkActivityBuilder::new(&ctx()) + .activity(ActivityId::Open) + .activity_name("Open") + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain("api.example.com", 443)) + .src_endpoint_addr(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)), 51234) + .actor_process( + Process::new("/usr/bin/curl", 4711) + .with_cmd_line("curl -sS https://api.example.com") + .with_parent(Process::new("/bin/bash", 4700)), + ) + .firewall_rule("default-deny-egress", "opa") + .connection_info(ConnectionInfo::new("tcp")) + .observation_point(3) + .status_detail("blocked by egress policy") + .log_source("/dev/kmsg") + .message("CONNECT denied api.example.com:443") + .unmapped("policy_version", 42) + .unmapped("engine", "opa") + .build(); + + assert_round_trips("network_activity", &event); +} + +#[test] +fn http_activity_round_trips() { + let event = HttpActivityBuilder::new(&ctx()) + .activity(ActivityId::Open) + .action(ActionId::Allowed) + .disposition(DispositionId::Allowed) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .http_request(HttpRequest { + http_method: HttpMethod::Post, + url: Some(Url::new("https", "api.example.com", "/v1/items", 443)), + }) + .http_response(HttpResponse { code: 201 }) + .src_endpoint(Endpoint::from_ip( + IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)), + 51235, + )) + .dst_endpoint(Endpoint::from_domain("api.example.com", 443)) + .actor_process(Process::new("/usr/bin/node", 4712)) + .firewall_rule("allow-api", "l7") + .status_detail("allowed by L7 rule") + .message("POST /v1/items 201") + .unmapped("l7_decision", "allow") + .build(); + + assert_round_trips("http_activity", &event); +} + +#[test] +fn ssh_activity_round_trips() { + let event = SshActivityBuilder::new(&ctx()) + .activity(ActivityId::Open) + .auth_type(AuthTypeId::Other, "publickey-nonce") + .protocol_ver("SSH-2.0-OpenSSH_9.6") + .severity(SeverityId::Informational) + .status(StatusId::Success) + .src_endpoint_addr(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 9)), 44321) + .dst_endpoint(Endpoint::from_domain("sandbox.local", 22)) + .message("ssh session accepted") + .build(); + + assert_round_trips("ssh_activity", &event); +} + +#[test] +fn process_activity_round_trips() { + let event = ProcessActivityBuilder::new(&ctx()) + .activity(ActivityId::Open) + .process( + Process::new("/usr/bin/python3", 4713) + .with_cmd_line("python3 -m pytest") + .with_parent(Process::new("/bin/sh", 4701)), + ) + .launch_type(LaunchTypeId::Other) + .exit_code(0) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .message("process started") + .build(); + + assert_round_trips("process_activity", &event); +} + +#[test] +fn detection_finding_round_trips() { + let event = DetectionFindingBuilder::new(&ctx()) + .finding_info( + FindingInfo::new("finding-001", "Sandbox bypass attempt") + .with_desc("Process attempted to reach the host network directly"), + ) + .severity(SeverityId::High) + .is_alert(true) + .confidence(ConfidenceId::High) + .risk_level(RiskLevelId::High) + .attack(Attack::mitre( + "T1046", + "Network Service Discovery", + "TA0007", + "Discovery", + )) + .evidence_pairs(&[("dst_host", "169.254.169.254"), ("dst_port", "80")]) + .evidence("binary", "/usr/bin/curl") + .remediation("Tighten the egress policy for this sandbox") + .log_source("/dev/kmsg") + .message("bypass attempt detected") + .unmapped("detector", "bypass_monitor") + .build(); + + assert_round_trips("detection_finding", &event); +} + +#[test] +fn application_lifecycle_round_trips() { + let event = AppLifecycleBuilder::new(&ctx()) + .activity(ActivityId::Open) + .severity(SeverityId::Informational) + .message("supervisor started") + .build(); + + assert_round_trips("application_lifecycle", &event); +} + +#[test] +fn device_config_state_change_round_trips() { + let event = ConfigStateChangeBuilder::new(&ctx()) + .state(StateId::Other, "policy-loaded") + .security_level(SecurityLevelId::Secure) + .prev_security_level(SecurityLevelId::AtRisk) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .message("policy reloaded") + .unmapped("policy_version", 7) + .build(); + + assert_round_trips("device_config_state_change", &event); +} + +#[test] +fn api_activity_round_trips() { + let event = ApiActivityBuilder::new(&ctx(), "chat.completions") + .severity(SeverityId::Informational) + .status(StatusId::Success) + .http_request(HttpRequest { + http_method: HttpMethod::Post, + url: Some(Url::new("https", "inference.local", "/v1/chat", 443)), + }) + .dst_endpoint(Endpoint::from_domain("inference.local", 443)) + .ai_model(AiModel::new("llama-3.1-8b", "nvidia")) + .message("inference request routed") + .unmapped("route", "system") + .build(); + + assert_round_trips("api_activity", &event); +} + +#[test] +fn base_event_round_trips() { + let event = BaseEventBuilder::new(&ctx()) + .activity_name("custom activity") + .severity(SeverityId::Low) + .message("base event") + .unmapped("detail", "value") + .build(); + + assert_round_trips("base_event", &event); +} diff --git a/crates/openshell-supervisor-process/src/log_push.rs b/crates/openshell-supervisor-process/src/log_push.rs index 01e373ba8e..256fbff4a4 100644 --- a/crates/openshell-supervisor-process/src/log_push.rs +++ b/crates/openshell-supervisor-process/src/log_push.rs @@ -27,10 +27,7 @@ pub struct LogPushLayer { impl LogPushLayer { pub fn new(sandbox_id: String, tx: mpsc::Sender) -> Self { - let max_level = std::env::var("OPENSHELL_LOG_PUSH_LEVEL") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(tracing::Level::INFO); + let max_level = parse_max_level(std::env::var("OPENSHELL_LOG_PUSH_LEVEL").ok().as_deref()); Self { sandbox_id, tx, @@ -39,6 +36,12 @@ impl LogPushLayer { } } +/// Resolve the push level filter, defaulting to `INFO` when unset or unparseable. +fn parse_max_level(raw: Option<&str>) -> tracing::Level { + raw.and_then(|s| s.parse().ok()) + .unwrap_or(tracing::Level::INFO) +} + impl Layer for LogPushLayer { fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { let meta = event.metadata(); @@ -309,3 +312,209 @@ impl tracing::field::Visit for LogVisitor { } } } + +#[cfg(test)] +mod tests { + use super::*; + use openshell_ocsf::{ + ActionId, ActivityId, DispositionId, Endpoint, NetworkActivityBuilder, SandboxContext, + SeverityId, StatusId, ocsf_emit, + }; + use tracing_subscriber::layer::SubscriberExt; + + fn ocsf_ctx() -> SandboxContext { + SandboxContext { + sandbox_id: "sb-test".to_string(), + sandbox_name: "test-sandbox".to_string(), + container_image: "openshell/sandbox:test".to_string(), + hostname: "test-host".to_string(), + product_version: "0.0.0".to_string(), + proxy_ip: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + proxy_port: 8888, + } + } + + /// Capture lines emitted by `f` with an `INFO` level filter. + fn capture(capacity: usize, f: impl FnOnce()) -> Vec { + let (tx, mut rx) = mpsc::channel::(capacity); + let layer = LogPushLayer { + sandbox_id: "sb-test".to_string(), + tx, + max_level: tracing::Level::INFO, + }; + let subscriber = tracing_subscriber::registry().with(layer); + tracing::subscriber::with_default(subscriber, f); + + let mut out = Vec::new(); + while let Ok(line) = rx.try_recv() { + out.push(line); + } + out + } + + #[test] + fn ocsf_events_push_shorthand_with_ocsf_level_and_no_fields() { + let event = NetworkActivityBuilder::new(&ocsf_ctx()) + .activity(ActivityId::Open) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain("blocked.example.com", 443)) + .message("CONNECT denied blocked.example.com:443".to_string()) + .build(); + let expected_shorthand = event.format_shorthand(); + + let lines = capture(16, || ocsf_emit!(event)); + + assert_eq!(lines.len(), 1); + let line = &lines[0]; + assert_eq!(line.level, "OCSF"); + assert_eq!(line.target, openshell_ocsf::OCSF_TARGET); + assert_eq!(line.source, "sandbox"); + assert_eq!(line.sandbox_id, "sb-test"); + assert_eq!(line.message, expected_shorthand); + assert!(line.fields.is_empty()); + assert!(line.timestamp_ms > 0); + } + + #[test] + fn non_ocsf_events_use_visitor_extraction() { + let lines = capture(16, || { + tracing::info!(target: "test_target", answer = 42, name = "widget", "hello"); + }); + + assert_eq!(lines.len(), 1); + let line = &lines[0]; + assert_eq!(line.level, "INFO"); + assert_eq!(line.target, "test_target"); + assert_eq!(line.source, "sandbox"); + assert_eq!(line.message, "hello"); + assert_eq!(line.fields.get("name").map(String::as_str), Some("widget")); + assert_eq!(line.fields.get("answer").map(String::as_str), Some("42")); + assert!(!line.fields.contains_key("message")); + } + + #[test] + fn events_without_a_message_field_fall_back_to_the_event_name() { + let lines = capture(16, || { + tracing::info!(target: "test_target", answer = 1); + }); + + assert_eq!(lines.len(), 1); + assert!( + lines[0].message.starts_with("event "), + "expected event-name fallback, got {:?}", + lines[0].message + ); + } + + #[test] + fn events_below_the_max_level_are_filtered() { + let lines = capture(16, || { + tracing::debug!(target: "test_target", "debug line"); + tracing::trace!(target: "test_target", "trace line"); + tracing::info!(target: "test_target", "info line"); + tracing::warn!(target: "test_target", "warn line"); + }); + + let messages: Vec<_> = lines.iter().map(|l| l.message.as_str()).collect(); + assert_eq!(messages, vec!["info line", "warn line"]); + } + + #[test] + fn lines_are_dropped_when_the_channel_is_full() { + let lines = capture(2, || { + for i in 0..3 { + tracing::info!(target: "test_target", "line {i}"); + } + }); + + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].message, "line 0"); + assert_eq!(lines[1].message, "line 1"); + } + + #[test] + fn parse_max_level_defaults_to_info() { + assert_eq!(parse_max_level(None), tracing::Level::INFO); + assert_eq!(parse_max_level(Some("not-a-level")), tracing::Level::INFO); + assert_eq!(parse_max_level(Some("debug")), tracing::Level::DEBUG); + assert_eq!(parse_max_level(Some("TRACE")), tracing::Level::TRACE); + assert_eq!(parse_max_level(Some("warn")), tracing::Level::WARN); + } + + fn test_line(message: &str) -> SandboxLogLine { + SandboxLogLine { + sandbox_id: "sb-test".to_string(), + timestamp_ms: 1, + level: "INFO".to_string(), + target: "t".to_string(), + message: message.to_string(), + source: "sandbox".to_string(), + fields: std::collections::HashMap::new(), + } + } + + #[tokio::test] + async fn drain_during_backoff_buffers_up_to_the_cap_and_drops_the_rest() { + let (tx, mut rx) = mpsc::channel::(1024); + for i in 0..250 { + tx.try_send(test_line(&format!("line {i}"))).unwrap(); + } + drop(tx); + + let mut batch = Vec::new(); + drain_during_backoff(&mut rx, &mut batch, tokio::time::Duration::from_secs(30)).await; + + assert_eq!(batch.len(), 200); + assert_eq!(batch[0].message, "line 0"); + assert_eq!(batch[199].message, "line 199"); + } + + #[tokio::test] + async fn drain_during_backoff_preserves_an_existing_batch() { + let (tx, mut rx) = mpsc::channel::(16); + tx.try_send(test_line("new")).unwrap(); + drop(tx); + + let mut batch = vec![test_line("buffered")]; + drain_during_backoff(&mut rx, &mut batch, tokio::time::Duration::from_secs(30)).await; + + assert_eq!(batch.len(), 2); + assert_eq!(batch[0].message, "buffered"); + assert_eq!(batch[1].message, "new"); + } + + #[tokio::test] + async fn drain_during_backoff_returns_early_when_the_channel_closes() { + let (tx, mut rx) = mpsc::channel::(16); + tx.try_send(test_line("last")).unwrap(); + drop(tx); + + let mut batch = Vec::new(); + tokio::time::timeout( + tokio::time::Duration::from_secs(5), + drain_during_backoff(&mut rx, &mut batch, tokio::time::Duration::from_secs(30)), + ) + .await + .expect("closed channel should end the backoff drain"); + + assert_eq!(batch.len(), 1); + } + + #[test] + fn log_visitor_into_parts_uses_the_fallback_only_without_a_message() { + let visitor = LogVisitor { + message: Some("explicit".to_string()), + fields: vec![("k".to_string(), "v".to_string())], + }; + let (msg, fields) = visitor.into_parts("fallback"); + assert_eq!(msg, "explicit"); + assert_eq!(fields.get("k").map(String::as_str), Some("v")); + + let (msg, fields) = LogVisitor::default().into_parts("fallback"); + assert_eq!(msg, "fallback"); + assert!(fields.is_empty()); + } +} From 5c541e1e0e0cfed3a631ee438916cbc71e7b1c6b Mon Sep 17 00:00:00 2001 From: "John T. Myers" <9696606+johntmyers@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:40:15 +0000 Subject: [PATCH 057/505] fix(kubernetes): prevent stop-start relay race (#3064) Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- crates/openshell-driver-kubernetes/README.md | 6 +- .../openshell-driver-kubernetes/src/driver.rs | 70 ++++++++++++++++--- crates/openshell-server/src/compute/mod.rs | 15 ++++ docs/reference/sandbox-compute-drivers.mdx | 4 +- 4 files changed, 81 insertions(+), 14 deletions(-) diff --git a/crates/openshell-driver-kubernetes/README.md b/crates/openshell-driver-kubernetes/README.md index 0d0186ae11..02dcfe5e87 100644 --- a/crates/openshell-driver-kubernetes/README.md +++ b/crates/openshell-driver-kubernetes/README.md @@ -79,9 +79,9 @@ its pod. The driver sets `spec.operatingMode: Suspended` for `v1beta1` or `spec.replicas: 0` for `v1alpha1`. Start sets `Running` or one replica for the same resource, so the replacement pod mounts the existing claim. Delete is the only lifecycle operation that removes the Sandbox resource and its owned -storage. The driver confirms the stop from the published `Suspended` -condition when available. Legacy `v1alpha1` controllers omit a zero replica -count from status, so the driver confirms that their backing pod is gone. +storage. The driver confirms the stop from both the published `Suspended` +condition and deletion of the backing pod. Legacy `v1alpha1` controllers omit +a usable stopped condition, so pod deletion alone confirms their stop. The workspace PVC size defaults to `workspace_default_storage_size`. Set `workspace_storage_class` to pin the PVC to a specific `StorageClass`; an empty diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index f4addd384a..484cd07708 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -1623,8 +1623,7 @@ impl KubernetesComputeDriver { let (agent_sandbox_api, kube_name, pod_name, namespace, stop_timeout) = self .patch_sandbox_operating_state(sandbox_id, false) .await?; - let legacy_pod_api = (agent_sandbox_api.resource.version == SANDBOX_VERSION_V1ALPHA1) - .then(|| Api::::namespaced(self.client.clone(), &namespace)); + let pod_api = Api::::namespaced(self.client.clone(), &namespace); let deadline = tokio::time::Instant::now() + stop_timeout; let mut poll_interval = STOP_INITIAL_POLL_INTERVAL; @@ -1649,17 +1648,18 @@ impl KubernetesComputeDriver { )) })? .map_err(KubernetesDriverError::from_kube)?; - if kubernetes_sandbox_has_stopped_condition(&object) { - return Ok(()); - } if let Some(error) = kubernetes_sandbox_stop_failure(&object) { return Err(KubernetesDriverError::Message(error)); } - if let Some(pod_api) = legacy_pod_api.as_ref() - && kubernetes_sandbox_pod_is_gone(pod_api, &pod_name, deadline) - .await - .map_err(KubernetesDriverError::Message)? - { + let pod_is_gone = kubernetes_sandbox_pod_is_gone(&pod_api, &pod_name, deadline) + .await + .map_err(KubernetesDriverError::Message)?; + let stop_is_complete = kubernetes_sandbox_stop_is_complete( + &agent_sandbox_api.resource.version, + &object, + pod_is_gone, + ); + if stop_is_complete { return Ok(()); } let now = tokio::time::Instant::now(); @@ -4560,6 +4560,19 @@ fn kubernetes_sandbox_has_stopped_condition(obj: &DynamicObject) -> bool { }) } +fn kubernetes_sandbox_stop_is_complete( + api_version: &str, + obj: &DynamicObject, + pod_is_gone: bool, +) -> bool { + if api_version == SANDBOX_VERSION_V1ALPHA1 { + // v1alpha1 omits a usable stopped condition. + pod_is_gone + } else { + kubernetes_sandbox_has_stopped_condition(obj) && pod_is_gone + } +} + fn kubernetes_sandbox_stop_failure(obj: &DynamicObject) -> Option { obj.data .get("status")? @@ -5392,6 +5405,43 @@ mod tests { assert!(kubernetes_sandbox_has_stopped_condition(&sandbox)); } + #[test] + fn beta_stop_requires_suspended_condition_and_deleted_pod() { + let resource = ApiResource::from_gvk(&GroupVersionKind::gvk( + SANDBOX_GROUP, + SANDBOX_VERSION_V1BETA1, + SANDBOX_KIND, + )); + let mut sandbox = DynamicObject::new("sandbox", &resource); + + assert!(!kubernetes_sandbox_stop_is_complete( + SANDBOX_VERSION_V1BETA1, + &sandbox, + true, + )); + + sandbox.data = serde_json::json!({ + "status": { + "conditions": [{"type": "Suspended", "status": "True"}] + } + }); + assert!(!kubernetes_sandbox_stop_is_complete( + SANDBOX_VERSION_V1BETA1, + &sandbox, + false, + )); + assert!(kubernetes_sandbox_stop_is_complete( + SANDBOX_VERSION_V1BETA1, + &sandbox, + true, + )); + assert!(kubernetes_sandbox_stop_is_complete( + SANDBOX_VERSION_V1ALPHA1, + &DynamicObject::new("sandbox", &resource), + true, + )); + } + #[test] fn stop_failure_only_rejects_terminal_suspension_condition() { let resource = ApiResource::from_gvk(&GroupVersionKind::gvk( diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 3181f339a9..2dd1f7cd2e 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -3183,6 +3183,17 @@ impl ComputeRuntime { else { return Ok(()); }; + let phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown); + if matches!( + phase, + SandboxPhase::Deleting | SandboxPhase::Stopping | SandboxPhase::Stopped + ) { + // Lifecycle shutdown intentionally discards the canonical process + // result. Finalization must still acknowledge that discarded + // result so the supervisor can exit before the compute backend's + // termination grace period expires. + return Ok(()); + } let Some(status) = sandbox.status.as_ref() else { return Err("main-process exit has not been reported".to_string()); }; @@ -5600,6 +5611,10 @@ mod tests { .main_process_exited(id, "instance-1", 143) .await .unwrap(); + runtime + .finalize_main_process_exit(id, "instance-1") + .await + .expect("intentional shutdown finalization should be acknowledged"); let stored = runtime .store diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index 03da703ee4..82aa68a956 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -436,7 +436,9 @@ The Kubernetes driver creates namespaced `agents.x-k8s.io` `Sandbox` resources f Stop patches the existing resource rather than deleting it. For `v1beta1`, the driver sets `spec.operatingMode` to `Suspended` or `Running`. For `v1alpha1`, it sets `spec.replicas` to `0` or `1`. The Sandbox resource and its -workspace PVC keep their identity across both operations. +workspace PVC keep their identity across both operations. Stop returns only +after the controller reports suspension and deletes the old pod, so an +immediate start cannot race the prior pod's termination. If Agent Sandbox is upgraded in place, restart the OpenShell gateway after the controller and CRD rollout completes so the gateway can detect the served API versions again. From 8ffc6c2a13cd482dea9e2195058309344c274756 Mon Sep 17 00:00:00 2001 From: "John T. Myers" <9696606+johntmyers@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:04:13 +0000 Subject: [PATCH 058/505] fix(policy): compose advisor proposals with provider endpoints (#2935) Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- architecture/security-policy.md | 16 +++++ crates/openshell-policy/src/ambiguity.rs | 15 ++-- crates/openshell-policy/src/merge.rs | 44 ++++++++++-- crates/openshell-server/src/grpc/policy.rs | 72 ++++++++++++++++--- .../src/policy_local.rs | 1 + docs/sandboxes/policies.mdx | 2 + docs/sandboxes/policy-advisor.mdx | 21 +++++- 7 files changed, 151 insertions(+), 20 deletions(-) diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 3f582eb25d..9203ba3178 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -268,6 +268,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`. diff --git a/crates/openshell-policy/src/ambiguity.rs b/crates/openshell-policy/src/ambiguity.rs index 5c2b9d9177..2c0b1b1944 100644 --- a/crates/openshell-policy/src/ambiguity.rs +++ b/crates/openshell-policy/src/ambiguity.rs @@ -181,12 +181,6 @@ fn connection_conflicts(left: &NetworkEndpoint, right: &NetworkEndpoint) -> Vec< &normalized_strings(&left.allowed_ips), &normalized_strings(&right.allowed_ips), ); - push_conflict( - &mut conflicts, - "advisor_proposed", - &left.advisor_proposed, - &right.advisor_proposed, - ); conflicts } @@ -787,6 +781,15 @@ mod tests { assert!(find_endpoint_ambiguities(&policy_with(left, right)).is_empty()); } + #[test] + fn advisor_provenance_does_not_make_endpoints_ambiguous() { + let explicit = endpoint("api.example.com", 443); + let mut proposed = explicit.clone(); + proposed.advisor_proposed = true; + + assert!(find_endpoint_ambiguities(&policy_with(explicit, proposed)).is_empty()); + } + #[test] fn plain_l4_endpoint_does_not_compete_with_l7_endpoint_metadata() { let left = endpoint("api.example.com", 443); diff --git a/crates/openshell-policy/src/merge.rs b/crates/openshell-policy/src/merge.rs index 12026bbea3..a87defa340 100644 --- a/crates/openshell-policy/src/merge.rs +++ b/crates/openshell-policy/src/merge.rs @@ -632,7 +632,8 @@ fn endpoint_attributes_cover(loaded: &NetworkEndpoint, proposed: &NetworkEndpoin return false; } - // Widened fields (list appends and `|=` flags) use containment: merging + // Widened fields (list appends and authorization flags) use containment: + // merging // into an endpoint that already carries them leaves the loaded copy a // superset of the proposal, so equality would report "not covered" for a // proposal that did land. @@ -648,7 +649,6 @@ fn endpoint_attributes_cover(loaded: &NetworkEndpoint, proposed: &NetworkEndpoin loaded.request_body_credential_rewrite, proposed.request_body_credential_rewrite, ) - && flag_covers(loaded.advisor_proposed, proposed.advisor_proposed) // Fields the merge neither widens nor retains: it drops them entirely. // An unset proposal value asks for nothing and is satisfied by whatever // is loaded; a set value that differs was dropped, so the proposal is @@ -1374,7 +1374,11 @@ fn merge_endpoint( existing.websocket_credential_rewrite |= incoming.websocket_credential_rewrite; existing.request_body_credential_rewrite |= incoming.request_body_credential_rewrite; existing.allow_uninspected_credentials |= incoming.allow_uninspected_credentials; - existing.advisor_proposed |= incoming.advisor_proposed; + // Provenance is not an authorization bit. If either declaration came + // directly from a user or provider, keep the endpoint explicit. This + // mirrors binary provenance and prevents an advisor overlay from tainting + // an already explicit endpoint for exact-host SSRF evaluation. + existing.advisor_proposed &= incoming.advisor_proposed; normalize_endpoint(existing); Ok(()) } @@ -3025,7 +3029,7 @@ mod tests { } #[test] - fn policy_coverage_requires_endpoint_advisor_provenance_to_be_loaded() { + fn policy_coverage_ignores_endpoint_advisor_provenance() { let loaded_endpoint = endpoint("api.example.com", 443); let mut proposed_endpoint = loaded_endpoint.clone(); proposed_endpoint.advisor_proposed = true; @@ -3036,7 +3040,37 @@ mod tests { let proposed = rule_with_authorizations("proposed", vec![proposed_endpoint], &["/usr/bin/client"]); - assert!(!policy_covers_rule(&loaded, &proposed)); + assert!(policy_covers_rule(&loaded, &proposed)); + } + + #[test] + fn explicit_endpoint_provenance_wins_in_either_merge_order() { + let explicit_endpoint = endpoint("api.example.com", 443); + let mut proposed_endpoint = explicit_endpoint.clone(); + proposed_endpoint.advisor_proposed = true; + + for (existing_endpoint, incoming_endpoint) in [ + (explicit_endpoint.clone(), proposed_endpoint.clone()), + (proposed_endpoint, explicit_endpoint), + ] { + let incoming = + rule_with_authorizations("api", vec![incoming_endpoint], &["/usr/bin/client"]); + let merged = merge_policy( + policy_with_rule( + "api", + rule_with_authorizations("api", vec![existing_endpoint], &["/usr/bin/client"]), + ), + &[PolicyMergeOp::AddRule { + rule_name: "api".to_string(), + rule: incoming.clone(), + }], + ) + .expect("compatible explicit and advisor rules should merge"); + + let endpoint = &merged.policy.network_policies["api"].endpoints[0]; + assert!(!endpoint.advisor_proposed); + assert!(policy_covers_rule(&merged.policy, &incoming)); + } } #[test] diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 0bda93a15f..6db1d12cc5 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -11378,7 +11378,7 @@ mod tests { } #[tokio::test] - async fn approve_all_skips_later_batch_conflict_and_applies_compatible_prefix() { + async fn approve_all_skips_later_tls_conflict_and_applies_compatible_prefix() { let state = test_server_state().await; let sandbox_id = "sb-approve-all-conflict"; let sandbox_name = "approve-all-conflict"; @@ -11425,6 +11425,7 @@ mod tests { endpoints: vec![NetworkEndpoint { host: "shared.example.com".to_string(), port: 443, + tls: "skip".to_string(), advisor_proposed: true, ..Default::default() }], @@ -14400,10 +14401,11 @@ mod tests { .await .unwrap(); + let sandbox_id = "sb-agent-provider-effective-policy"; let sandbox_name = "agent-provider-effective-policy".to_string(); let mut sandbox = Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: "sb-agent-provider-effective-policy".to_string(), + id: sandbox_id.to_string(), name: sandbox_name.clone(), created_at_ms: 1_000_000, labels: HashMap::new(), @@ -14429,6 +14431,7 @@ mod tests { sandbox.set_phase(SandboxPhase::Ready as i32); state.store.put_message(&sandbox).await.unwrap(); + #[allow(deprecated)] let proposed_rule = NetworkPolicyRule { name: "github_contents_write".to_string(), endpoints: vec![NetworkEndpoint { @@ -14446,15 +14449,16 @@ mod tests { ..Default::default() }), }], + advisor_proposed: true, ..Default::default() }], binaries: vec![NetworkBinary { path: "/usr/bin/curl".to_string(), - ..Default::default() + harness: true, }], }; - handle_submit_policy_analysis( + let submit = handle_submit_policy_analysis( &state, with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), @@ -14469,12 +14473,16 @@ mod tests { })), ) .await - .unwrap(); + .unwrap() + .into_inner(); + assert_eq!(submit.accepted_chunks, 1); + assert_eq!(submit.rejected_chunks, 0); + let chunk_id = submit.accepted_chunk_ids[0].clone(); let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, + name: sandbox_name.clone(), status_filter: String::new(), workspace: "default".to_string(), })), @@ -14482,7 +14490,13 @@ mod tests { .await .unwrap() .into_inner(); - let verdict = &draft.chunks[0].validation_result; + let chunk = draft + .chunks + .iter() + .find(|chunk| chunk.id == chunk_id) + .expect("provider-overlap proposal should reach the draft inbox"); + assert_eq!(chunk.status, "pending"); + let verdict = &chunk.validation_result; let first_line = verdict.lines().next().unwrap_or(""); assert!( first_line.starts_with("prover: "), @@ -14492,7 +14506,49 @@ mod tests { assert!( !verdict.contains("validation unavailable"), "providers-v2 composition must not break the prover pipeline; \ - got: {verdict}" + got: {verdict}" + ); + + handle_approve_draft_chunk( + &state, + authed_request(ApproveDraftChunkRequest { + name: sandbox_name, + chunk_id, + workspace: "default".to_string(), + review_token: chunk.review_token.clone(), + }), + ) + .await + .expect("provider-overlap proposal should approve"); + + let stored = state + .store + .get_latest_policy(sandbox_id) + .await + .unwrap() + .expect("approval should persist a base-policy revision"); + let base_policy = ProtoSandboxPolicy::decode(stored.policy_payload.as_slice()).unwrap(); + assert!( + base_policy + .network_policies + .contains_key("github_contents_write") + ); + assert!( + !base_policy + .network_policies + .contains_key("_provider_work_custom"), + "provider-composed rules must not be copied into the mutable base policy" + ); + + let effective_policy = get_sandbox_policy(&state, sandbox_id).await; + let provider_rule = &effective_policy.network_policies["_provider_work_custom"]; + assert_eq!(provider_rule.endpoints[0].access, "full"); + assert_eq!(provider_rule.endpoints[0].deny_rules.len(), 1); + assert!(!provider_rule.endpoints[0].advisor_proposed); + assert!( + effective_policy + .network_policies + .contains_key("github_contents_write") ); } diff --git a/crates/openshell-supervisor-network/src/policy_local.rs b/crates/openshell-supervisor-network/src/policy_local.rs index 908587ed31..ef899b666e 100644 --- a/crates/openshell-supervisor-network/src/policy_local.rs +++ b/crates/openshell-supervisor-network/src/policy_local.rs @@ -1440,6 +1440,7 @@ mod tests { assert_eq!(rule.endpoints[0].port, 443); assert_eq!(rule.endpoints[0].ports, vec![443]); assert_eq!(rule.endpoints[0].protocol, "rest"); + assert!(rule.endpoints[0].advisor_proposed); #[allow(deprecated)] { assert!(rule.binaries[0].harness); diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index a85bbd9b9d..16202751f8 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -236,6 +236,8 @@ The following steps outline the hot-reload policy update workflow. OpenShell validates a complete candidate policy before activating any part of it. Endpoints may overlap when their connection and request-processing metadata agree. For example, two `api.example.com:443` REST entries can contribute different allow and deny rules when they use the same TLS, destination, credential, parser, and enforcement settings. A plain L4 endpoint may overlap an L7 endpoint because it authorizes the destination without contributing request-processing metadata. A more-specific path endpoint may override request-processing metadata from a broader endpoint, such as a `/graphql` GraphQL endpoint alongside a general REST endpoint for the same host. OpenShell rejects the candidate when overlapping exact or wildcard host selectors can both contribute equally specific endpoint configuration and disagree on those fields. +Internal policy-advisor provenance does not make otherwise compatible endpoints ambiguous. This lets an advisor proposal extend a provider-covered host without modifying the provider rule. TLS, destination IP constraints, credential handling, protocol, parser, and equally specific enforcement settings must still agree. + When the gateway knows the affected sandbox scope, it validates the complete effective candidate before persistence. This covers direct policy replacement, incremental merges and proposal approvals, provider attachment, and diff --git a/docs/sandboxes/policy-advisor.mdx b/docs/sandboxes/policy-advisor.mdx index b4d607cb33..35e0073ec3 100644 --- a/docs/sandboxes/policy-advisor.mdx +++ b/docs/sandboxes/policy-advisor.mdx @@ -131,6 +131,25 @@ OpenShell has two proposal paths: | Mechanistic mapper | Aggregated denial summaries from the sandbox. | Groups by host, port, and binary. If L7 request samples are available, it can draft REST method and path rules. Otherwise it drafts an L4 endpoint. | | Agent-authored proposal | The in-sandbox agent, using `policy.local`. | Usually a REST `addRule` with exact host, port, binary, method, and path from the structured denial. It can also propose L4 rules for opaque protocols. | +### How proposal provenance works + +OpenShell tracks whether an endpoint and binary came from policy advisor. This is internal provenance; it is not a policy YAML field that authors set. Think of each marker as answering “who introduced this identity?” rather than “what traffic does this allow?” + +| Marker | `false` | `true` | When both declarations meet | +|---|---|---|---| +| Endpoint provenance | A user or provider explicitly declared the endpoint. | Policy advisor proposed the endpoint. | The values do not conflict by themselves. The explicit declaration wins if the endpoints merge. | +| Binary provenance | A user or provider explicitly declared the binary path. | Policy advisor proposed the binary path. | The explicit declaration wins if the same path merges. | + +For example, a GitHub provider can explicitly declare `api.github.com:443` for read operations. An agent can then propose `PUT /repos/NVIDIA/OpenShell/contents/docs/**` for the same endpoint. The provider endpoint has provenance `false`; the proposal endpoint has provenance `true`. OpenShell allows that overlap, keeps the provider rule immutable, and stores an approved write rule in the sandbox policy layer. + +Provenance does not hide a real endpoint conflict. The same two declarations still fail validation if they disagree on connection or request-processing behavior that must have one value, such as TLS mode, `allowed_ips`, an equally specific L7 protocol or parser contract, credential binding, or enforcement mode. Authorization fields such as compatible allow and deny rules can combine. + +Exact-host SSRF trust requires an exact endpoint and the matching binary identity to be explicit in the same rule. An advisor-only endpoint or binary does not create that stronger trust. For example: + +- A provider rule that explicitly declares both `/usr/bin/gh` and `api.github.com:443` already establishes exact-host trust for that pair. A later advisor proposal does not create or broaden that trust. +- If the provider declares `api.github.com:443` for `/usr/bin/gh`, but the advisor proposes the endpoint for `/usr/bin/curl`, `curl` does not inherit the provider's binary identity. Its proposed rule remains subject to the normal SSRF checks. +- If an advisor proposes `internal-api.example:443` and it resolves to a private address, approval alone is not enough. A developer must explicitly authorize the intended address range with `allowed_ips`. + For REST APIs, prefer L7 rules over broad L4 access. A good proposal allows one method and the smallest safe path: ```json @@ -172,7 +191,7 @@ For REST APIs, prefer L7 rules over broad L4 access. A good proposal allows one The current `policy.local` JSON shape covers L4 endpoints and REST method or path rules. Use [Customize Sandbox Policies](/sandboxes/policies) or [Policy Schema Reference](/reference/policy-schema) for policy fields that are not part of the agent-authored proposal surface, such as WebSocket credential rewrite, GraphQL operation matching, endpoint path scoping, and provider-owned policy bundles. -Policy advisor proposals do not add `allowed_ips` automatically. If an advisor-proposed hostname resolves to an internal or private address, OpenShell's SSRF protections still block the connection until a developer explicitly adds the required `allowed_ips` entry. Exact hostname trust for user-declared policy endpoints does not apply to advisor-generated proposal binaries. +Policy advisor proposals do not add `allowed_ips` automatically. If an advisor-proposed hostname resolves to an internal or private address, OpenShell's SSRF protections still block the connection until a developer explicitly adds the required `allowed_ips` entry. Private RFC 1918, CGNAT, IPv6 ULA, and other special-use destinations classified as internal produce advisory security notes when they appear as literal endpoint IPs or in `allowed_ips`. CIDR intersections are included, and hostless `allowed_ips` rules receive an additional warning because they can match any hostname resolving into the configured range. From 8a13bc1298e9f74f110f0bc57cfb12e91c93d9c7 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Tue, 1 Sep 2026 09:49:01 +0000 Subject: [PATCH 059/505] chore(deps): remove legacy rustls webpki path (#3013) Signed-off-by: Evan Lezar --- Cargo.lock | 266 +++++++++++++++-------------------------------------- Cargo.toml | 4 +- 2 files changed, 77 insertions(+), 193 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 011730760d..0ca41a4c18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,7 +127,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -138,7 +138,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -434,23 +434,17 @@ dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", "aws-smithy-types", - "h2 0.3.27", - "h2 0.4.13", - "http 0.2.12", + "h2", "http 1.4.0", - "http-body 0.4.6", - "hyper 0.14.32", - "hyper 1.9.0", - "hyper-rustls 0.24.2", - "hyper-rustls 0.27.9", + "hyper", + "hyper-rustls", "hyper-util", "pin-project-lite", - "rustls 0.21.12", - "rustls 0.23.38", + "rustls", "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower 0.5.3", "tracing", ] @@ -615,7 +609,7 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper", "hyper-util", "itoa", "matchit", @@ -818,7 +812,7 @@ dependencies = [ "hex", "http 1.4.0", "http-body-util", - "hyper 1.9.0", + "hyper", "hyper-named-pipe", "hyper-util", "hyperlocal", @@ -1828,7 +1822,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2206,25 +2200,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "h2" version = "0.4.13" @@ -2465,30 +2440,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - [[package]] name = "hyper" version = "1.9.0" @@ -2499,7 +2450,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.13", + "h2", "http 1.4.0", "http-body 1.0.1", "httparse", @@ -2518,7 +2469,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" dependencies = [ "hex", - "hyper 1.9.0", + "hyper", "hyper-util", "pin-project-lite", "tokio", @@ -2526,21 +2477,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "hyper-rustls" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" -dependencies = [ - "futures-util", - "http 0.2.12", - "hyper 0.14.32", - "log", - "rustls 0.21.12", - "tokio", - "tokio-rustls 0.24.1", -] - [[package]] name = "hyper-rustls" version = "0.27.9" @@ -2548,13 +2484,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http 1.4.0", - "hyper 1.9.0", + "hyper", "hyper-util", "log", - "rustls 0.23.38", + "rustls", "rustls-native-certs", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower-service", ] @@ -2564,7 +2500,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper 1.9.0", + "hyper", "hyper-util", "pin-project-lite", "tokio", @@ -2583,12 +2519,12 @@ dependencies = [ "futures-util", "http 1.4.0", "http-body 1.0.1", - "hyper 1.9.0", + "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2", "tokio", "tower-service", "tracing", @@ -2602,7 +2538,7 @@ checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" dependencies = [ "hex", "http-body-util", - "hyper 1.9.0", + "hyper", "hyper-util", "pin-project-lite", "tokio", @@ -3155,15 +3091,15 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", - "hyper-rustls 0.27.9", + "hyper", + "hyper-rustls", "hyper-timeout", "hyper-util", "jsonpath-rust", "k8s-openapi", "kube-core", "pem", - "rustls 0.23.38", + "rustls", "rustls-pemfile", "secrecy", "serde", @@ -3417,7 +3353,7 @@ checksum = "3589659543c04c7dc5526ec858591015b87cd8746583b51b48ef4353f99dbcda" dependencies = [ "base64 0.22.1", "http-body-util", - "hyper 1.9.0", + "hyper", "hyper-util", "indexmap", "ipnet", @@ -3628,7 +3564,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3715,7 +3651,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -3850,8 +3786,8 @@ dependencies = [ "dialoguer", "futures", "http-body-util", - "hyper 1.9.0", - "hyper-rustls 0.27.9", + "hyper", + "hyper-rustls", "hyper-util", "indicatif", "miette", @@ -3867,7 +3803,7 @@ dependencies = [ "prost-types", "rcgen", "reqwest 0.12.28", - "rustls 0.23.38", + "rustls", "rustls-pemfile", "serde", "serde_json", @@ -3877,7 +3813,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-stream", "tokio-tungstenite 0.26.2", "tonic", @@ -4050,7 +3986,7 @@ dependencies = [ "futures", "http 1.4.0", "http-body-util", - "hyper 1.9.0", + "hyper", "hyper-util", "miette", "nix 0.29.0", @@ -4145,7 +4081,7 @@ dependencies = [ "http 1.4.0", "hyper-util", "rcgen", - "rustls 0.23.38", + "rustls", "serde", "serde_json", "thiserror 2.0.18", @@ -4290,7 +4226,7 @@ dependencies = [ "openshell-supervisor-process", "prost", "prost-types", - "rustls 0.23.38", + "rustls", "serde", "serde_json", "temp-env", @@ -4310,18 +4246,18 @@ version = "0.0.0" dependencies = [ "async-trait", "futures", - "hyper 1.9.0", + "hyper", "hyper-util", "miette", "oauth2", "openshell-core", "reqwest 0.12.28", - "rustls 0.23.38", + "rustls", "serde", "serde_json", "thiserror 2.0.18", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-stream", "tokio-tungstenite 0.26.2", "tonic", @@ -4351,8 +4287,8 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", - "hyper-rustls 0.27.9", + "hyper", + "hyper-rustls", "hyper-util", "ipnet", "jsonwebtoken", @@ -4396,18 +4332,18 @@ dependencies = [ "rsa 0.9.10", "russh", "rustix 1.1.4", - "rustls 0.23.38", + "rustls", "rustls-pemfile", "serde", "serde_json", "sha2 0.10.9", - "socket2 0.6.3", + "socket2", "spiffe", "sqlx", "tempfile", "thiserror 2.0.18", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-stream", "tokio-tungstenite 0.26.2", "toml", @@ -4493,7 +4429,7 @@ dependencies = [ "rcgen", "regorus", "reqwest 0.12.28", - "rustls 0.23.38", + "rustls", "rustls-native-certs", "rustls-pemfile", "serde", @@ -4506,7 +4442,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-stream", "tokio-tungstenite 0.26.2", "tonic", @@ -4540,7 +4476,7 @@ dependencies = [ "seccompiler", "serde_json", "sha2 0.10.9", - "socket2 0.6.3", + "socket2", "tempfile", "tokio", "tokio-stream", @@ -5359,8 +5295,8 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.1.2", - "rustls 0.23.38", - "socket2 0.6.3", + "rustls", + "socket2", "thiserror 2.0.18", "tokio", "tracing", @@ -5381,7 +5317,7 @@ dependencies = [ "rand_pcg", "ring", "rustc-hash 2.1.2", - "rustls 0.23.38", + "rustls", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -5399,7 +5335,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2", "tracing", "windows-sys 0.60.2", ] @@ -5647,15 +5583,15 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", - "hyper-rustls 0.27.9", + "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.38", + "rustls", "rustls-native-certs", "rustls-pki-types", "serde", @@ -5663,7 +5599,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower 0.5.3", "tower-http 0.6.8", "tower-service", @@ -5686,15 +5622,15 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", - "hyper-rustls 0.27.9", + "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.38", + "rustls", "rustls-pki-types", "rustls-platform-verifier", "serde", @@ -5702,7 +5638,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-util", "tower 0.5.3", "tower-http 0.6.8", @@ -5954,19 +5890,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", + "windows-sys 0.59.0", ] [[package]] @@ -5980,7 +5904,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.13", + "rustls-webpki", "subtle", "zeroize", ] @@ -6027,14 +5951,14 @@ dependencies = [ "jni 0.21.1", "log", "once_cell", - "rustls 0.23.38", + "rustls", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki 0.103.13", + "rustls-webpki", "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6043,16 +5967,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" -[[package]] -name = "rustls-webpki" -version = "0.101.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring", - "untrusted 0.9.0", -] - [[package]] name = "rustls-webpki" version = "0.103.13" @@ -6147,16 +6061,6 @@ dependencies = [ "sha2 0.11.0", ] -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted 0.9.0", -] - [[package]] name = "sec1" version = "0.7.3" @@ -6577,16 +6481,6 @@ dependencies = [ "serde", ] -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.3" @@ -6594,7 +6488,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6697,7 +6591,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rustls 0.23.38", + "rustls", "rustls-native-certs", "serde", "serde_json", @@ -7092,7 +6986,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7128,7 +7022,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7264,7 +7158,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -7280,23 +7174,13 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.38", + "rustls", "tokio", ] @@ -7319,11 +7203,11 @@ checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ "futures-util", "log", - "rustls 0.23.38", + "rustls", "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tungstenite 0.26.2", ] @@ -7404,20 +7288,20 @@ dependencies = [ "axum", "base64 0.22.1", "bytes", - "h2 0.4.13", + "h2", "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper", "hyper-timeout", "hyper-util", "percent-encoding", "pin-project", "rustls-native-certs", - "socket2 0.6.3", + "socket2", "sync_wrapper", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-stream", "tower 0.5.3", "tower-layer", @@ -7695,7 +7579,7 @@ dependencies = [ "httparse", "log", "rand 0.9.4", - "rustls 0.23.38", + "rustls", "rustls-pki-types", "sha1 0.10.6", "thiserror 2.0.18", @@ -8125,7 +8009,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -8562,7 +8446,7 @@ dependencies = [ "futures", "http 1.4.0", "http-body-util", - "hyper 1.9.0", + "hyper", "hyper-util", "log", "once_cell", diff --git a/Cargo.toml b/Cargo.toml index 57b77d0716..6936439a3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,8 +90,8 @@ regex = "1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots"] } # 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"] } From bb70461878ca5d152616961609db9b403b289098 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Tue, 1 Sep 2026 12:36:51 +0000 Subject: [PATCH 060/505] test(e2e): run conformance in gateway lanes (#2925) * test(e2e): isolate VM-specific smoke assertions Signed-off-by: Evan Lezar * test(e2e): add portable CLI conformance baseline Signed-off-by: Evan Lezar * feat(conformance): add standalone CLI runner Signed-off-by: Evan Lezar * test(e2e): run conformance in gateway lanes Signed-off-by: Evan Lezar --------- Signed-off-by: Evan Lezar --- .github/actions/setup-e2e-cli/action.yml | 32 +- .github/workflows/branch-e2e.yml | 65 +- .github/workflows/e2e-docker-test.yml | 6 + .github/workflows/e2e-gpu-test.yaml | 7 + .github/workflows/e2e-kubernetes-test.yml | 7 + .github/workflows/e2e-podman-test.yml | 6 + .github/workflows/e2e-vm-test.yml | 6 + AGENTS.md | 2 + Cargo.lock | 22 + TESTING.md | 49 +- architecture/build.md | 1 + crates/openshell-conformance-cli/Cargo.toml | 25 + crates/openshell-conformance-cli/src/main.rs | 244 +++++ crates/openshell-conformance/Cargo.toml | 23 + crates/openshell-conformance/src/executor.rs | 52 + crates/openshell-conformance/src/lib.rs | 986 ++++++++++++++++++ .../src/scenarios/mod.rs | 8 + .../src/scenarios/smoke.rs | 206 ++++ e2e/rust/Cargo.toml | 6 + e2e/rust/e2e-docker.sh | 42 +- e2e/rust/e2e-kubernetes.sh | 51 +- e2e/rust/e2e-podman.sh | 37 +- e2e/rust/e2e-vm.sh | 9 +- e2e/rust/tests/smoke.rs | 139 --- e2e/rust/tests/vm_overlay.rs | 54 + e2e/support/conformance.sh | 30 + e2e/with-docker-gateway.sh | 1 + e2e/with-podman-gateway.sh | 1 + tasks/test.toml | 81 +- 29 files changed, 1981 insertions(+), 217 deletions(-) create mode 100644 crates/openshell-conformance-cli/Cargo.toml create mode 100644 crates/openshell-conformance-cli/src/main.rs create mode 100644 crates/openshell-conformance/Cargo.toml create mode 100644 crates/openshell-conformance/src/executor.rs create mode 100644 crates/openshell-conformance/src/lib.rs create mode 100644 crates/openshell-conformance/src/scenarios/mod.rs create mode 100644 crates/openshell-conformance/src/scenarios/smoke.rs delete mode 100644 e2e/rust/tests/smoke.rs create mode 100644 e2e/rust/tests/vm_overlay.rs create mode 100644 e2e/support/conformance.sh diff --git a/.github/actions/setup-e2e-cli/action.yml b/.github/actions/setup-e2e-cli/action.yml index 4d43e232a4..d7d1c8a296 100644 --- a/.github/actions/setup-e2e-cli/action.yml +++ b/.github/actions/setup-e2e-cli/action.yml @@ -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- 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" diff --git a/.github/workflows/branch-e2e.yml b/.github/workflows/branch-e2e.yml index 0b050d0e51..580a05c1f9 100644 --- a/.github/workflows/branch-e2e.yml +++ b/.github/workflows/branch-e2e.yml @@ -105,6 +105,31 @@ 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 + 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-conformance-cli + binary: openshell-conformance + artifact-name: openshell-conformance-${{ matrix.triple }} + triple: ${{ matrix.triple }} + runner: ${{ matrix.runner }} + dev-shell: ${{ matrix.dev_shell }} + cargo-version: ${{ needs.version.outputs.cargo }} + secrets: inherit + build-sandbox: needs: version permissions: @@ -241,7 +266,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 +276,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 +288,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 +314,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 +330,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 +346,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 +358,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 +389,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 +403,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 +420,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 +434,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 +449,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 +463,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 diff --git a/.github/workflows/e2e-docker-test.yml b/.github/workflows/e2e-docker-test.yml index aa884bc3e2..0b2338e8a6 100644 --- a/.github/workflows/e2e-docker-test.yml +++ b/.github/workflows/e2e-docker-test.yml @@ -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: diff --git a/.github/workflows/e2e-gpu-test.yaml b/.github/workflows/e2e-gpu-test.yaml index 1a8275a274..b363a0509b 100644 --- a/.github/workflows/e2e-gpu-test.yaml +++ b/.github/workflows/e2e-gpu-test.yaml @@ -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 )" + 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 diff --git a/.github/workflows/e2e-kubernetes-test.yml b/.github/workflows/e2e-kubernetes-test.yml index 4498f9d44a..368d902b40 100644 --- a/.github/workflows/e2e-kubernetes-test.yml +++ b/.github/workflows/e2e-kubernetes-test.yml @@ -52,6 +52,11 @@ on: required: false type: string default: "" + conformance-artifact-prefix: + description: "Optional prebuilt conformance CLI artifact prefix (artifact suffix is )" + 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 +97,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 != '' diff --git a/.github/workflows/e2e-podman-test.yml b/.github/workflows/e2e-podman-test.yml index 4a5a6d38d7..3ec5294c41 100644 --- a/.github/workflows/e2e-podman-test.yml +++ b/.github/workflows/e2e-podman-test.yml @@ -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: diff --git a/.github/workflows/e2e-vm-test.yml b/.github/workflows/e2e-vm-test.yml index 57e04c32b3..954de9669e 100644 --- a/.github/workflows/e2e-vm-test.yml +++ b/.github/workflows/e2e-vm-test.yml @@ -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: diff --git a/AGENTS.md b/AGENTS.md index 2f2a166f55..5fdaa63d02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,8 @@ 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 | diff --git a/Cargo.lock b/Cargo.lock index 0ca41a4c18..9188a93d0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3823,6 +3823,28 @@ dependencies = [ "url", ] +[[package]] +name = "openshell-conformance" +version = "0.0.0" +dependencies = [ + "rand 0.9.4", + "serde", + "serde_json", + "tempfile", + "tokio", +] + +[[package]] +name = "openshell-conformance-cli" +version = "0.0.0" +dependencies = [ + "clap", + "openshell-conformance", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "openshell-core" version = "0.0.0" diff --git a/TESTING.md b/TESTING.md index 6c0829060d..199fa35416 100644 --- a/TESTING.md +++ b/TESTING.md @@ -148,7 +148,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 +171,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--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 +``` + +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 diff --git a/architecture/build.md b/architecture/build.md index 4ffec5b712..aa252dad0f 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -12,6 +12,7 @@ OpenShell builds these main artifacts: |---|---| | Gateway binary | `crates/openshell-server` | | 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` | diff --git a/crates/openshell-conformance-cli/Cargo.toml b/crates/openshell-conformance-cli/Cargo.toml new file mode 100644 index 0000000000..ce9f89c17c --- /dev/null +++ b/crates/openshell-conformance-cli/Cargo.toml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-conformance-cli" +description = "Standalone OpenShell CLI conformance test runner" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "openshell-conformance" +path = "src/main.rs" + +[dependencies] +clap.workspace = true +openshell-conformance = { path = "../openshell-conformance" } +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true + +[lints] +workspace = true diff --git a/crates/openshell-conformance-cli/src/main.rs b/crates/openshell-conformance-cli/src/main.rs new file mode 100644 index 0000000000..c877cf4fc1 --- /dev/null +++ b/crates/openshell-conformance-cli/src/main.rs @@ -0,0 +1,244 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Standalone runner for `OpenShell` CLI conformance scenarios. + +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::{Parser, Subcommand, ValueEnum}; +use openshell_conformance::{OpenShellRunner, Scenario, scenario, scenarios}; +use serde::Serialize; + +#[derive(Debug, Parser)] +#[command( + name = "openshell-conformance", + about = "Run OpenShell CLI conformance scenarios", + version +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// List registered scenarios. + List { + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + output: OutputFormat, + }, + /// Run all registered scenarios, or named scenarios. + Run { + /// Scenario names. Omit to run every registered scenario. + scenarios: Vec, + /// Explicit path to the `OpenShell` CLI. Defaults to `openshell` on PATH. + #[arg(long)] + openshell_bin: Option, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + output: OutputFormat, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +enum OutputFormat { + Text, + Json, +} + +#[derive(Serialize)] +struct ScenarioDescription<'a> { + name: &'a str, + description: &'a str, +} + +#[derive(Serialize)] +struct ScenarioResult<'a> { + name: &'a str, + passed: bool, + diagnostic: Option, +} + +#[derive(Serialize)] +struct RunReport<'a> { + scenarios: Vec>, + passed: bool, +} + +#[tokio::main] +async fn main() -> ExitCode { + match execute(Cli::parse()).await { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("openshell-conformance: {error}"); + ExitCode::FAILURE + } + } +} + +async fn execute(cli: Cli) -> Result<(), String> { + match cli.command { + Command::List { output } => list(output), + Command::Run { + scenarios: requested, + openshell_bin, + output, + } => run(&requested, openshell_bin, output).await, + } +} + +fn list(output: OutputFormat) -> Result<(), String> { + match output { + OutputFormat::Text => { + for candidate in scenarios() { + println!("{:<16} {}", candidate.name, candidate.description); + } + } + OutputFormat::Json => { + let result = scenarios() + .iter() + .map(|candidate| ScenarioDescription { + name: candidate.name, + description: candidate.description, + }) + .collect::>(); + println!( + "{}", + serde_json::to_string_pretty(&result).map_err(|error| error.to_string())? + ); + } + } + Ok(()) +} + +async fn run( + requested: &[String], + binary: Option, + output: OutputFormat, +) -> Result<(), String> { + let selected = select_scenarios(requested)?; + let mut results = Vec::with_capacity(selected.len()); + for candidate in selected { + let runner = binary.as_ref().map_or_else( + || OpenShellRunner::new(candidate.name), + |path| OpenShellRunner::with_binary(path.clone(), candidate.name), + ); + let mut runner = match runner { + Ok(runner) => runner, + Err(error) => { + results.push(ScenarioResult { + name: candidate.name, + passed: false, + diagnostic: Some(error.to_string()), + }); + continue; + } + }; + eprintln!("CLI conformance run ID: {}", runner.id()); + let scenario_result = match runner.check_gateway_status().await { + Ok(()) => candidate.run(&mut runner).await, + Err(error) => Err(error), + }; + let outcome = runner.finish(scenario_result).await; + results.push(ScenarioResult { + name: candidate.name, + passed: outcome.is_ok(), + diagnostic: outcome.err(), + }); + } + + let passed = results.iter().all(|result| result.passed); + match output { + OutputFormat::Text => { + for result in &results { + if result.passed { + println!("PASS {}", result.name); + } else { + println!( + "FAIL {}\n{}", + result.name, + result.diagnostic.as_deref().unwrap_or("unknown failure") + ); + } + } + } + OutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&RunReport { + scenarios: results, + passed + }) + .map_err(|error| error.to_string())? + ), + } + if passed { + Ok(()) + } else { + Err("one or more scenarios failed".to_string()) + } +} + +fn select_scenarios(requested: &[String]) -> Result, String> { + if requested.is_empty() { + return Ok(scenarios().iter().collect()); + } + requested + .iter() + .map(|name| { + scenario(name).ok_or_else(|| { + format!("unknown scenario '{name}'; run `openshell-conformance list`") + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::*; + + #[test] + fn selects_all_scenarios_by_default() { + assert_eq!( + select_scenarios(&[]).expect("select all").len(), + scenarios().len() + ); + } + + #[test] + fn selects_named_scenario() { + let selected = select_scenarios(&["smoke".to_string()]).expect("select smoke"); + assert_eq!(selected[0].name, "smoke"); + } + + #[test] + fn unknown_scenario_has_actionable_diagnostic() { + let error = select_scenarios(&["missing".to_string()]).expect_err("unknown scenario"); + assert!(error.contains("openshell-conformance list")); + } + + #[test] + fn parses_binary_override_and_json_output() { + let cli = Cli::try_parse_from([ + "openshell-conformance", + "run", + "smoke", + "--openshell-bin", + "/opt/openshell", + "--output", + "json", + ]) + .expect("parse CLI"); + let Command::Run { + openshell_bin, + output, + .. + } = cli.command + else { + panic!("expected run") + }; + assert_eq!(openshell_bin, Some(PathBuf::from("/opt/openshell"))); + assert_eq!(output, OutputFormat::Json); + } +} diff --git a/crates/openshell-conformance/Cargo.toml b/crates/openshell-conformance/Cargo.toml new file mode 100644 index 0000000000..7b67258045 --- /dev/null +++ b/crates/openshell-conformance/Cargo.toml @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-conformance" +description = "Reusable OpenShell CLI conformance scenarios and runner" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +rand.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true + +[dev-dependencies] +tempfile = "3" + +[lints] +workspace = true diff --git a/crates/openshell-conformance/src/executor.rs b/crates/openshell-conformance/src/executor.rs new file mode 100644 index 0000000000..b45c102ee1 --- /dev/null +++ b/crates/openshell-conformance/src/executor.rs @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Process execution boundary for the CLI conformance runner. + +use std::future::Future; +use std::path::PathBuf; +use std::pin::Pin; +use std::process::{Output, Stdio}; +use std::time::Duration; + +use tokio::time::timeout; + +pub type CliExecution<'a> = + Pin> + Send + 'a>>; + +pub trait CliExecutor: Send + Sync { + fn execute(&self, args: Vec, command_timeout: Duration) -> CliExecution<'_>; +} + +pub enum CliExecutionError { + Spawn(std::io::Error), + Timeout, +} + +pub struct ProcessCli { + binary: PathBuf, +} + +impl ProcessCli { + pub fn new(binary: PathBuf) -> Self { + Self { binary } + } +} + +impl CliExecutor for ProcessCli { + fn execute(&self, args: Vec, command_timeout: Duration) -> CliExecution<'_> { + Box::pin(async move { + let mut process = tokio::process::Command::new(&self.binary); + process + .args(&args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + timeout(command_timeout, process.output()) + .await + .map_err(|_| CliExecutionError::Timeout)? + .map_err(CliExecutionError::Spawn) + }) + } +} diff --git a/crates/openshell-conformance/src/lib.rs b/crates/openshell-conformance/src/lib.rs new file mode 100644 index 0000000000..aa6f69c387 --- /dev/null +++ b/crates/openshell-conformance/src/lib.rs @@ -0,0 +1,986 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Reusable support for portable `OpenShell` CLI conformance scenarios. + +pub mod executor; +mod scenarios; + +use std::collections::BTreeSet; +use std::error::Error; +use std::fmt; +use std::future::Future; +use std::path::PathBuf; +use std::pin::Pin; +use std::process::ExitStatus; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use rand::Rng; +use serde::Deserialize; +use serde::de::DeserializeOwned; +use tokio::time::sleep; + +use self::executor::{CliExecutionError, CliExecutor, ProcessCli}; + +pub use scenarios::SMOKE_SCENARIO; + +/// An installed conformance scenario. +#[derive(Debug)] +pub struct Scenario { + pub name: &'static str, + pub description: &'static str, + run: for<'a> fn(&'a mut OpenShellRunner) -> ScenarioFuture<'a>, +} + +pub type ScenarioFuture<'a> = Pin> + Send + 'a>>; + +impl Scenario { + pub async fn run(&self, runner: &mut OpenShellRunner) -> Result<(), String> { + (self.run)(runner).await + } +} + +const SCENARIOS: &[Scenario] = &[SMOKE_SCENARIO]; + +/// Returns every scenario compiled into this distribution. +pub fn scenarios() -> &'static [Scenario] { + SCENARIOS +} + +/// Finds a scenario by its stable command-line name. +pub fn scenario(name: &str) -> Option<&'static Scenario> { + scenarios().iter().find(|candidate| candidate.name == name) +} + +const CLEANUP_TIMEOUT: Duration = Duration::from_secs(120); +pub const STATUS_TIMEOUT: Duration = Duration::from_secs(30); +const GATEWAY_STATUS_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(10); +const GATEWAY_STATUS_INTERVAL: Duration = Duration::from_secs(2); + +/// Generate the suite-owned identifier used in resource names and diagnostics. +fn generate_run_id() -> String { + const ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + let mut rng = rand::rng(); + (0..10) + .map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char) + .collect() +} + +/// The completed outcome of one `OpenShell` CLI process. +#[derive(Debug)] +pub struct CommandResult { + run_id: String, + scenario: String, + step: String, + expectation: String, + command: String, + status: ExitStatus, + elapsed: Duration, + stdout: String, + stderr: String, +} + +impl CommandResult { + pub fn success(&self) -> bool { + self.status.success() + } + + pub fn exit_code(&self) -> Option { + self.status.code() + } + + pub fn stdout(&self) -> &str { + &self.stdout + } + + pub fn stderr(&self) -> &str { + &self.stderr + } + + pub fn elapsed(&self) -> Duration { + self.elapsed + } + + pub fn json(&self) -> Result { + serde_json::from_str(&self.stdout).map_err(|source| RunnerError::InvalidJson { + context: self.context(), + source, + stdout: self.stdout.clone(), + stderr: self.stderr.clone(), + }) + } + + pub fn require_success(&self) -> Result<(), String> { + if self.success() { + return Ok(()); + } + Err(self.failure_diagnostic(&self.expectation)) + } + + pub fn failure_diagnostic(&self, expectation: &str) -> String { + format!( + "{}\nexpected: {expectation}\nactual: exit {} after {:.1?}\ncommand: {}\nstdout:\n{}\nstderr:\n{}", + self.context(), + exit_description(self.status), + self.elapsed, + self.command, + self.stdout, + self.stderr, + ) + } + + fn context(&self) -> String { + format!("[run {}][{}/{}]", self.run_id, self.scenario, self.step) + } +} + +/// Failures in reusable runner mechanics rather than scenario assertions. +#[derive(Debug)] +pub enum RunnerError { + BinaryUnavailable(String), + Spawn { + context: String, + command: String, + source: std::io::Error, + }, + Timeout { + context: String, + command: String, + timeout: Duration, + }, + InvalidJson { + context: String, + source: serde_json::Error, + stdout: String, + stderr: String, + }, + PollTimeout { + context: String, + timeout: Duration, + last_observation: String, + }, + ObservationFailed { + context: String, + diagnostic: String, + }, +} + +impl fmt::Display for RunnerError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BinaryUnavailable(message) => write!(f, "{message}"), + Self::Spawn { + context, + command, + source, + } => write!( + f, + "{context} failed to spawn command: {source}\ncommand: {command}" + ), + Self::Timeout { + context, + command, + timeout, + } => write!( + f, + "{context} timed out after {timeout:.1?}\ncommand: {command}" + ), + Self::InvalidJson { + context, + source, + stdout, + stderr, + } => write!( + f, + "{context} returned invalid JSON: {source}\nstdout:\n{stdout}\nstderr:\n{stderr}" + ), + Self::PollTimeout { + context, + timeout, + last_observation, + } => write!( + f, + "{context} did not satisfy the observation within {timeout:.1?}\nlast observation:\n{last_observation}" + ), + Self::ObservationFailed { + context, + diagnostic, + } => write!(f, "{context} observation failed early:\n{diagnostic}"), + } + } +} + +impl Error for RunnerError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Spawn { source, .. } => Some(source), + Self::InvalidJson { source, .. } => Some(source), + _ => None, + } + } +} + +/// Result of one read-only polling observation. +pub enum Poll { + Ready(T), + Pending(String), + Failed(String), +} + +#[derive(Debug, Deserialize)] +struct StatusOutput { + gateway: Option, + server: Option, + status: String, + version: Option, + authentication: Option, +} + +#[derive(Debug, Deserialize)] +struct AuthenticationOutput { + status: String, +} + +/// Runs `OpenShell` commands for one conformance scenario and owns its cleanup. +pub struct OpenShellRunner { + cli: Arc, + run_id: String, + scenario: String, + known_sandboxes: BTreeSet, + finished: bool, +} + +/// A runner command with diagnostic context but no timeout yet. +pub struct CommandStep<'a> { + runner: &'a OpenShellRunner, + step: String, + description: Option, +} + +/// A fully configured runner command ready to execute. +pub struct OpenShellCommand<'a> { + runner: &'a OpenShellRunner, + step: String, + description: String, + timeout: Duration, +} + +impl OpenShellRunner { + pub fn new(scenario: &str) -> Result { + Ok(Self::with_executor( + Arc::new(ProcessCli::new(PathBuf::from("openshell"))), + scenario, + )) + } + + /// Uses an explicit `openshell` binary rather than resolving it on `PATH`. + pub fn with_binary(binary: PathBuf, scenario: &str) -> Result { + if !binary.is_file() { + return Err(RunnerError::BinaryUnavailable(format!( + "OpenShell CLI binary not found at {}", + binary.display() + ))); + } + Ok(Self::with_executor( + Arc::new(ProcessCli::new(binary)), + scenario, + )) + } + + /// Creates a runner with an injected executor. This is useful for harness tests. + pub fn with_executor(cli: Arc, scenario: &str) -> Self { + Self { + cli, + run_id: generate_run_id(), + scenario: scenario.to_string(), + known_sandboxes: BTreeSet::new(), + finished: false, + } + } + + pub fn id(&self) -> &str { + &self.run_id + } + + pub fn scenario(&self) -> &str { + &self.scenario + } + + pub fn step(&self, step: impl Into) -> CommandStep<'_> { + CommandStep { + runner: self, + step: step.into(), + description: None, + } + } + + /// Confirm that the configured gateway is reachable before a scenario runs. + pub async fn check_gateway_status(&mut self) -> Result<(), String> { + let status = self + .poll_until( + "preflight", + STATUS_TIMEOUT, + GATEWAY_STATUS_INTERVAL, + async |runner| match runner + .step("status/json") + .description("machine-readable gateway status succeeds") + .with_timeout(GATEWAY_STATUS_ATTEMPT_TIMEOUT) + .run(&["status", "--output", "json"]) + .await + { + Ok(result) if !result.success() => Poll::Pending( + result.failure_diagnostic("gateway status command succeeds"), + ), + Ok(result) => match result.json::() { + Ok(status) if status.status == "connected" => Poll::Ready(status), + Ok(status) if status.status == "not_configured" => Poll::Failed( + "no active gateway; register and select a gateway before running conformance" + .to_string(), + ), + Ok(status) => Poll::Pending(format!( + "gateway status is {:?}; expected \"connected\"\nstderr:\n{}", + status.status, + result.stderr() + )), + Err(error) => Poll::Failed(error.to_string()), + }, + Err(error) => Poll::Pending(error.to_string()), + }, + ) + .await + .map_err(|error| error.to_string())?; + + eprintln!( + "gateway preflight connected: gateway={}, server={}, version={}, authentication={}", + status.gateway.as_deref().unwrap_or("unknown"), + status.server.as_deref().unwrap_or("unknown"), + status.version.as_deref().unwrap_or("unknown"), + status + .authentication + .as_ref() + .map_or("unknown", |authentication| authentication.status.as_str()), + ); + Ok(()) + } + + /// Register a sandbox name for cleanup. + /// + /// Call this before running the command that may create the sandbox. The + /// scenario remains responsible for constructing and running that command. + pub fn track_sandbox(&mut self, name: &str) { + self.known_sandboxes.insert(name.to_string()); + } + + /// Stop tracking a sandbox after the scenario confirms it is absent. + pub fn forget_sandbox(&mut self, name: &str) { + self.known_sandboxes.remove(name); + } + + pub async fn poll_until( + &mut self, + step: &str, + poll_timeout: Duration, + interval: Duration, + mut observe: F, + ) -> Result + where + F: AsyncFnMut(&mut Self) -> Poll, + { + let started = Instant::now(); + let context = self.context(step); + + loop { + match observe(self).await { + Poll::Ready(value) => return Ok(value), + Poll::Pending(diagnostic) => { + if started.elapsed() >= poll_timeout { + return Err(RunnerError::PollTimeout { + context, + timeout: poll_timeout, + last_observation: diagnostic, + }); + } + } + Poll::Failed(diagnostic) => { + return Err(RunnerError::ObservationFailed { + context, + diagnostic, + }); + } + } + sleep(interval).await; + } + } + + pub async fn finish(mut self, scenario_result: Result<(), String>) -> Result<(), String> { + let cleanup_result = self.cleanup().await; + self.finished = true; + combine_results(scenario_result, cleanup_result) + } + + async fn run_strings( + &self, + step: &str, + expectation: &str, + args: Vec, + command_timeout: Duration, + ) -> Result { + let context = self.context(step); + let command = sanitized_command(&args); + eprintln!("{context} running: {command}"); + + let started = Instant::now(); + let output = + self.cli + .execute(args, command_timeout) + .await + .map_err(|error| match error { + CliExecutionError::Timeout => RunnerError::Timeout { + context: context.clone(), + command: command.clone(), + timeout: command_timeout, + }, + CliExecutionError::Spawn(source) => RunnerError::Spawn { + context: context.clone(), + command: command.clone(), + source, + }, + })?; + let elapsed = started.elapsed(); + eprintln!( + "{context} completed in {:.1?}: exit {}", + elapsed, + exit_description(output.status) + ); + + Ok(CommandResult { + run_id: self.run_id.clone(), + scenario: self.scenario.clone(), + step: step.to_string(), + expectation: expectation.to_string(), + command, + status: output.status, + elapsed, + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) + } + + async fn cleanup(&self) -> Result<(), String> { + if self.known_sandboxes.is_empty() { + return Ok(()); + } + + let cleanup_started = Instant::now(); + let mut failures = Vec::new(); + for name in self.known_sandboxes.clone() { + let remaining = remaining_cleanup_time(cleanup_started); + if remaining.is_zero() { + failures.push(format!( + "{} cleanup budget expired before deleting sandbox '{name}'", + self.context("cleanup/delete") + )); + break; + } + match self + .step("cleanup/delete") + .description(format!("sandbox '{name}' is deleted or already absent")) + .with_timeout(remaining) + .run(&["sandbox", "delete", &name]) + .await + { + Ok(result) if result.success() || output_reports_not_found(&result) => {} + Ok(result) => { + failures.push(result.failure_diagnostic(&format!( + "sandbox '{name}' is deleted or already absent" + ))); + } + Err(error) => failures.push(error.to_string()), + } + } + + if failures.is_empty() { + Ok(()) + } else { + Err(failures.join("\n\n")) + } + } + + fn context(&self, step: &str) -> String { + format!("[run {}][{}/{}]", self.run_id, self.scenario, step) + } +} + +impl<'a> CommandStep<'a> { + #[must_use] + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + pub fn with_timeout(self, timeout: Duration) -> OpenShellCommand<'a> { + let description = self + .description + .unwrap_or_else(|| format!("step '{}' succeeds", self.step)); + OpenShellCommand { + runner: self.runner, + step: self.step, + description, + timeout, + } + } +} + +impl OpenShellCommand<'_> { + pub async fn run(&self, args: &[&str]) -> Result { + self.runner + .run_strings( + &self.step, + &self.description, + args.iter().map(|arg| (*arg).to_string()).collect(), + self.timeout, + ) + .await + } +} + +impl Drop for OpenShellRunner { + fn drop(&mut self) { + if self.finished { + return; + } + let resources = if self.known_sandboxes.is_empty() { + "none explicitly tracked".to_string() + } else { + self.known_sandboxes + .iter() + .cloned() + .collect::>() + .join(", ") + }; + eprintln!( + "[run {}][{}] WARNING: runner dropped without finish(); known sandboxes: {}", + self.run_id, self.scenario, resources + ); + } +} + +fn combine_results( + scenario_result: Result<(), String>, + cleanup_result: Result<(), String>, +) -> Result<(), String> { + match (scenario_result, cleanup_result) { + (Ok(()), Ok(())) => Ok(()), + (Err(functional), Ok(())) => Err(functional), + (Ok(()), Err(cleanup)) => Err(format!("cleanup failed:\n{cleanup}")), + (Err(functional), Err(cleanup)) => Err(format!( + "{functional}\n\nsecondary cleanup failure:\n{cleanup}" + )), + } +} + +fn remaining_cleanup_time(started: Instant) -> Duration { + CLEANUP_TIMEOUT.saturating_sub(started.elapsed()) +} + +fn output_reports_not_found(result: &CommandResult) -> bool { + result.stderr().to_ascii_lowercase().contains("not found") + || result.stdout().to_ascii_lowercase().contains("not found") +} + +fn exit_description(status: ExitStatus) -> String { + status.code().map_or_else( + || "terminated by signal".to_string(), + |code| code.to_string(), + ) +} + +fn sanitized_command(args: &[String]) -> String { + let mut redact_next = false; + let rendered = args.iter().map(|arg| { + let rendered = if redact_next { + redact_next = false; + "".to_string() + } else if sensitive_flag(arg) { + redact_next = !arg.contains('='); + arg.split_once('=') + .map_or_else(|| arg.clone(), |(flag, _)| format!("{flag}=")) + } else { + arg.clone() + }; + shell_escape(&rendered) + }); + std::iter::once("openshell".to_string()) + .chain(rendered) + .collect::>() + .join(" ") +} + +fn sensitive_flag(arg: &str) -> bool { + let flag = arg.split_once('=').map_or(arg, |(flag, _)| flag); + matches!( + flag, + "--credential" + | "--credentials" + | "--env" + | "--header" + | "--material" + | "--password" + | "--secret" + | "--token" + ) || flag.contains("client-secret") + || flag.contains("private-key") + || flag.ends_with("-token") +} + +fn shell_escape(arg: &str) -> String { + if arg + .chars() + .all(|character| character.is_ascii_alphanumeric() || "-_./:@=,".contains(character)) + { + return arg.to_string(); + } + format!("'{}'", arg.replace('\'', "'\\''")) +} + +#[cfg(all(test, unix))] +mod tests { + use std::collections::VecDeque; + use std::os::unix::process::ExitStatusExt; + use std::process::Output; + use std::sync::Mutex; + + use serde::Deserialize; + + use super::executor::CliExecution; + use super::*; + + struct MockCli { + state: Mutex, + } + + struct MockCliState { + responses: VecDeque, + invocations: Vec>, + } + + enum MockResponse { + Output { + exit_code: i32, + stdout: String, + stderr: String, + }, + Timeout, + } + + impl MockResponse { + fn output(exit_code: i32, stdout: &str, stderr: &str) -> Self { + Self::Output { + exit_code, + stdout: stdout.to_string(), + stderr: stderr.to_string(), + } + } + + fn success(stdout: &str) -> Self { + Self::output(0, stdout, "") + } + } + + impl MockCli { + fn new(responses: Vec) -> Self { + Self { + state: Mutex::new(MockCliState { + responses: responses.into(), + invocations: Vec::new(), + }), + } + } + + fn invocations(&self) -> Vec> { + self.state + .lock() + .expect("lock mock CLI state") + .invocations + .clone() + } + } + + impl CliExecutor for MockCli { + fn execute(&self, args: Vec, _command_timeout: Duration) -> CliExecution<'_> { + let response = { + let mut state = self.state.lock().expect("lock mock CLI state"); + state.invocations.push(args); + state + .responses + .pop_front() + .expect("mock CLI received an unexpected invocation") + }; + + Box::pin(async move { + match response { + MockResponse::Output { + exit_code, + stdout, + stderr, + } => Ok(Output { + status: ExitStatus::from_raw(exit_code << 8), + stdout: stdout.into_bytes(), + stderr: stderr.into_bytes(), + }), + MockResponse::Timeout => Err(CliExecutionError::Timeout), + } + }) + } + } + + fn test_runner(responses: Vec) -> (OpenShellRunner, Arc) { + let cli = Arc::new(MockCli::new(responses)); + let runner = OpenShellRunner::with_executor(cli.clone(), "smoke"); + (runner, cli) + } + + #[test] + fn with_binary_rejects_a_missing_file() { + let directory = tempfile::tempdir().expect("create missing CLI directory"); + let Err(error) = OpenShellRunner::with_binary(directory.path().join("missing"), "smoke") + else { + panic!("missing CLI should be rejected"); + }; + + assert!(matches!(error, RunnerError::BinaryUnavailable(_))); + } + + #[test] + fn exposes_generated_identity() { + let (mut runner, _cli) = test_runner(Vec::new()); + + assert_eq!(runner.id().len(), 10); + assert_eq!(runner.scenario(), "smoke"); + runner.finished = true; + } + + #[tokio::test] + async fn captures_streams_and_nonzero_exit_as_result() { + let (mut runner, _cli) = test_runner(vec![MockResponse::output(7, "stdout", "stderr")]); + + let result = runner + .step("capture") + .description("sandbox list succeeds") + .with_timeout(Duration::from_secs(1)) + .run(&["sandbox", "list"]) + .await + .expect("completed nonzero exit is a result"); + + assert_eq!(result.exit_code(), Some(7)); + assert_eq!(result.stdout(), "stdout"); + assert_eq!(result.stderr(), "stderr"); + assert!( + result + .require_success() + .expect_err("nonzero result should fail its expectation") + .contains("expected: sandbox list succeeds") + ); + runner.finished = true; + } + + #[tokio::test] + async fn reports_timeout_as_typed_error() { + let (mut runner, _cli) = test_runner(vec![MockResponse::Timeout]); + + let error = runner + .step("timeout") + .with_timeout(Duration::from_millis(10)) + .run(&["status"]) + .await + .expect_err("command should time out"); + + assert!(matches!(error, RunnerError::Timeout { .. })); + runner.finished = true; + } + + #[derive(Debug, Deserialize, PartialEq, Eq)] + struct JsonFixture { + status: String, + } + + #[tokio::test] + async fn deserializes_stdout_json_without_stderr() { + let (mut runner, _cli) = test_runner(vec![MockResponse::output( + 0, + "{\"status\":\"connected\"}", + "diagnostic", + )]); + + let result = runner + .step("json") + .with_timeout(Duration::from_secs(1)) + .run(&["status"]) + .await + .expect("run fake CLI"); + + assert_eq!( + result.json::().expect("parse JSON"), + JsonFixture { + status: "connected".to_string() + } + ); + runner.finished = true; + } + + #[tokio::test] + async fn accepts_connected_gateway_status() { + let (mut runner, _cli) = + test_runner(vec![MockResponse::success("{\"status\":\"connected\"}")]); + + runner + .check_gateway_status() + .await + .expect("connected gateway should satisfy preflight"); + + runner.finished = true; + } + + #[tokio::test] + async fn polling_returns_ready_value() { + let (mut runner, _cli) = test_runner(Vec::new()); + let mut attempts = 0; + + let value = runner + .poll_until( + "poll", + Duration::from_secs(1), + Duration::from_millis(1), + async |_runner| { + attempts += 1; + if attempts == 2 { + Poll::Ready("ready") + } else { + Poll::Pending("not ready".to_string()) + } + }, + ) + .await + .expect("poll should become ready"); + + assert_eq!(value, "ready"); + runner.finished = true; + } + + #[tokio::test] + async fn polling_timeout_preserves_last_observation() { + let (mut runner, _cli) = test_runner(Vec::new()); + + let error = runner + .poll_until( + "poll", + Duration::from_millis(1), + Duration::from_millis(1), + async |_runner| Poll::<()>::Pending("still pending".to_string()), + ) + .await + .expect_err("poll should time out"); + + assert!(matches!( + error, + RunnerError::PollTimeout { + last_observation, + .. + } if last_observation == "still pending" + )); + runner.finished = true; + } + + #[tokio::test] + async fn finish_skips_cleanup_commands_without_registered_resources() { + let (runner, cli) = test_runner(Vec::new()); + + let error = runner + .finish(Err("preflight failed".to_string())) + .await + .expect_err("functional failure should be preserved"); + + assert_eq!(error, "preflight failed"); + assert!(cli.invocations().is_empty()); + } + + #[test] + fn tracks_sandbox_for_cleanup() { + let (mut runner, _cli) = test_runner(Vec::new()); + + runner.track_sandbox("ct-0123456789-01"); + + assert!(runner.known_sandboxes.contains("ct-0123456789-01")); + runner.finished = true; + } + + #[test] + fn forgets_exact_name_cleanup() { + let (mut runner, _cli) = test_runner(Vec::new()); + + runner.track_sandbox("ct-0123456789-01"); + runner.forget_sandbox("ct-0123456789-01"); + + assert!(runner.known_sandboxes.is_empty()); + runner.finished = true; + } + + #[tokio::test] + async fn finish_deletes_explicitly_tracked_resources() { + let (mut runner, cli) = test_runner(vec![MockResponse::success("")]); + runner.track_sandbox("ct-0123456789-01"); + + runner + .finish(Ok(())) + .await + .expect("owned resource cleanup should succeed"); + + assert_eq!( + cli.invocations(), + vec![vec![ + "sandbox".to_string(), + "delete".to_string(), + "ct-0123456789-01".to_string(), + ]] + ); + } + + #[test] + fn functional_failure_remains_primary_when_cleanup_also_fails() { + let error = combine_results( + Err("functional failure".to_string()), + Err("cleanup failure".to_string()), + ) + .expect_err("combined result should fail"); + + assert!(error.starts_with("functional failure")); + assert!(error.contains("secondary cleanup failure:\ncleanup failure")); + } + + #[test] + fn generated_run_id_is_portable_and_compact() { + let run_id = generate_run_id(); + assert_eq!(run_id.len(), 10); + assert!( + run_id + .chars() + .all(|character| character.is_ascii_lowercase() || character.is_ascii_digit()) + ); + } + + #[test] + fn sanitizes_sensitive_cli_arguments() { + let command = sanitized_command(&[ + "provider".to_string(), + "create".to_string(), + "--credential".to_string(), + "TOKEN=secret".to_string(), + "--client-secret=hunter2".to_string(), + ]); + + assert!(!command.contains("TOKEN=secret")); + assert!(!command.contains("hunter2")); + assert!(command.contains("")); + } +} diff --git a/crates/openshell-conformance/src/scenarios/mod.rs b/crates/openshell-conformance/src/scenarios/mod.rs new file mode 100644 index 0000000000..bd6abdc7af --- /dev/null +++ b/crates/openshell-conformance/src/scenarios/mod.rs @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Registered, portable conformance scenarios. + +mod smoke; + +pub use smoke::SMOKE_SCENARIO; diff --git a/crates/openshell-conformance/src/scenarios/smoke.rs b/crates/openshell-conformance/src/scenarios/smoke.rs new file mode 100644 index 0000000000..7a71cb9099 --- /dev/null +++ b/crates/openshell-conformance/src/scenarios/smoke.rs @@ -0,0 +1,206 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Portable phase-1 CLI conformance scenario. + +use std::time::{Duration, Instant}; + +use crate::{OpenShellRunner, STATUS_TIMEOUT, Scenario, ScenarioFuture}; +use serde::Deserialize; +use tokio::time::sleep; + +const CREATE_TIMEOUT: Duration = Duration::from_secs(600); +const LIST_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(10); +const LIST_PAGE_SIZE: u32 = 1_000; +const EXEC_TIMEOUT: Duration = Duration::from_secs(120); +const DELETE_TIMEOUT: Duration = Duration::from_secs(120); +const DELETE_POLL_INTERVAL: Duration = Duration::from_secs(1); + +#[derive(Debug, Deserialize)] +struct SandboxListEntry { + name: String, + phase: String, +} + +/// Certify status -> create -> list Ready -> exec -> delete -> list empty. +pub const SMOKE_SCENARIO: Scenario = Scenario { + name: "smoke", + description: "Create, inspect, execute in, and delete a base sandbox.", + run: run_smoke, +}; + +fn run_smoke(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { + Box::pin(async move { run_smoke_inner(runner).await }) +} + +async fn run_smoke_inner(runner: &mut OpenShellRunner) -> Result<(), String> { + let status = runner + .step("status") + .description("openshell status succeeds") + .with_timeout(STATUS_TIMEOUT) + .run(&["status"]) + .await + .map_err(|error| error.to_string())?; + status.require_success()?; + + let sandbox_name = format!("ct-{}-01", runner.id()); + runner.track_sandbox(&sandbox_name); + let create = runner + .step("create") + .description("sandbox creation succeeds") + .with_timeout(CREATE_TIMEOUT) + .run(&[ + "sandbox", + "create", + "--name", + &sandbox_name, + "--from", + "base", + "--detach", + ]) + .await + .map_err(|error| error.to_string())?; + create.require_success()?; + + let get = runner + .step("get-ready") + .description(format!("sandbox '{sandbox_name}' can be retrieved")) + .with_timeout(LIST_ATTEMPT_TIMEOUT) + .run(&["sandbox", "get", &sandbox_name, "--output", "json"]) + .await + .map_err(|error| error.to_string())?; + get.require_success()?; + + let sandbox = get + .json::() + .map_err(|error| error.to_string())?; + if sandbox.name != sandbox_name { + return Err(format!( + "sandbox get returned {:?}; expected sandbox '{sandbox_name}'", + sandbox.name + )); + } + if sandbox.phase != "Ready" { + return Err(format!( + "sandbox '{sandbox_name}' is in phase {:?}; expected Ready", + sandbox.phase + )); + } + + check_sandbox_listed(runner, &sandbox_name).await?; + + let marker = format!("openshell-conformance-{}", runner.id()); + let exec = runner + .step("exec") + .description("sandbox exec exits successfully") + .with_timeout(EXEC_TIMEOUT) + .run(&[ + "sandbox", + "exec", + "--name", + &sandbox_name, + "--no-tty", + "--", + "echo", + &marker, + ]) + .await + .map_err(|error| error.to_string())?; + exec.require_success()?; + let expected_stdout = format!("{marker}\n"); + if exec.stdout() != expected_stdout { + return Err(exec.failure_diagnostic(&format!("stdout is exactly {expected_stdout:?}"))); + } + + let delete = runner + .step("delete") + .description("sandbox deletion succeeds") + .with_timeout(DELETE_TIMEOUT) + .run(&["sandbox", "delete", &sandbox_name]) + .await + .map_err(|error| error.to_string())?; + delete.require_success()?; + + check_empty_list(runner, &sandbox_name).await?; + runner.forget_sandbox(&sandbox_name); + Ok(()) +} + +async fn check_sandbox_listed(runner: &OpenShellRunner, sandbox_name: &str) -> Result<(), String> { + if find_sandbox(runner, sandbox_name, "list-visible") + .await? + .is_some() + { + return Ok(()); + } + Err(format!( + "sandbox '{sandbox_name}' does not appear in sandbox list" + )) +} + +async fn check_empty_list(runner: &OpenShellRunner, sandbox_name: &str) -> Result<(), String> { + let started = Instant::now(); + + loop { + match find_sandbox(runner, sandbox_name, "list-empty/query").await? { + None => return Ok(()), + Some(sandbox) if started.elapsed() >= DELETE_TIMEOUT => { + return Err(format!( + "sandbox '{sandbox_name}' remains listed in phase {:?} after {DELETE_TIMEOUT:.1?}", + sandbox.phase + )); + } + Some(_) => sleep(DELETE_POLL_INTERVAL).await, + } + } +} + +async fn find_sandbox( + runner: &OpenShellRunner, + sandbox_name: &str, + step: &str, +) -> Result, String> { + let mut offset = 0u32; + + loop { + let limit = LIST_PAGE_SIZE.to_string(); + let page_offset = offset.to_string(); + let result = runner + .step(format!("{step}/{offset}")) + .description(format!("sandbox list page at offset {offset} succeeds")) + .with_timeout(LIST_ATTEMPT_TIMEOUT) + .run(&[ + "sandbox", + "list", + "--limit", + &limit, + "--offset", + &page_offset, + "--output", + "json", + ]) + .await + .map_err(|error| error.to_string())?; + result.require_success()?; + + let sandboxes = result + .json::>() + .map_err(|error| error.to_string())?; + if let Some(sandbox) = sandboxes + .iter() + .find(|sandbox| sandbox.name == sandbox_name) + { + return Ok(Some(SandboxListEntry { + name: sandbox.name.clone(), + phase: sandbox.phase.clone(), + })); + } + if sandboxes.len() < LIST_PAGE_SIZE as usize { + return Ok(None); + } + + offset = offset + .checked_add(LIST_PAGE_SIZE) + .ok_or_else(|| "sandbox list pagination offset overflowed".to_string())?; + } +} diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 44881e1682..6dca586879 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -16,6 +16,7 @@ license = "Apache-2.0" publish = false [features] +# Selects the common E2E suite. e2e = [] # Selects tests that rely on `host.openshell.internal` (the sandbox's stable # alias to the host running test fixtures). docker, podman, and vm wire the @@ -47,6 +48,11 @@ name = "provider_refresh_keycloak" path = "tests/provider_refresh_keycloak.rs" required-features = ["e2e-provider-refresh-keycloak"] +[[test]] +name = "vm_overlay" +path = "tests/vm_overlay.rs" +required-features = ["e2e-vm"] + [[test]] name = "custom_image" path = "tests/custom_image.rs" diff --git a/e2e/rust/e2e-docker.sh b/e2e/rust/e2e-docker.sh index 6c28868083..ba97b85a5b 100755 --- a/e2e/rust/e2e-docker.sh +++ b/e2e/rust/e2e-docker.sh @@ -2,23 +2,47 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Run a Rust e2e test against a standalone gateway running the bundled Docker -# compute driver. Set OPENSHELL_GATEWAY_ENDPOINT=http://host:port to reuse an -# existing plaintext gateway instead of starting an ephemeral one. +# Run standalone CLI conformance, and optionally a focused Rust e2e test, +# against a gateway using the bundled Docker compute driver. Set +# OPENSHELL_GATEWAY_ENDPOINT=http://host:port to reuse an existing plaintext +# gateway instead of starting an ephemeral one. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -E2E_TEST="${OPENSHELL_E2E_DOCKER_TEST:-smoke}" -E2E_FEATURES="${OPENSHELL_E2E_DOCKER_FEATURES:-e2e,e2e-docker}" +E2E_TEST="${OPENSHELL_E2E_DOCKER_TEST:-}" +E2E_FEATURES="${OPENSHELL_E2E_DOCKER_FEATURES-e2e-docker}" DEFAULT_WORKLOAD_MANIFEST="${ROOT}/e2e/gpu/images/.build/workloads.yaml" +RUN_WITH_GATEWAY_COMMAND="__openshell_run_docker_e2e" +# shellcheck source=e2e/support/conformance.sh +source "${ROOT}/e2e/support/conformance.sh" + +run_e2e() { + e2e_run_openshell_conformance "Docker" + + if [ -z "${E2E_FEATURES}" ]; then + return 0 + fi + + local cargo_args=( + cargo test --manifest-path "${ROOT}/e2e/rust/Cargo.toml" + --features "${E2E_FEATURES}" + ) + if [ -n "${E2E_TEST}" ]; then + cargo_args+=(--test "${E2E_TEST}") + fi + cargo_args+=(-- --nocapture) + "${cargo_args[@]}" +} if [ "${E2E_TEST}" = "gpu" ] && [ -z "${OPENSHELL_E2E_WORKLOAD_MANIFEST:-}" ] && [ ! -f "${DEFAULT_WORKLOAD_MANIFEST}" ]; then echo "note: running GPU e2e without a workload manifest; workload validation will log an explicit skip. Build one with 'mise run e2e:workloads:build' or set OPENSHELL_E2E_WORKLOAD_MANIFEST." fi +if [ "${1:-}" = "${RUN_WITH_GATEWAY_COMMAND}" ]; then + run_e2e + exit 0 +fi + exec "${ROOT}/e2e/with-docker-gateway.sh" \ - cargo test --manifest-path "${ROOT}/e2e/rust/Cargo.toml" \ - --features "${E2E_FEATURES}" \ - --test "${E2E_TEST}" \ - -- --nocapture + bash "${BASH_SOURCE[0]}" "${RUN_WITH_GATEWAY_COMMAND}" diff --git a/e2e/rust/e2e-kubernetes.sh b/e2e/rust/e2e-kubernetes.sh index cf28e35728..c54057bb94 100755 --- a/e2e/rust/e2e-kubernetes.sh +++ b/e2e/rust/e2e-kubernetes.sh @@ -6,7 +6,7 @@ # via Helm. Set OPENSHELL_E2E_KUBE_CONTEXT to target an existing cluster; # otherwise an ephemeral k3d cluster is created and torn down by # with-kube-gateway.sh. Set OPENSHELL_E2E_KUBE_TEST to scope to a single -# integration test (e.g. smoke) for local debugging. +# integration test for local debugging. # # Features: the default set includes `e2e-host-gateway` so tests that rely on # the sandbox-side `host.openshell.internal` alias compile and run. The @@ -18,14 +18,18 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +RUN_WITH_GATEWAY_COMMAND="__openshell_run_kubernetes_e2e" +# shellcheck source=e2e/support/conformance.sh +source "${ROOT}/e2e/support/conformance.sh" -E2E_FEATURES="${OPENSHELL_E2E_KUBERNETES_FEATURES:-e2e,e2e-host-gateway,e2e-kubernetes}" +E2E_FEATURES="${OPENSHELL_E2E_KUBERNETES_FEATURES-e2e,e2e-host-gateway,e2e-kubernetes}" # Docker and Podman build their local gateway and CLI together in the shared # gateway wrapper. Kubernetes consumes published gateway images, so only its # local CLI needs to be built when CI has not supplied a prebuilt one. if [ -z "${OPENSHELL_BIN:-}" ]; then cargo build -p openshell-cli + export OPENSHELL_BIN="${ROOT}/target/debug/openshell" fi test_filter=() @@ -33,15 +37,42 @@ if [ -n "${OPENSHELL_E2E_KUBE_TEST:-}" ]; then test_filter+=(--test "${OPENSHELL_E2E_KUBE_TEST}") fi +is_operator_workspace_mode() { + [[ ",${E2E_FEATURES}," == *",e2e-kubernetes-workspace-operator,"* ]] +} + +run_conformance() { + if is_operator_workspace_mode; then + echo "note: skipping standalone CLI conformance in Kubernetes operator workspace mode; default workspace is not operator-allowlisted (see #2971)." + return 0 + fi + + e2e_run_openshell_conformance "Kubernetes" +} + run_suite() { "${ROOT}/e2e/with-kube-gateway.sh" \ - cargo test --manifest-path "${ROOT}/e2e/rust/Cargo.toml" \ - --features "${E2E_FEATURES}" \ - --no-fail-fast \ - ${test_filter[@]+"${test_filter[@]}"} \ - -- --nocapture + bash "${BASH_SOURCE[0]}" "${RUN_WITH_GATEWAY_COMMAND}" } +run_e2e() { + run_conformance + if [ -z "${E2E_FEATURES}" ]; then + return 0 + fi + + cargo test --manifest-path "${ROOT}/e2e/rust/Cargo.toml" \ + --features "${E2E_FEATURES}" \ + --no-fail-fast \ + ${test_filter[@]+"${test_filter[@]}"} \ + -- --nocapture +} + +if [ "${1:-}" = "${RUN_WITH_GATEWAY_COMMAND}" ]; then + run_e2e + exit 0 +fi + if [ "${OPENSHELL_E2E_CREDENTIAL_DRIVERS:-0}" = "1" ] \ && [ -z "${OPENSHELL_E2E_CREDENTIAL_DRIVER:-}" ]; then OPENSHELL_E2E_CREDENTIAL_DRIVER=kubernetes-secrets run_suite @@ -50,8 +81,4 @@ if [ "${OPENSHELL_E2E_CREDENTIAL_DRIVERS:-0}" = "1" ] \ fi exec "${ROOT}/e2e/with-kube-gateway.sh" \ - cargo test --manifest-path "${ROOT}/e2e/rust/Cargo.toml" \ - --features "${E2E_FEATURES}" \ - --no-fail-fast \ - ${test_filter[@]+"${test_filter[@]}"} \ - -- --nocapture + bash "${BASH_SOURCE[0]}" "${RUN_WITH_GATEWAY_COMMAND}" diff --git a/e2e/rust/e2e-podman.sh b/e2e/rust/e2e-podman.sh index f5f1effd18..736519458e 100755 --- a/e2e/rust/e2e-podman.sh +++ b/e2e/rust/e2e-podman.sh @@ -10,28 +10,43 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" E2E_TEST="${OPENSHELL_E2E_PODMAN_TEST:-}" -E2E_FEATURES="${OPENSHELL_E2E_PODMAN_FEATURES:-e2e-podman}" +E2E_FEATURES="${OPENSHELL_E2E_PODMAN_FEATURES-e2e-podman}" DEFAULT_WORKLOAD_MANIFEST="${ROOT}/e2e/gpu/images/.build/workloads.yaml" +RUN_WITH_GATEWAY_COMMAND="__openshell_run_podman_e2e" +# shellcheck source=e2e/support/conformance.sh +source "${ROOT}/e2e/support/conformance.sh" if [ "${E2E_TEST}" = "gpu" ] && [ -z "${OPENSHELL_E2E_WORKLOAD_MANIFEST:-}" ] && [ ! -f "${DEFAULT_WORKLOAD_MANIFEST}" ]; then echo "note: running Podman GPU e2e without a workload manifest; workload validation will log an explicit skip. Build one with 'CONTAINER_ENGINE=podman mise run e2e:workloads:build' or set OPENSHELL_E2E_WORKLOAD_MANIFEST." fi +if [ "${1:-}" = "${RUN_WITH_GATEWAY_COMMAND}" ]; then + e2e_run_openshell_conformance "Podman" + if [ -z "${E2E_FEATURES}" ]; then + exit 0 + fi + + TEST_ARGS=( + cargo test --manifest-path "${ROOT}/e2e/rust/Cargo.toml" + --features "${E2E_FEATURES}" + ) + if [ -n "${E2E_TEST}" ]; then + TEST_ARGS+=(--test "${E2E_TEST}") + fi + TEST_ARGS+=(-- --nocapture) + "${TEST_ARGS[@]}" + exit 0 +fi + # An empty selector runs the full Podman suite, including provider_token_exchange. if [ -z "${E2E_TEST}" ] || [ "${E2E_TEST}" = "provider_token_exchange" ]; then export OPENSHELL_E2E_SPIFFE_FIXTURE="${OPENSHELL_E2E_SPIFFE_FIXTURE:-1}" fi -cargo build -p openshell-cli - -TEST_ARGS=( - cargo test --manifest-path "${ROOT}/e2e/rust/Cargo.toml" - --features "${E2E_FEATURES}" -) -if [ -n "${E2E_TEST}" ]; then - TEST_ARGS+=(--test "${E2E_TEST}") +if [ -n "${OPENSHELL_GATEWAY_ENDPOINT:-}" ] && [ -z "${OPENSHELL_BIN:-}" ]; then + cargo build -p openshell-cli + export OPENSHELL_BIN="${ROOT}/target/debug/openshell" fi -TEST_ARGS+=(-- --nocapture) exec "${ROOT}/e2e/with-podman-gateway.sh" \ - "${TEST_ARGS[@]}" + bash "${BASH_SOURCE[0]}" "${RUN_WITH_GATEWAY_COMMAND}" diff --git a/e2e/rust/e2e-vm.sh b/e2e/rust/e2e-vm.sh index 4acb7256bb..de7e3ad09d 100755 --- a/e2e/rust/e2e-vm.sh +++ b/e2e/rust/e2e-vm.sh @@ -46,13 +46,15 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" source "${ROOT}/e2e/support/gateway-common.sh" +# shellcheck source=e2e/support/conformance.sh +source "${ROOT}/e2e/support/conformance.sh" COMPRESSED_DIR="${ROOT}/target/vm-runtime-compressed" GATEWAY_BIN="${OPENSHELL_GATEWAY_BIN:-${ROOT}/target/debug/openshell-gateway}" DRIVER_BIN="${OPENSHELL_VM_DRIVER_BIN:-${ROOT}/target/debug/openshell-driver-vm}" CLI_BIN="${OPENSHELL_BIN:-${ROOT}/target/debug/openshell}" E2E_TEST_OVERRIDE="${OPENSHELL_E2E_VM_TEST:-}" -E2E_FEATURES="${OPENSHELL_E2E_VM_FEATURES:-e2e-vm}" +E2E_FEATURES="${OPENSHELL_E2E_VM_FEATURES-e2e-vm}" SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-${COMMUNITY_SANDBOX_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/base:latest}}" # The VM driver places `compute-driver.sock` under `[openshell.drivers.vm].state_dir`. @@ -371,7 +373,6 @@ fi # The CLI uses the raw endpoint but still resolves matching metadata so it # can find the mTLS client bundle. -export OPENSHELL_E2E_EXPECT_VM_OVERLAY=1 export OPENSHELL_E2E_DRIVER="vm" export OPENSHELL_E2E_VM_STATE_DIR="${RUN_STATE_DIR}" e2e_export_gateway_restart_metadata \ @@ -386,6 +387,8 @@ e2e_export_gateway_restart_metadata \ # preparation; allow 180s for slower CI runners. export OPENSHELL_PROVISION_TIMEOUT="${SANDBOX_PROVISION_TIMEOUT}" +e2e_run_openshell_conformance "VM" + run_e2e_test() { local test_target="$1" shift @@ -403,7 +406,7 @@ run_e2e_test() { if [ -n "${E2E_TEST_OVERRIDE}" ]; then run_e2e_test "${E2E_TEST_OVERRIDE}" else - run_e2e_test smoke run_e2e_test host_gateway_alias + run_e2e_test vm_overlay run_e2e_test vm_gateway_start fi diff --git a/e2e/rust/tests/smoke.rs b/e2e/rust/tests/smoke.rs deleted file mode 100644 index c27255e5ef..0000000000 --- a/e2e/rust/tests/smoke.rs +++ /dev/null @@ -1,139 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -#![cfg(feature = "e2e")] - -//! Smoke test: verify the gateway is healthy, create a sandbox, exec a -//! command inside it, and tear it down. -//! -//! This test is cluster-agnostic — it works against any running gateway -//! (Docker-based cluster or openshell-driver-vm microVM). The `e2e:vm` mise -//! task uses it to validate the VM gateway after boot. - -use std::process::Stdio; -use std::time::Duration; - -use openshell_e2e::harness::binary::openshell_cmd; -use openshell_e2e::harness::output::strip_ansi; -use openshell_e2e::harness::sandbox::SandboxGuard; - -/// End-to-end smoke test: status → create → exec → list → delete. -#[tokio::test] -async fn gateway_smoke() { - // ── 1. Gateway must be reachable ────────────────────────────────── - let mut clean_status = String::new(); - let mut status_ok = false; - for _ in 0..15 { - let mut status_cmd = openshell_cmd(); - status_cmd - .arg("status") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let status_out = status_cmd - .output() - .await - .expect("failed to run openshell status"); - - let status_text = format!( - "{}{}", - String::from_utf8_lossy(&status_out.stdout), - String::from_utf8_lossy(&status_out.stderr), - ); - clean_status = strip_ansi(&status_text); - - if status_out.status.success() && clean_status.contains("Connected") { - status_ok = true; - break; - } - - tokio::time::sleep(Duration::from_secs(2)).await; - } - - assert!( - status_ok, - "openshell status never became healthy:\n{clean_status}", - ); - - // ── 2. Create a sandbox and exec a command ─────────────────────── - // Default behaviour keeps the sandbox alive after the command exits, - // so we can verify it in the list before cleaning up. - let mut sb = SandboxGuard::create(&["--", "echo", "smoke-ok"]) - .await - .expect("sandbox create should succeed"); - - assert!( - sb.create_output.contains("smoke-ok"), - "expected 'smoke-ok' in sandbox output:\n{}", - sb.create_output, - ); - - if std::env::var_os("OPENSHELL_E2E_EXPECT_VM_OVERLAY").is_some() { - assert_vm_overlay_root(&sb.name).await; - } - - // ── 3. Verify the sandbox appeared in the list ─────────────────── - let mut list_cmd = openshell_cmd(); - list_cmd - .args(["sandbox", "list", "--names"]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let list_out = list_cmd - .output() - .await - .expect("failed to run openshell sandbox list"); - - let list_text = strip_ansi(&format!( - "{}{}", - String::from_utf8_lossy(&list_out.stdout), - String::from_utf8_lossy(&list_out.stderr), - )); - - assert!( - list_text.contains(&sb.name), - "sandbox '{}' should appear in list output:\n{list_text}", - sb.name, - ); - - // ── 4. Cleanup ─────────────────────────────────────────────────── - sb.cleanup().await; -} - -async fn assert_vm_overlay_root(sandbox_name: &str) { - let script = concat!( - "set -eu; ", - "test \"$(stat -f -c %T /)\" = \"overlayfs\"; ", - "printf \"overlay-write\\n\" > /sandbox/overlay-check; ", - "test \"$(cat /sandbox/overlay-check)\" = \"overlay-write\"; ", - "if [ -e /opt/openshell/tls/tls.key ]; then ", - "test \"$(stat -c %a /opt/openshell/tls/tls.key)\" = \"600\"; ", - "fi; ", - "echo vm-overlay-ok", - ); - - let mut exec_cmd = openshell_cmd(); - exec_cmd - .args(["sandbox", "exec", "--name", sandbox_name, "--no-tty", "--"]) - .arg("sh") - .arg("-lc") - .arg(script) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let output = exec_cmd - .output() - .await - .expect("failed to run VM overlay assertion"); - let combined = strip_ansi(&format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - )); - - assert!( - output.status.success() && combined.contains("vm-overlay-ok"), - "VM overlay assertion failed (status {:?}):\n{combined}", - output.status.code(), - ); -} diff --git a/e2e/rust/tests/vm_overlay.rs b/e2e/rust/tests/vm_overlay.rs new file mode 100644 index 0000000000..078c6f01de --- /dev/null +++ b/e2e/rust/tests/vm_overlay.rs @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! VM-driver-specific assertions for the sandbox root filesystem. + +use std::process::Stdio; + +use openshell_e2e::harness::binary::openshell_cmd; +use openshell_e2e::harness::output::strip_ansi; +use openshell_e2e::harness::sandbox::SandboxGuard; + +#[tokio::test] +async fn vm_overlay() { + let mut sandbox = SandboxGuard::create(&["--", "echo", "vm-sandbox-ready"]) + .await + .expect("sandbox create should succeed"); + + let script = concat!( + "set -eu; ", + "test \"$(stat -f -c %T /)\" = \"overlayfs\"; ", + "printf \"overlay-write\\n\" > /sandbox/overlay-check; ", + "test \"$(cat /sandbox/overlay-check)\" = \"overlay-write\"; ", + "if [ -e /opt/openshell/tls/tls.key ]; then ", + "test \"$(stat -c %a /opt/openshell/tls/tls.key)\" = \"600\"; ", + "fi; ", + "echo vm-overlay-ok", + ); + + let mut exec_cmd = openshell_cmd(); + exec_cmd + .args(["sandbox", "exec", "--name", &sandbox.name, "--no-tty", "--"]) + .arg("sh") + .arg("-lc") + .arg(script) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let output = exec_cmd + .output() + .await + .expect("failed to run VM overlay assertion"); + let combined = strip_ansi(&format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + )); + assert!( + output.status.success() && combined.contains("vm-overlay-ok"), + "VM overlay assertion failed (status {:?}):\n{combined}", + output.status.code(), + ); + + sandbox.cleanup().await; +} diff --git a/e2e/support/conformance.sh b/e2e/support/conformance.sh new file mode 100644 index 0000000000..dd2cbcc596 --- /dev/null +++ b/e2e/support/conformance.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Shared helpers for running standalone conformance suites against an already +# configured e2e gateway. + +e2e_run_openshell_conformance() { + local gateway_label=${1:-OpenShell} + + if [ -z "${OPENSHELL_BIN:-}" ]; then + echo "ERROR: OPENSHELL_BIN must point to the openshell CLI under test" >&2 + return 2 + fi + + if [ -z "${OPENSHELL_CONFORMANCE_BIN:-}" ]; then + echo "ERROR: OPENSHELL_CONFORMANCE_BIN must point to the openshell-conformance CLI under test" >&2 + return 2 + fi + + if [ ! -x "${OPENSHELL_CONFORMANCE_BIN}" ]; then + echo "ERROR: openshell conformance binary is not executable: ${OPENSHELL_CONFORMANCE_BIN}" >&2 + return 2 + fi + + echo "==> Running standalone CLI conformance against the ${gateway_label} gateway" + "${OPENSHELL_CONFORMANCE_BIN}" run \ + --openshell-bin "${OPENSHELL_BIN}" \ + --output json +} diff --git a/e2e/with-docker-gateway.sh b/e2e/with-docker-gateway.sh index 958ea1b0eb..0a767576dd 100755 --- a/e2e/with-docker-gateway.sh +++ b/e2e/with-docker-gateway.sh @@ -437,6 +437,7 @@ ensure_sandbox_image_available() { } e2e_build_gateway_binaries "${ROOT}" TARGET_DIR GATEWAY_BIN CLI_BIN +export OPENSHELL_BIN="${CLI_BIN}" if [ "${OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER:-0}" = "1" ]; then e2e_build_external_driver \ "${ROOT}" openshell-driver-docker openshell-driver-docker DRIVER_BIN diff --git a/e2e/with-podman-gateway.sh b/e2e/with-podman-gateway.sh index dcb2d43fe0..fc3419e182 100755 --- a/e2e/with-podman-gateway.sh +++ b/e2e/with-podman-gateway.sh @@ -383,6 +383,7 @@ fi ensure_podman_api_socket e2e_build_gateway_binaries "${ROOT}" TARGET_DIR GATEWAY_BIN CLI_BIN +export OPENSHELL_BIN="${CLI_BIN}" if [ "${OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER:-0}" = "1" ]; then e2e_build_external_driver \ "${ROOT}" openshell-driver-podman openshell-driver-podman DRIVER_BIN diff --git a/tasks/test.toml b/tasks/test.toml index 29e06b826a..9a1466963e 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -82,8 +82,20 @@ hide = true ["e2e:rust"] description = "Run Rust CLI e2e tests against a Docker-backed gateway" +depends = ["e2e:conformance:build"] +run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-docker.sh" + +["e2e:conformance:build"] +description = "Build the standalone CLI conformance binary" +run = "if [ -z \"${OPENSHELL_CONFORMANCE_BIN:-}\" ]; then cargo build -p openshell-conformance-cli; fi" +hide = true + +["e2e:cli-conformance"] +description = "Build and run the standalone CLI conformance suite against the configured gateway" +depends = ["e2e:conformance:build"] run = [ - "e2e/with-docker-gateway.sh cargo test --manifest-path e2e/rust/Cargo.toml --features e2e-docker", + "if [ -z \"${OPENSHELL_BIN:-}\" ]; then cargo build -p openshell-cli; fi", + "\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" run --openshell-bin \"${OPENSHELL_BIN:-$PWD/target/debug/openshell}\"", ] ["e2e:websocket-conformance"] @@ -108,7 +120,8 @@ run = "e2e/with-docker-gateway.sh uv run pytest -o python_files='test_*.py *_tes ["e2e:podman"] description = "Run Rust CLI e2e tests against a Podman-backed gateway" -run = "e2e/rust/e2e-podman.sh" +depends = ["e2e:conformance:build"] +run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-podman.sh" ["e2e:oidc-pkce"] description = "Run Linux browser PKCE and RBAC e2e tests against Keycloak and a Podman gateway" @@ -139,80 +152,95 @@ run = [ ["e2e:podman:gpu"] description = "Run GPU e2e against a standalone gateway with the Podman compute driver" env = { OPENSHELL_E2E_PODMAN_GPU = "1", OPENSHELL_E2E_PODMAN_TEST = "gpu", OPENSHELL_E2E_PODMAN_FEATURES = "e2e-podman-gpu" } -run = "e2e/rust/e2e-podman.sh" +depends = ["e2e:conformance:build"] +run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-podman.sh" ["e2e:kubernetes"] description = "Run Rust CLI e2e tests against an OpenShell gateway deployed on Kubernetes via Helm (set OPENSHELL_E2E_KUBE_CONTEXT to reuse a cluster; otherwise creates a local k3d cluster when k3d is installed; set OPENSHELL_E2E_KUBE_TEST= to scope to one test)" -run = "e2e/rust/e2e-kubernetes.sh" +depends = ["e2e:conformance:build"] +run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-kubernetes.sh" ["e2e:kubernetes:v1alpha1"] description = "Run Kubernetes e2e against Agent Sandbox v1alpha1" env = { AGENT_SANDBOX_VERSION = "v0.4.6" } -run = "e2e/rust/e2e-kubernetes.sh" +depends = ["e2e:conformance:build"] +run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-kubernetes.sh" ["e2e:kubernetes:agent-sandbox-versions"] description = "Run Kubernetes e2e against Agent Sandbox v1beta1 and v1alpha1" +depends = ["e2e:conformance:build"] run = [ - "e2e/rust/e2e-kubernetes.sh", - "AGENT_SANDBOX_VERSION=v0.4.6 e2e/rust/e2e-kubernetes.sh", + "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-kubernetes.sh", + "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" AGENT_SANDBOX_VERSION=v0.4.6 e2e/rust/e2e-kubernetes.sh", ] ["e2e:kubernetes:sidecar"] description = "Run Kubernetes e2e with the supervisor sidecar topology overlay" env = { OPENSHELL_E2E_KUBE_EXTRA_VALUES = "deploy/helm/openshell/ci/values-sidecar.yaml" } -run = "e2e/rust/e2e-kubernetes.sh" +depends = ["e2e:conformance:build"] +run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-kubernetes.sh" ["e2e:kubernetes:db"] description = "Run Kubernetes e2e with all database backend scenarios (SQLite and external PostgreSQL with existingSecret)" env = { OPENSHELL_E2E_KUBE_DB_SCENARIOS = "1" } -run = "e2e/rust/e2e-kubernetes.sh" +depends = ["e2e:conformance:build"] +run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-kubernetes.sh" ["e2e:kubernetes:credential-drivers"] description = "Run Kubernetes e2e for provider credential storage backed by Kubernetes Secrets and Vault" env = { OPENSHELL_E2E_CREDENTIAL_DRIVERS = "1", OPENSHELL_E2E_KUBE_TEST = "credential_drivers", OPENSHELL_E2E_KUBERNETES_FEATURES = "e2e,e2e-kubernetes,e2e-kubernetes-credential-drivers" } -run = "e2e/rust/e2e-kubernetes.sh" +depends = ["e2e:conformance:build"] +run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-kubernetes.sh" ["e2e:kubernetes:workspace-managed"] description = "Run Kubernetes e2e with managed workspace mode (auto-created per-workspace namespaces)" env = { OPENSHELL_E2E_KUBE_EXTRA_VALUES = "deploy/helm/openshell/ci/values-workspace-managed.yaml", OPENSHELL_E2E_KUBE_IMAGE_PULL_SECRET = "e2e-regcred", OPENSHELL_E2E_KUBE_TEST = "workspace_namespace_managed", OPENSHELL_E2E_KUBERNETES_FEATURES = "e2e,e2e-kubernetes,e2e-kubernetes-workspace-managed" } -run = "e2e/rust/e2e-kubernetes.sh" +depends = ["e2e:conformance:build"] +run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-kubernetes.sh" ["e2e:kubernetes:workspace-operator"] description = "Run Kubernetes e2e with operator workspace mode (pre-provisioned per-workspace namespaces)" env = { OPENSHELL_E2E_KUBE_EXTRA_VALUES = "deploy/helm/openshell/ci/values-workspace-operator.yaml", OPENSHELL_E2E_KUBE_TEST = "workspace_namespace_operator", OPENSHELL_E2E_KUBERNETES_FEATURES = "e2e,e2e-kubernetes,e2e-kubernetes-workspace-operator" } -run = "e2e/rust/e2e-kubernetes.sh" +depends = ["e2e:conformance:build"] +run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-kubernetes.sh" ["e2e:vm"] description = "Start openshell-gateway with the VM compute driver and run VM e2e tests" -run = "e2e/rust/e2e-vm.sh" +depends = ["e2e:conformance:build"] +run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-vm.sh" ["e2e:gateway:no-compute-drivers"] description = "Build and launch-check openshell-gateway without compiled compute drivers" run = "bash e2e/no-compute-driver-gateway.sh" ["e2e:docker:external-driver"] -description = "Run Docker smoke E2E with a driver-free gateway and external Docker driver binary" -env = { OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER = "1" } -run = "e2e/rust/e2e-docker.sh" +description = "Run Docker conformance with a driver-free gateway and external Docker driver binary" +env = { OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER = "1", OPENSHELL_E2E_DOCKER_FEATURES = "" } +depends = ["e2e:conformance:build"] +run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-docker.sh" ["e2e:podman:external-driver"] -description = "Run Podman E2E with a driver-free gateway and external Podman driver binary" -env = { OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER = "1", OPENSHELL_E2E_PODMAN_TEST = "smoke" } -run = "e2e/rust/e2e-podman.sh" +description = "Run Podman conformance with a driver-free gateway and external Podman driver binary" +env = { OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER = "1", OPENSHELL_E2E_PODMAN_FEATURES = "" } +depends = ["e2e:conformance:build"] +run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-podman.sh" ["e2e:vm:external-driver"] description = "Run VM E2E with a driver-free gateway and external VM driver binary" env = { OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER = "1" } -run = "e2e/rust/e2e-vm.sh" +depends = ["e2e:conformance:build"] +run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-vm.sh" ["e2e:kubernetes:external-driver"] -description = "Run Kubernetes smoke E2E with a driver-free gateway and external Kubernetes driver sidecar" -env = { OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER = "1", OPENSHELL_E2E_KUBE_BUILD_IMAGES = "1", OPENSHELL_E2E_KUBE_TEST = "smoke" } -run = "e2e/rust/e2e-kubernetes.sh" +description = "Run Kubernetes conformance with a driver-free gateway and external Kubernetes driver sidecar" +env = { OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER = "1", OPENSHELL_E2E_KUBE_BUILD_IMAGES = "1", OPENSHELL_E2E_KUBERNETES_FEATURES = "" } +depends = ["e2e:conformance:build"] +run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-kubernetes.sh" ["e2e:docker"] -description = "Run smoke e2e against a standalone gateway with the Docker compute driver" -run = "e2e/rust/e2e-docker.sh" +description = "Run Docker conformance and Rust e2e tests against a standalone gateway" +depends = ["e2e:conformance:build"] +run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-docker.sh" ["e2e:mechanistic-smoke"] description = "Run mechanistic L4 smoke against a Docker-backed gateway" @@ -231,7 +259,8 @@ run = [ ["e2e:docker:gpu"] description = "Run GPU e2e against a standalone gateway with the Docker compute driver" env = { OPENSHELL_E2E_DOCKER_GPU = "1", OPENSHELL_E2E_DOCKER_TEST = "gpu", OPENSHELL_E2E_DOCKER_FEATURES = "e2e-docker-gpu" } -run = "e2e/rust/e2e-docker.sh" +depends = ["e2e:conformance:build"] +run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-docker.sh" ["e2e:openshift"] description = "Run OpenShift database-backend integration scenarios against a live cluster (requires oc CLI authenticated to an OpenShift cluster)" From f7180c0fd63a89249fa3a22b04689d6702d71886 Mon Sep 17 00:00:00 2001 From: alangou Date: Tue, 1 Sep 2026 13:57:14 +0000 Subject: [PATCH 061/505] feat(ci): add Codex Security release qualification (#3087) * feat(ci): add Codex Security release qualification Scan cumulative release-train diffs through NVIDIA inference and publish findings to Code Scanning. Signed-off-by: alangou * fix(ci): disable package cache for security scan Prevent cache poisoning in the tag-triggered Codex Security workflow. Signed-off-by: alangou * refactor(ci): simplify Codex Security reporting Remove custom inference cost accounting so the workflow remains focused on scanning and SARIF publication. Signed-off-by: alangou --------- Signed-off-by: alangou --- .github/workflows/codex-security.yml | 244 ++++++++++++++++++ .github/workflows/release-tag.yml | 1 + CI.md | 33 ++- architecture/build.md | 72 +++++- .../scripts/codex-security-release-range.mjs | 232 +++++++++++++++++ .../codex-security-release-range.test.mjs | 190 ++++++++++++++ tasks/test.toml | 6 + 7 files changed, 757 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/codex-security.yml create mode 100644 tasks/scripts/codex-security-release-range.mjs create mode 100644 tasks/scripts/codex-security-release-range.test.mjs diff --git a/.github/workflows/codex-security.yml b/.github/workflows/codex-security.yml new file mode 100644 index 0000000000..0749ec7d84 --- /dev/null +++ b/.github/workflows/codex-security.yml @@ -0,0 +1,244 @@ +# 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 + 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_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 }}) + 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" + + - 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 + + - 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 + + node tasks/scripts/codex-security-release-range.mjs "${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 + 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 + 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' \ + --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 + 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\`" + 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 + 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." diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 92eb4adf14..20d74436c6 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -4,6 +4,7 @@ on: push: tags: - "v*.*.*" + - "!v*.*.*-pre.*" workflow_dispatch: inputs: tag: diff --git a/CI.md b/CI.md index c0ec57f877..c29abdba0c 100644 --- a/CI.md +++ b/CI.md @@ -28,13 +28,15 @@ The GitHub ruleset should require the `OpenShell / ...` statuses published by `R ## 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 +52,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 @@ -187,6 +203,7 @@ 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`. | ## Release workflows @@ -195,7 +212,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.` 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 `). 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 diff --git a/architecture/build.md b/architecture/build.md index aa252dad0f..32c50aa397 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -290,22 +290,31 @@ 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 because it +needs a scoped API key. That key routes Codex Security's model calls to +NVIDIA-hosted inference; the job itself still runs on a GitHub-hosted runner and +uses no NVIDIA self-hosted runner. 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. 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 @@ -330,12 +339,49 @@ 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. 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. + `tasks/scripts/codex-security-release-range.mjs` resolves the scan range: 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. 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. 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. + +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. See `CI.md` for the contributor workflow, labels, and maintainer merge-queue workflow. diff --git a/tasks/scripts/codex-security-release-range.mjs b/tasks/scripts/codex-security-release-range.mjs new file mode 100644 index 0000000000..025df1e7ad --- /dev/null +++ b/tasks/scripts/codex-security-release-range.mjs @@ -0,0 +1,232 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync, spawnSync } from 'node:child_process'; +import { appendFileSync } from 'node:fs'; + +const STABLE_TAG_RE = + /^v(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)$/; +const PRERELEASE_TAG_RE = + /^v(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)-pre\.(?[1-9]\d*)$/; + +function versionFromMatch(match) { + return [ + Number(match.groups.major), + Number(match.groups.minor), + Number(match.groups.patch), + ]; +} + +export function parseStableTag(tag) { + const match = STABLE_TAG_RE.exec(tag); + if (match === null) return null; + return { tag, version: versionFromMatch(match) }; +} + +export function parsePrereleaseTag(tag) { + const match = PRERELEASE_TAG_RE.exec(tag); + if (match === null) return null; + const version = versionFromMatch(match); + return { + tag, + version, + prerelease: Number(match.groups.prerelease), + train: `v${version.join('.')}`, + }; +} + +export function compareVersions(left, right) { + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) return left[index] - right[index]; + } + return 0; +} + +export function selectPreviousStable(tags, candidateVersion) { + return tags + .map(parseStableTag) + .filter( + (parsed) => + parsed !== null && + compareVersions(parsed.version, candidateVersion) < 0, + ) + .sort((left, right) => compareVersions(right.version, left.version))[0]?.tag; +} + +function parseArguments(argv) { + const options = { + candidate: '', + stable: '', + mainRef: 'origin/main', + allowFullBootstrap: false, + githubOutput: process.env.GITHUB_OUTPUT ?? '', + }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + switch (argument) { + case '--candidate': + options.candidate = argv[++index] ?? ''; + break; + case '--stable': + options.stable = argv[++index] ?? ''; + break; + case '--main-ref': + options.mainRef = argv[++index] ?? ''; + break; + case '--allow-full-bootstrap': + options.allowFullBootstrap = true; + break; + case '--github-output': + options.githubOutput = argv[++index] ?? ''; + break; + default: + throw new Error(`unknown argument: ${argument}`); + } + } + + if (options.candidate === '') { + throw new Error('--candidate is required'); + } + if (options.mainRef === '') { + throw new Error('--main-ref must not be empty'); + } + return options; +} + +function git(args) { + return execFileSync('git', args, { encoding: 'utf8' }).trim(); +} + +function resolvesToCommit(ref) { + try { + return git(['rev-parse', '--verify', `${ref}^{commit}`]); + } catch { + throw new Error(`Git reference does not resolve to a commit: ${ref}`); + } +} + +function isAncestor(ancestor, descendant) { + const result = spawnSync( + 'git', + ['merge-base', '--is-ancestor', ancestor, descendant], + { stdio: 'ignore' }, + ); + if (result.status === 0) return true; + if (result.status === 1) return false; + throw new Error( + `git merge-base failed for ${ancestor} and ${descendant} (exit ${result.status ?? 'unknown'})`, + ); +} + +function writeOutputs(path, outputs) { + if (path === '') return; + const lines = Object.entries(outputs).map(([key, value]) => `${key}=${value}`); + appendFileSync(path, `${lines.join('\n')}\n`, { encoding: 'utf8' }); +} + +function resolveRange(options) { + const candidate = parsePrereleaseTag(options.candidate); + if (candidate === null) { + throw new Error( + `candidate must match vMAJOR.MINOR.PATCH-pre.N: ${options.candidate}`, + ); + } + + const candidateSha = resolvesToCommit(candidate.tag); + const mainSha = resolvesToCommit(options.mainRef); + if (!isAncestor(candidateSha, mainSha)) { + throw new Error( + `candidate ${candidate.tag} (${candidateSha}) is not an ancestor of ${options.mainRef}`, + ); + } + + const mergedTags = git(['tag', '--list', 'v*', '--merged', candidateSha]) + .split('\n') + .filter(Boolean); + const stableTag = + options.stable || selectPreviousStable(mergedTags, candidate.version); + + if (stableTag === undefined || stableTag === '') { + if (!options.allowFullBootstrap) { + throw new Error( + `no previous stable tag exists for ${candidate.tag}; rerun with an approved base or --allow-full-bootstrap`, + ); + } + + return { + base_tag: '', + base_sha: '', + candidate_tag: candidate.tag, + candidate_sha: candidateSha, + train: candidate.train, + category: `codex-security/${candidate.train}`, + scan_scope: 'full', + commit_count: git(['rev-list', '--count', candidateSha]), + }; + } + + const stable = parseStableTag(stableTag); + if (stable === null) { + throw new Error( + `stable base must match vMAJOR.MINOR.PATCH without a prerelease: ${stableTag}`, + ); + } + if (compareVersions(stable.version, candidate.version) >= 0) { + throw new Error( + `stable base ${stable.tag} must be older than release train ${candidate.train}`, + ); + } + + const stableSha = resolvesToCommit(stable.tag); + if (!isAncestor(stableSha, candidateSha)) { + throw new Error( + `stable base ${stable.tag} (${stableSha}) is not an ancestor of ${candidate.tag}`, + ); + } + + const commitCount = Number( + git(['rev-list', '--count', `${stableSha}..${candidateSha}`]), + ); + if (!Number.isSafeInteger(commitCount) || commitCount <= 0) { + throw new Error( + `candidate ${candidate.tag} has no commits after stable base ${stable.tag}`, + ); + } + + return { + base_tag: stable.tag, + base_sha: stableSha, + candidate_tag: candidate.tag, + candidate_sha: candidateSha, + train: candidate.train, + category: `codex-security/${candidate.train}`, + scan_scope: 'diff', + commit_count: String(commitCount), + }; +} + +function main() { + try { + const options = parseArguments(process.argv.slice(2)); + const outputs = resolveRange(options); + writeOutputs(options.githubOutput, outputs); + process.stdout.write(`${JSON.stringify(outputs, null, 2)}\n`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (process.env.GITHUB_ACTIONS === 'true') { + process.stderr.write(`::error::${message}\n`); + } else { + process.stderr.write(`codex-security-release-range: ${message}\n`); + } + process.exitCode = 1; + } +} + +if ( + process.argv[1] !== undefined && + import.meta.url === new URL(process.argv[1], 'file:').href +) { + main(); +} diff --git a/tasks/scripts/codex-security-release-range.test.mjs b/tasks/scripts/codex-security-release-range.test.mjs new file mode 100644 index 0000000000..ea017a8825 --- /dev/null +++ b/tasks/scripts/codex-security-release-range.test.mjs @@ -0,0 +1,190 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +import { + compareVersions, + parsePrereleaseTag, + parseStableTag, + selectPreviousStable, +} from './codex-security-release-range.mjs'; + +const SCRIPT = fileURLToPath( + new URL('./codex-security-release-range.mjs', import.meta.url), +); + +function git(repository, ...args) { + return execFileSync('git', args, { + cwd: repository, + encoding: 'utf8', + }).trim(); +} + +function commit(repository, name) { + writeFileSync(join(repository, 'content.txt'), `${name}\n`, { + encoding: 'utf8', + }); + git(repository, 'add', 'content.txt'); + git( + repository, + '-c', + 'user.name=Codex Security Test', + '-c', + 'user.email=codex-security-test@example.com', + 'commit', + '-m', + name, + ); +} + +function createRepository() { + const repository = mkdtempSync(join(tmpdir(), 'codex-security-range-')); + git(repository, 'init', '--initial-branch=main'); + return repository; +} + +function runResolver(repository, ...args) { + return JSON.parse( + execFileSync(process.execPath, [SCRIPT, ...args], { + cwd: repository, + encoding: 'utf8', + }), + ); +} + +test('parses strict stable and prerelease tags', () => { + assert.deepEqual(parseStableTag('v0.1.0'), { + tag: 'v0.1.0', + version: [0, 1, 0], + }); + assert.equal(parseStableTag('v0.1.0-pre.1'), null); + assert.equal(parseStableTag('dev'), null); + assert.equal(parseStableTag('v01.1.0'), null); + + assert.deepEqual(parsePrereleaseTag('v2.10.3-pre.12'), { + tag: 'v2.10.3-pre.12', + version: [2, 10, 3], + prerelease: 12, + train: 'v2.10.3', + }); + assert.equal(parsePrereleaseTag('v2.10.3'), null); + assert.equal(parsePrereleaseTag('v2.10.3-pre.0'), null); +}); + +test('selects the newest stable strictly before the candidate train', () => { + assert.equal( + selectPreviousStable( + [ + 'v0.1.9', + 'v0.1.10', + 'v0.2.0-pre.1', + 'v0.2.0', + 'vm-runtime', + ], + [0, 2, 0], + ), + 'v0.1.10', + ); + assert.equal(selectPreviousStable(['v0.1.0'], [0, 1, 0]), undefined); + assert(compareVersions([0, 10, 0], [0, 2, 99]) > 0); +}); + +test('resolves a cumulative prerelease range from Git history', () => { + const repository = createRepository(); + try { + commit(repository, 'stable'); + git(repository, 'tag', 'v0.1.0'); + commit(repository, 'pre one'); + git(repository, 'tag', 'v0.1.1-pre.1'); + commit(repository, 'pre two'); + git(repository, 'tag', 'v0.1.1-pre.2'); + git( + repository, + 'update-ref', + 'refs/remotes/origin/main', + git(repository, 'rev-parse', 'HEAD'), + ); + + const result = runResolver( + repository, + '--candidate', + 'v0.1.1-pre.2', + ); + assert.equal(result.base_tag, 'v0.1.0'); + assert.equal(result.candidate_tag, 'v0.1.1-pre.2'); + assert.equal(result.train, 'v0.1.1'); + assert.equal(result.category, 'codex-security/v0.1.1'); + assert.equal(result.scan_scope, 'diff'); + assert.equal(result.commit_count, '2'); + } finally { + rmSync(repository, { recursive: true, force: true }); + } +}); + +test('rejects a prerelease that is not on main', () => { + const repository = createRepository(); + try { + commit(repository, 'stable'); + git(repository, 'tag', 'v0.1.0'); + git( + repository, + 'update-ref', + 'refs/remotes/origin/main', + git(repository, 'rev-parse', 'HEAD'), + ); + git(repository, 'switch', '--create', 'detached-release'); + commit(repository, 'off-main candidate'); + git(repository, 'tag', 'v0.1.1-pre.1'); + + const result = spawnSync( + process.execPath, + [SCRIPT, '--candidate', 'v0.1.1-pre.1'], + { cwd: repository, encoding: 'utf8' }, + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /is not an ancestor of origin\/main/); + } finally { + rmSync(repository, { recursive: true, force: true }); + } +}); + +test('requires explicit approval before a full bootstrap scan', () => { + const repository = createRepository(); + try { + commit(repository, 'first candidate'); + git(repository, 'tag', 'v0.1.0-pre.1'); + git( + repository, + 'update-ref', + 'refs/remotes/origin/main', + git(repository, 'rev-parse', 'HEAD'), + ); + + const rejected = spawnSync( + process.execPath, + [SCRIPT, '--candidate', 'v0.1.0-pre.1'], + { cwd: repository, encoding: 'utf8' }, + ); + assert.equal(rejected.status, 1); + assert.match(rejected.stderr, /--allow-full-bootstrap/); + + const approved = runResolver( + repository, + '--candidate', + 'v0.1.0-pre.1', + '--allow-full-bootstrap', + ); + assert.equal(approved.scan_scope, 'full'); + assert.equal(approved.base_tag, ''); + assert.equal(approved.train, 'v0.1.0'); + } finally { + rmSync(repository, { recursive: true, force: true }); + } +}); diff --git a/tasks/test.toml b/tasks/test.toml index 9a1466963e..2fc3c565e4 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -13,6 +13,7 @@ depends = [ "test:install-sh", "test:build-env", "test:packaging-assets", + "test:codex-security-release-range", "test:docs-website", ] @@ -45,6 +46,11 @@ run = "tasks/scripts/test-packaging-assets.sh" run_windows = "echo Skipping test:packaging-assets: Linux service and RPM assets do not apply on Windows." hide = true +["test:codex-security-release-range"] +description = "Test Codex Security release-range resolution" +run = "node --test tasks/scripts/codex-security-release-range.test.mjs" +hide = true + [e2e] description = "Run all end-to-end tests (Rust + Python + MCP)" depends = ["e2e:rust", "e2e:python", "e2e:mcp"] From e04638d2d6b7e9451a051ab1415516e9650f21a2 Mon Sep 17 00:00:00 2001 From: rootkiller6788 Date: Tue, 1 Sep 2026 15:02:35 +0000 Subject: [PATCH 062/505] fix(cli): fail sandbox exec when the relay closes without an exit status (#2957) `sandbox exec` seeded its exit code to 0 and only overwrote it on an `Exit` event, so a stream that ended early, was cancelled, or was truncated reported a successful run. The gateway already treats the same condition as a relay failure (`Status::unavailable`); mirror that on the CLI side so exit 0 always means an observed exit status of 0. Fixes #2732 Signed-off-by: rootkiller6788 Co-authored-by: rootkiller6788 --- crates/openshell-cli/src/run.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index bead4b4319..b819be92f8 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -1479,7 +1479,8 @@ const MAX_STDIN_PAYLOAD: usize = 4 * 1024 * 1024; /// Execute a command in a running sandbox via gRPC, streaming output to the terminal. /// -/// Returns the remote command's exit code. +/// Returns the remote command's exit code, or an error if the event stream +/// closes before the command reports an exit status. #[allow(clippy::too_many_arguments, clippy::implicit_hasher)] pub async fn sandbox_exec_grpc( server: &str, @@ -1577,6 +1578,7 @@ pub async fn sandbox_exec_grpc( // Stream output to terminal in real-time. let mut exit_code = 0i32; + let mut exit_seen = false; let stdout = std::io::stdout(); let stderr = std::io::stderr(); @@ -1595,11 +1597,21 @@ pub async fn sandbox_exec_grpc( } Some(exec_sandbox_event::Payload::Exit(exit)) => { exit_code = exit.exit_code; + exit_seen = true; } None => {} } } + // A stream that closes without an Exit event means we never observed the + // command's outcome. The server treats the same condition as a relay + // failure; mirror that here so exit 0 stays meaningful. + if !exit_seen { + return Err(miette::miette!( + "sandbox exec relay closed before the command reported an exit status" + )); + } + Ok(exit_code) } @@ -2012,6 +2024,7 @@ async fn sandbox_exec_interactive_grpc( let _resize_guard = TaskGuard(resize_task); let mut exit_code = 0i32; + let mut exit_seen = false; let stdout = std::io::stdout(); let stderr = std::io::stderr(); @@ -2030,6 +2043,7 @@ async fn sandbox_exec_interactive_grpc( } Some(exec_sandbox_event::Payload::Exit(exit)) => { exit_code = exit.exit_code; + exit_seen = true; break; } None => {} @@ -2041,6 +2055,15 @@ async fn sandbox_exec_interactive_grpc( // Drop the raw mode guard to restore the terminal before returning. drop(raw_guard); + // A stream that closes without an Exit event means we never observed the + // command's outcome. Treat it as a relay failure rather than reporting a + // successful (0) exit. + if !exit_seen { + return Err(miette::miette!( + "sandbox exec relay closed before the command reported an exit status" + )); + } + Ok(exit_code) } From b1435002489ac9aa7c2ed99416e44580707acb58 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Tue, 1 Sep 2026 15:23:58 +0000 Subject: [PATCH 063/505] fix(podman): restore rootless workload SIGTERM shutdown (#3036) * fix(supervisor-process): avoid shutdown exit report hang Signed-off-by: Evan Lezar * fix(podman): retain workload signal capability Signed-off-by: Evan Lezar * fix(compute): preserve stopped status after signal exit Signed-off-by: Evan Lezar --------- Signed-off-by: Evan Lezar --- .../skills/debug-openshell-cluster/SKILL.md | 4 + crates/openshell-driver-podman/README.md | 2 +- .../openshell-driver-podman/src/container.rs | 10 +- crates/openshell-server/src/compute/mod.rs | 201 +++++++++++++++++- .../openshell-supervisor-process/src/run.rs | 60 +++++- 5 files changed, 257 insertions(+), 20 deletions(-) diff --git a/.agents/skills/debug-openshell-cluster/SKILL.md b/.agents/skills/debug-openshell-cluster/SKILL.md index d5c996aecf..b2019652b8 100644 --- a/.agents/skills/debug-openshell-cluster/SKILL.md +++ b/.agents/skills/debug-openshell-cluster/SKILL.md @@ -262,6 +262,10 @@ When `userns` is configured (e.g. `userns = "auto"` or `userns = "keep-id"`): rootful Podman uses absolute host IDs (e.g. `uidmap = ["0:1000:1", "1:100000:65536"]`). - `nomap` (without hyphen) is accepted as input but canonicalized to `no-map` for Podman's API. +- A workload remains in `stopping` until Podman resorts to `SIGKILL`: inspect + supervisor logs for `failed to signal entrypoint process group`. The + supervisor must retain `CAP_KILL` so its root process can forward `SIGTERM` + to a workload that runs as the sandbox user. ### Step 6: Check Kubernetes Helm Gateways diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index d55d20ec93..ccd4413b3f 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -81,7 +81,7 @@ The container spec in `container.rs` sets these security-critical fields: |---|---|---| | `user` | `0:0` | The supervisor needs root inside the container for namespace creation, proxy setup, Landlock, seccomp, and filesystem preparation. | | `cap_drop` | Selected unneeded defaults | Podman's default capability set is already restricted. The driver drops capabilities the supervisor does not need. | -| `cap_add` | `SYS_ADMIN`, `NET_ADMIN`, `SYS_PTRACE`, `SYSLOG`, `DAC_READ_SEARCH`, `SETPCAP` | Grants supervisor-only capabilities required for namespace setup, process identity, bypass diagnostics, and child bounding-set cleanup. Policy DNS binds an unprivileged supervisor port and does not require `NET_BIND_SERVICE`. | +| `cap_add` | `SYS_ADMIN`, `NET_ADMIN`, `SYS_PTRACE`, `SYSLOG`, `DAC_READ_SEARCH`, `SETPCAP`, `KILL` | Grants supervisor-only capabilities required for namespace setup, process identity, bypass diagnostics, child bounding-set cleanup, and forwarding shutdown signals to a workload that runs as the sandbox user. Policy DNS binds an unprivileged supervisor port and does not require `NET_BIND_SERVICE`. | | `no_new_privileges` | `true` | Prevents privilege escalation after exec. | | `seccomp_profile_path` | `unconfined` | The supervisor installs its own policy-aware BPF filter. A container-level profile can block Landlock/seccomp syscalls during setup. | | `mounts` | Private tmpfs at `/run/netns` | Lets the supervisor create named network namespaces in rootless Podman. | diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index abb9d69dd2..a81ee13e1d 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -1114,8 +1114,6 @@ pub fn build_container_spec_for_image( "DAC_OVERRIDE".into(), // Not needed: the supervisor does not create setuid/setgid executables. "FSETID".into(), - // Not needed: the supervisor does not send signals to arbitrary processes. - "KILL".into(), // Not needed: the supervisor does not bind privileged ports (<1024). "NET_BIND_SERVICE".into(), // Not in Podman's default set but explicitly denied in case the image @@ -1146,6 +1144,9 @@ pub fn build_container_spec_for_image( // Child setup clears the capability bounding set before exec, which // requires CAP_SETPCAP in the supervisor until drop_privileges(). "SETPCAP".into(), + // Forwarding shutdown signals to the canonical workload process + // group after it drops to the sandbox UID requires CAP_KILL. + "KILL".into(), ], // SETUID, SETGID, SETPCAP, CHOWN, and FOWNER are intentionally kept from // Podman's default set and not dropped: @@ -1885,6 +1886,7 @@ mod tests { "missing DAC_READ_SEARCH" ); assert!(added.contains(&"SETPCAP"), "missing SETPCAP"); + assert!(added.contains(&"KILL"), "missing KILL"); // SETUID and SETGID are NOT in cap_add — they remain available from the // default bounding set because we no longer use cap_drop:ALL. Verify they @@ -1916,6 +1918,10 @@ mod tests { !dropped.contains(&"SETPCAP"), "SETPCAP must not be dropped (needed for child bounding-set clear)" ); + assert!( + !dropped.contains(&"KILL"), + "KILL must not be dropped (needed to signal the sandbox workload on shutdown)" + ); assert!( !dropped.contains(&"ALL"), "must not use cap_drop:ALL in rootless Podman" diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 2dd1f7cd2e..4d4c227355 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -1418,11 +1418,15 @@ impl ComputeRuntime { || driver_snapshot_confirms_stopped(&snapshot); let suspension_progressing = expected_stopped && driver_snapshot_confirms_stopping(&snapshot); - if suspension_progressing { + let runtime_restart_during_stop = + expected_stopped && driver_snapshot_reports_runtime_restart(&snapshot); + if suspension_progressing || runtime_restart_during_stop { // The Kubernetes controller has accepted the stop and - // is waiting for its pod to terminate. Preserve the - // durable transition so a later watch event can complete - // it instead of claiming the sandbox is running again. + // is waiting for its pod to terminate. A container + // runtime restart can likewise be the expected SIGTERM + // exit from an in-flight stop. Preserve the durable + // transition so completion or recovery, rather than the + // watcher, determines its terminal state. debug!(sandbox_id, "Sandbox stop is still progressing"); } else if backend_phase == SandboxPhase::Error || observed_stopped == expected_stopped @@ -4020,8 +4024,11 @@ fn apply_driver_snapshot(sandbox: &mut Sandbox, incoming: &DriverSandbox, sessio // Infrastructure errors and successful main-process completions are // sticky until an explicit lifecycle operation changes desired state. A - // late backend snapshot must not revive either result. - if matches!(old_phase, SandboxPhase::Error | SandboxPhase::Completed) { + // late signal-exit snapshot must also not overwrite the terminal reason + // recorded by a completed explicit stop. + if matches!(old_phase, SandboxPhase::Error | SandboxPhase::Completed) + || (old_phase == SandboxPhase::Stopped && driver_snapshot_reports_runtime_restart(incoming)) + { if let Some(metadata) = sandbox.metadata.as_mut() { metadata.name.clone_from(sandbox_name); } @@ -4058,6 +4065,13 @@ fn apply_driver_snapshot(sandbox: &mut Sandbox, incoming: &DriverSandbox, sessio ); phase = match old_phase { + // SIGTERM-driven runtime exits are reported as a runtime restart by + // Docker and Podman. While an explicit stop owns this durable + // transition, preserve Stopping so the stop result chooses whether + // the sandbox actually reached Stopped or needs recovery. + SandboxPhase::Stopping if driver_snapshot_reports_runtime_restart(incoming) => { + SandboxPhase::Stopping + } SandboxPhase::Stopping if phase == SandboxPhase::Stopped || driver_snapshot_confirms_stopped(incoming) => { @@ -4139,6 +4153,17 @@ fn driver_snapshot_confirms_stopped(incoming: &DriverSandbox) -> bool { }) } +fn driver_snapshot_reports_runtime_restart(incoming: &DriverSandbox) -> bool { + incoming.status.as_ref().is_some_and(|status| { + status.conditions.iter().any(|condition| { + condition.status.eq_ignore_ascii_case("false") + && condition + .reason + .eq_ignore_ascii_case("ContainerRuntimeRestart") + }) + }) +} + fn driver_snapshot_confirms_stopping(incoming: &DriverSandbox) -> bool { incoming.status.as_ref().is_some_and(|status| { status.conditions.iter().any(|condition| { @@ -6770,6 +6795,103 @@ mod tests { assert_eq!(driver.stop_calls(), 1); } + #[tokio::test] + async fn explicit_stop_completes_after_term_runtime_restart() { + let driver = ControlledDriver::new(); + driver.block_stop(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-term-stop", "sandbox-term-stop", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + + let stop_runtime = runtime.clone(); + let stop = tokio::spawn(async move { + stop_runtime + .stop_sandbox("default", "sandbox-term-stop") + .await + }); + tokio::time::timeout(Duration::from_secs(1), driver.stop_started.notified()) + .await + .expect("stop did not reach the driver"); + + let mut runtime_restart = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name()); + runtime_restart.status = Some(make_driver_status(make_driver_condition( + "ContainerRuntimeRestart", + "container exited with status 143", + ))); + runtime.apply_sandbox_update(runtime_restart).await.unwrap(); + + let stopping = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!(stopping.phase(), SandboxPhase::Stopping as i32); + assert_eq!( + stopping.status.unwrap().conditions[0].reason, + "ContainerRuntimeRestart" + ); + + driver.release_stop(); + let stopped = tokio::time::timeout(Duration::from_secs(1), stop) + .await + .expect("stop did not finish") + .unwrap() + .unwrap(); + assert_eq!(stopped.phase(), SandboxPhase::Stopped as i32); + assert_eq!(stopped.status.unwrap().conditions[0].reason, "Stopped"); + } + + #[tokio::test] + async fn failed_stop_does_not_report_term_runtime_restart_as_stopped() { + let driver = ControlledDriver::new(); + driver.block_stop(); + driver.set_stop_outcome(ControlledLifecycleOutcome::Error("stop timed out")); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-term-fail", "sandbox-term-fail", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + + let stop_runtime = runtime.clone(); + let stop = tokio::spawn(async move { + stop_runtime + .stop_sandbox("default", "sandbox-term-fail") + .await + }); + tokio::time::timeout(Duration::from_secs(1), driver.stop_started.notified()) + .await + .expect("stop did not reach the driver"); + + let mut runtime_restart = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name()); + runtime_restart.status = Some(make_driver_status(make_driver_condition( + "ContainerRuntimeRestart", + "container exited with status 143", + ))); + driver.set_get_outcome(ControlledGetOutcome::Sandbox(Box::new( + runtime_restart.clone(), + ))); + runtime.apply_sandbox_update(runtime_restart).await.unwrap(); + + driver.release_stop(); + let err = tokio::time::timeout(Duration::from_secs(1), stop) + .await + .expect("stop did not finish") + .unwrap() + .unwrap_err(); + assert!(err.message().contains("stop timed out")); + + let stored = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Stopping as i32); + assert_eq!( + stored.status.unwrap().conditions[0].reason, + "ContainerRuntimeRestart" + ); + } + #[tokio::test] async fn request_cancellation_does_not_cancel_start_worker() { let driver = ControlledDriver::new(); @@ -8360,6 +8482,73 @@ mod tests { assert_eq!(status.exit_code, None); } + #[tokio::test] + async fn unexpected_term_runtime_restart_transitions_to_error() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let sandbox = sandbox_record("sb-term-exit", "sandbox-term-exit", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + let mut runtime_restart = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name()); + runtime_restart.status = Some(make_driver_status(make_driver_condition( + "ContainerRuntimeRestart", + "container exited with status 143", + ))); + + runtime.apply_sandbox_update(runtime_restart).await.unwrap(); + + let stored = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Error as i32); + assert_eq!( + stored.status.unwrap().conditions[0].reason, + "ContainerRuntimeRestart" + ); + } + + #[tokio::test] + async fn late_term_runtime_restart_preserves_intentional_stop_status() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let mut sandbox = sandbox_record( + "sb-term-stopped", + "sandbox-term-stopped", + SandboxPhase::Stopped, + ); + sandbox.status = Some(SandboxStatus { + sandbox_name: sandbox.object_name().to_string(), + phase: SandboxPhase::Stopped as i32, + conditions: vec![SandboxCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: "Stopped".to_string(), + message: "Sandbox compute is stopped".to_string(), + last_transition_time: String::new(), + }], + ..Default::default() + }); + runtime.store.put_message(&sandbox).await.unwrap(); + let mut runtime_restart = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name()); + runtime_restart.status = Some(make_driver_status(make_driver_condition( + "ContainerRuntimeRestart", + "container exited with status 143", + ))); + + runtime.apply_sandbox_update(runtime_restart).await.unwrap(); + + let stored = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Stopped as i32); + let ready = &stored.status.unwrap().conditions[0]; + assert_eq!(ready.reason, "Stopped"); + assert_eq!(ready.message, "Sandbox compute is stopped"); + } + #[tokio::test] async fn late_driver_exit_preserves_completed_main_result() { let runtime = test_runtime(Arc::new(TestDriver::default())).await; diff --git a/crates/openshell-supervisor-process/src/run.rs b/crates/openshell-supervisor-process/src/run.rs index b2820d9588..8c47e789ba 100644 --- a/crates/openshell-supervisor-process/src/run.rs +++ b/crates/openshell-supervisor-process/src/run.rs @@ -453,24 +453,34 @@ pub async fn run_process( .build() ); - if let Some(tx) = sidecar_exit_tx.as_ref() { - report_sidecar_main_process_exit(tx, &main_instance_id, rendered_code).await?; - } else if let (Some(endpoint), Some(id)) = (openshell_endpoint, sandbox_id) { - report_main_process_exit_until_ack(endpoint, id, &main_instance_id, rendered_code).await; - info!(instance_id = %main_instance_id, "main-process exit acknowledged"); + if outcome.should_report_main_process_exit() { + if let Some(tx) = sidecar_exit_tx.as_ref() { + report_sidecar_main_process_exit(tx, &main_instance_id, rendered_code).await?; + } else if let (Some(endpoint), Some(id)) = (openshell_endpoint, sandbox_id) { + report_main_process_exit_until_ack(endpoint, id, &main_instance_id, rendered_code) + .await; + info!(instance_id = %main_instance_id, "main-process exit acknowledged"); + } + } else { + info!( + instance_id = %main_instance_id, + "skipping main-process exit report during supervisor shutdown" + ); } main_session.mark_terminal_reported(); - if drain_terminal && terminal_delivery_pending { + if outcome.should_report_main_process_exit() && drain_terminal && terminal_delivery_pending { // The peer's SSH channel-close confirms that the terminal frames sent // above traversed russh and the relay. Detached commands have no active // attachment and never enter this wait. main_session.wait_for_terminal_attachments().await; } - if let Some(tx) = sidecar_exit_tx.as_ref() { - finalize_sidecar_main_process_exit(tx, &main_instance_id).await?; - } else if let (Some(endpoint), Some(id)) = (openshell_endpoint, sandbox_id) { - finalize_main_process_exit_until_ack(endpoint, id, &main_instance_id).await; - info!(instance_id = %main_instance_id, "main-process terminal delivery finalized"); + if outcome.should_report_main_process_exit() { + if let Some(tx) = sidecar_exit_tx.as_ref() { + finalize_sidecar_main_process_exit(tx, &main_instance_id).await?; + } else if let (Some(endpoint), Some(id)) = (openshell_endpoint, sandbox_id) { + finalize_main_process_exit_until_ack(endpoint, id, &main_instance_id).await; + info!(instance_id = %main_instance_id, "main-process terminal delivery finalized"); + } } supervisor_terminating.store(true, Ordering::Release); @@ -572,6 +582,16 @@ enum ProcessWaitOutcome { }, } +impl ProcessWaitOutcome { + /// A gateway acknowledgement is required for ordinary canonical-process + /// completion, but cannot be awaited after the supervisor itself has been + /// asked to terminate. At that point the gateway may already be shutting + /// down and no longer able to acknowledge the report. + fn should_report_main_process_exit(&self) -> bool { + !matches!(self, Self::ShutdownSignal { .. }) + } +} + async fn wait_for_process_exit_or_shutdown( handle: &mut ProcessHandle, timeout_secs: u64, @@ -789,4 +809,22 @@ mod tests { assert_eq!(ssh_proxy_url_for_policy(&policy, None), None); } + + #[cfg(unix)] + #[test] + fn supervisor_shutdown_exit_skips_gateway_acknowledgement() { + use std::os::unix::process::ExitStatusExt; + + let status = ProcessStatus::from(std::process::ExitStatus::from_raw(libc::SIGTERM)); + + assert!(ProcessWaitOutcome::Exited(status).should_report_main_process_exit()); + assert!(ProcessWaitOutcome::TimedOut.should_report_main_process_exit()); + assert!( + !ProcessWaitOutcome::ShutdownSignal { + signal: "SIGTERM", + status, + } + .should_report_main_process_exit() + ); + } } From c8f13205e31674b4e239b428f5a5f6cb31710427 Mon Sep 17 00:00:00 2001 From: Simon Scatton <44714756+SDAChess@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:31:18 +0000 Subject: [PATCH 064/505] ci(release): publish prerelease artifacts (#3093) Signed-off-by: Simon Scatton --- .github/workflows/release-tag.yml | 70 ++++++++++++++++++++------- .github/workflows/snap-package.yml | 6 +++ AGENTS.md | 2 +- CONTRIBUTING.md | 2 +- python/release_tooling_test.py | 24 +++++++++ tasks/scripts/release.py | 78 +++++++++++++++++++++++++----- 6 files changed, 150 insertions(+), 32 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 20d74436c6..b2b5bcb747 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -49,6 +49,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: @@ -65,14 +66,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 @@ -187,7 +199,7 @@ jobs: checkout-ref: ${{ inputs.tag || github.ref }} 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 @@ -195,22 +207,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: @@ -266,8 +283,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 }} @@ -530,6 +548,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 \ @@ -548,6 +567,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: | @@ -570,6 +590,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 }} @@ -606,9 +627,19 @@ 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] + needs: [compute-versions, release] + if: needs.compute-versions.outputs.is_prerelease != 'true' runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -635,6 +666,7 @@ jobs: 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: @@ -673,6 +705,7 @@ jobs: release-helm: name: Release Helm Chart (OCI) needs: [compute-versions, release, tag-ghcr-release] + if: needs.compute-versions.outputs.is_prerelease != 'true' runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -691,6 +724,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: diff --git a/.github/workflows/snap-package.yml b/.github/workflows/snap-package.yml index 6de2a587f2..7eb78d3a27 100644 --- a/.github/workflows/snap-package.yml +++ b/.github/workflows/snap-package.yml @@ -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 }} diff --git a/AGENTS.md b/AGENTS.md index 5fdaa63d02..2c11b4c4ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -250,7 +250,7 @@ When behavior, commands, or development workflows change, review the related age - 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. - 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`. +- 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` for stable release tags. - Use the `update-docs` skill to scan recent commits and draft doc updates. ### Architecture Docs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b33c58fae..b1a789496f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -456,7 +456,7 @@ 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. +Fern docs publishing is handled by the `publish-fern-docs` job in `.github/workflows/release-tag.yml` when a stable release tag is created. `docs/` is the source-of-truth docs tree. `fern/` contains the site config, components, and theme assets that publish those pages. diff --git a/python/release_tooling_test.py b/python/release_tooling_test.py index ccc97453fc..0846705cb6 100644 --- a/python/release_tooling_test.py +++ b/python/release_tooling_test.py @@ -44,8 +44,32 @@ def test_dev_versions_share_one_build_identity() -> None: assert versions.rpm_release == "0.dev.108.g152d05940" +def test_prerelease_versions_share_tag_identity() -> None: + versions = release._versions_from_prerelease( + (0, 1, 0), 1, "152d05940", "v0.1.0-pre.1" + ) + + assert versions.python == "0.1.0-pre.1" + assert versions.cargo == "0.1.0-pre.1" + assert versions.npm == "0.1.0-pre.1" + assert versions.docker == "0.1.0-pre.1" + assert versions.deb == "0.1.0~pre.1-1" + assert versions.snap == "0.1.0-pre.1" + assert versions.rpm_version == "0.1.0" + assert versions.rpm_release == "0.pre.1" + + def test_semver_tag_parser_excludes_vm_tags() -> None: assert release._parse_semver_tag("v0.0.37") == (0, 0, 37) assert release._parse_semver_tag("0.0.37") == (0, 0, 37) + assert release._parse_semver_tag("v0.1.0-pre.1") is None assert release._parse_semver_tag("vm-runtime") is None assert release._parse_semver_tag("vm-dev") is None + + +def test_prerelease_tag_parser_requires_positive_sequence() -> None: + assert release._parse_prerelease_tag("v0.1.0-pre.1") == (0, 1, 0, 1) + assert release._parse_prerelease_tag("0.1.0-pre.12") == (0, 1, 0, 12) + assert release._parse_prerelease_tag("v0.1.0") is None + assert release._parse_prerelease_tag("v0.1.0-pre.0") is None + assert release._parse_prerelease_tag("v0.1.0-rc.1") is None diff --git a/tasks/scripts/release.py b/tasks/scripts/release.py index dcf1c50119..feeaa84b91 100644 --- a/tasks/scripts/release.py +++ b/tasks/scripts/release.py @@ -12,8 +12,10 @@ from dataclasses import asdict, dataclass from pathlib import Path -SEMVER_TAG_GLOB = "v[0-9]*.[0-9]*.[0-9]*" SEMVER_TAG_RE = re.compile(r"^v?(?P\d+)\.(?P\d+)\.(?P\d+)$") +PRERELEASE_TAG_RE = re.compile( + r"^v?(?P\d+)\.(?P\d+)\.(?P\d+)-pre\.(?P[1-9]\d*)$" +) @dataclass(frozen=True) @@ -66,6 +68,18 @@ def _parse_semver_tag(tag: str) -> tuple[int, int, int] | None: ) +def _parse_prerelease_tag(tag: str) -> tuple[int, int, int, int] | None: + match = PRERELEASE_TAG_RE.match(tag) + if match is None: + return None + return ( + int(match.group("major")), + int(match.group("minor")), + int(match.group("patch")), + int(match.group("sequence")), + ) + + def _format_semver(version: tuple[int, int, int]) -> str: return f"{version[0]}.{version[1]}.{version[2]}" @@ -74,17 +88,22 @@ def _next_patch(version: tuple[int, int, int]) -> tuple[int, int, int]: return version[0], version[1], version[2] + 1 -def _latest_semver_tag() -> str | None: - try: - tag = _git( - ["describe", "--tags", "--match", SEMVER_TAG_GLOB, "--abbrev=0", "HEAD"] - ) - except subprocess.CalledProcessError: - return None +def _exact_release_tag() -> str | None: + tags = _git(["tag", "--points-at", "HEAD"]).splitlines() + stable = [(version, tag) for tag in tags if (version := _parse_semver_tag(tag))] + if stable: + return max(stable)[1] + + prereleases = [ + (version, tag) for tag in tags if (version := _parse_prerelease_tag(tag)) + ] + return max(prereleases)[1] if prereleases else None - if _parse_semver_tag(tag) is None: - raise RuntimeError(f"git describe returned non-semver release tag: {tag}") - return tag + +def _latest_stable_tag() -> str | None: + tags = _git(["tag", "--merged", "HEAD", "--list", "v*.*.*"]).splitlines() + stable = [(version, tag) for tag in tags if (version := _parse_semver_tag(tag))] + return max(stable)[1] if stable else None def _versions_from_parts( @@ -142,10 +161,45 @@ def _versions_from_parts( ) +def _versions_from_prerelease( + base_version: tuple[int, int, int], + sequence: int, + git_sha: str, + git_tag: str, +) -> Versions: + version = f"{_format_semver(base_version)}-pre.{sequence}" + return Versions( + python=version, + cargo=version, + npm=version, + docker=version, + deb=f"{_format_semver(base_version)}~pre.{sequence}-1", + snap=version, + rpm_version=_format_semver(base_version), + rpm_release=f"0.pre.{sequence}", + git_tag=git_tag, + git_sha=git_sha, + git_distance=0, + ) + + def _compute_versions() -> Versions: - git_tag = _latest_semver_tag() git_sha = _git(["rev-parse", "--short=9", "HEAD"]) + exact_tag = _exact_release_tag() + + if exact_tag is not None: + stable = _parse_semver_tag(exact_tag) + if stable is not None: + return _versions_from_parts(stable, 0, git_sha, exact_tag) + + prerelease = _parse_prerelease_tag(exact_tag) + if prerelease is None: + raise RuntimeError(f"invalid semantic release tag: {exact_tag}") + return _versions_from_prerelease( + prerelease[:3], prerelease[3], git_sha, exact_tag + ) + git_tag = _latest_stable_tag() if git_tag is None: base_version = (0, 0, 0) git_distance = int(_git(["rev-list", "--count", "HEAD"])) From a4f9c762ce3960f82058ab37323c1e7980aab53c Mon Sep 17 00:00:00 2001 From: Simon Scatton <44714756+SDAChess@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:12:01 +0000 Subject: [PATCH 065/505] fix(release): handle prerelease tag builds (#3094) Signed-off-by: Simon Scatton --- .github/workflows/release-dev.yml | 10 +++---- .github/workflows/release-tag.yml | 1 - python/release_tooling_test.py | 21 ++++++++++++++- tasks/scripts/release.py | 44 ++++++++++++++++++++++--------- 4 files changed, 56 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 7982a81643..2d6fb31247 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -47,11 +47,11 @@ 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" + echo "python=$(uv run python tasks/scripts/release.py get-version --dev --python)" >> "$GITHUB_OUTPUT" + echo "cargo=$(uv run python tasks/scripts/release.py get-version --dev --cargo)" >> "$GITHUB_OUTPUT" + echo "deb=$(uv run python tasks/scripts/release.py get-version --dev --deb)" >> "$GITHUB_OUTPUT" + echo "rpm_version=$(uv run python tasks/scripts/release.py get-version --dev --rpm-version)" >> "$GITHUB_OUTPUT" + echo "rpm_release=$(uv run python tasks/scripts/release.py get-version --dev --rpm-release)" >> "$GITHUB_OUTPUT" build-cli: needs: compute-versions diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index b2b5bcb747..c11b74c8a1 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -4,7 +4,6 @@ on: push: tags: - "v*.*.*" - - "!v*.*.*-pre.*" workflow_dispatch: inputs: tag: diff --git a/python/release_tooling_test.py b/python/release_tooling_test.py index 0846705cb6..d170839cca 100644 --- a/python/release_tooling_test.py +++ b/python/release_tooling_test.py @@ -49,7 +49,7 @@ def test_prerelease_versions_share_tag_identity() -> None: (0, 1, 0), 1, "152d05940", "v0.1.0-pre.1" ) - assert versions.python == "0.1.0-pre.1" + assert versions.python == "0.1.0rc1" assert versions.cargo == "0.1.0-pre.1" assert versions.npm == "0.1.0-pre.1" assert versions.docker == "0.1.0-pre.1" @@ -73,3 +73,22 @@ def test_prerelease_tag_parser_requires_positive_sequence() -> None: assert release._parse_prerelease_tag("v0.1.0") is None assert release._parse_prerelease_tag("v0.1.0-pre.0") is None assert release._parse_prerelease_tag("v0.1.0-rc.1") is None + + +def test_dev_versions_ignore_exact_prerelease_tag(monkeypatch) -> None: + def fake_git(args: list[str]) -> str: + if args == ["rev-parse", "--short=9", "HEAD"]: + return "152d05940" + if args == ["tag", "--merged", "HEAD", "--list", "v*.*.*"]: + return "v0.0.37\nv0.1.0-pre.1" + if args == ["rev-list", "v0.0.37..HEAD", "--count"]: + return "108" + raise AssertionError(f"unexpected git command: {args}") + + monkeypatch.setattr(release, "_git", fake_git) + + versions = release._compute_dev_versions() + + assert versions.python == "0.0.38.dev108+g152d05940" + assert versions.cargo == "0.0.38-dev.108+g152d05940" + assert versions.git_tag == "v0.0.37" diff --git a/tasks/scripts/release.py b/tasks/scripts/release.py index feeaa84b91..7ff40c380a 100644 --- a/tasks/scripts/release.py +++ b/tasks/scripts/release.py @@ -169,7 +169,7 @@ def _versions_from_prerelease( ) -> Versions: version = f"{_format_semver(base_version)}-pre.{sequence}" return Versions( - python=version, + python=f"{_format_semver(base_version)}rc{sequence}", cargo=version, npm=version, docker=version, @@ -213,6 +213,21 @@ def _compute_versions() -> Versions: return _versions_from_parts(parsed_tag, git_distance, git_sha, git_tag) +def _compute_dev_versions() -> Versions: + git_sha = _git(["rev-parse", "--short=9", "HEAD"]) + git_tag = _latest_stable_tag() + if git_tag is None: + git_distance = int(_git(["rev-list", "--count", "HEAD"])) + return _versions_from_parts((0, 0, 0), git_distance, git_sha, "") + + parsed_tag = _parse_semver_tag(git_tag) + if parsed_tag is None: + raise RuntimeError(f"invalid semantic release tag: {git_tag}") + + git_distance = int(_git(["rev-list", f"{git_tag}..HEAD", "--count"])) + return _versions_from_parts(parsed_tag, git_distance, git_sha, git_tag) + + def _print_env(versions: Versions) -> None: print(f"VERSION_PY={versions.python}") print(f"VERSION_CARGO={versions.cargo}") @@ -227,8 +242,8 @@ def _print_env(versions: Versions) -> None: print(f"GIT_DISTANCE={versions.git_distance}") -def get_version(format: str) -> None: - versions = _compute_versions() +def get_version(format: str, *, dev: bool = False) -> None: + versions = _compute_dev_versions() if dev else _compute_versions() if format == "python": print(versions.python) elif format == "cargo": @@ -491,6 +506,9 @@ def build_parser() -> argparse.ArgumentParser: sub = parser.add_subparsers(dest="command", required=True) get_version_parser = sub.add_parser("get-version", help="Print computed version.") + get_version_parser.add_argument( + "--dev", action="store_true", help="Ignore exact tags and print a dev version." + ) get_version_parser.add_argument( "--python", action="store_true", help="Print Python version only." ) @@ -550,25 +568,25 @@ def main() -> None: if args.command == "get-version": if args.python: - get_version("python") + get_version("python", dev=args.dev) elif args.cargo: - get_version("cargo") + get_version("cargo", dev=args.dev) elif args.npm: - get_version("npm") + get_version("npm", dev=args.dev) elif args.docker: - get_version("docker") + get_version("docker", dev=args.dev) elif args.deb: - get_version("deb") + get_version("deb", dev=args.dev) elif args.snap: - get_version("snap") + get_version("snap", dev=args.dev) elif args.rpm_version: - get_version("rpm-version") + get_version("rpm-version", dev=args.dev) elif args.rpm_release: - get_version("rpm-release") + get_version("rpm-release", dev=args.dev) elif args.json: - get_version("json") + get_version("json", dev=args.dev) else: - get_version("all") + get_version("all", dev=args.dev) elif args.command == "generate-homebrew-formula": generate_homebrew_formula( release_tag=args.release_tag, From d5742e01a3fee44bc5058063a881069dcee08182 Mon Sep 17 00:00:00 2001 From: Mrunal Patel Date: Tue, 1 Sep 2026 16:16:01 +0000 Subject: [PATCH 066/505] feat(cli): add structured output to list commands (#3067) Signed-off-by: Mrunal Patel --- .agents/skills/openshell-cli/SKILL.md | 18 ++ .agents/skills/openshell-cli/cli-reference.md | 52 ++- crates/openshell-cli/src/main.rs | 237 +++++++++++++- crates/openshell-cli/src/run.rs | 301 +++++++++++++++++- .../tests/forward_list_integration.rs | 156 +++++++++ .../tests/provider_commands_integration.rs | 2 +- docs/sandboxes/manage-sandboxes.mdx | 23 ++ docs/sandboxes/manage-workspaces.mdx | 4 + docs/sandboxes/policies.mdx | 5 + docs/sandboxes/providers-v2.mdx | 3 + 10 files changed, 765 insertions(+), 36 deletions(-) create mode 100644 crates/openshell-cli/tests/forward_list_integration.rs diff --git a/.agents/skills/openshell-cli/SKILL.md b/.agents/skills/openshell-cli/SKILL.md index eb5c467cb6..c9b4d20268 100644 --- a/.agents/skills/openshell-cli/SKILL.md +++ b/.agents/skills/openshell-cli/SKILL.md @@ -333,10 +333,15 @@ provider instead of passing API keys, tokens, or other secrets to `sandbox exec` ```bash openshell sandbox provider list my-sandbox +openshell sandbox provider list my-sandbox --output json openshell sandbox provider attach my-sandbox my-github openshell sandbox provider detach my-sandbox my-github ``` +Structured attachment output contains provider names, types, and sorted +credential and config key names. It never contains credential, handle, or +config values. + ### View logs ```bash @@ -498,6 +503,7 @@ View all revisions to understand how the policy evolved: ```bash openshell policy list dev --limit 50 +openshell policy list dev --output json ``` Fetch a specific historical revision: @@ -576,6 +582,17 @@ openshell sandbox delete my-app openshell sandbox create --from ./Dockerfile --name my-app --forward 8080 ``` +Use structured output when automation needs the tracked forward metadata and +validated process state: + +```bash +openshell forward list --output json +``` + +Each record includes `sandbox`, `bind_address`, `port`, `pid`, and `alive`. +The `alive` boolean validates the tracked process identity; it does not probe +the forwarded socket. + Create and forward in one command: ```bash @@ -738,6 +755,7 @@ openshell forward service my-app --target-port 8000 --local 127.0.0.1:0 # Expose and manage an HTTP service through the gateway. openshell service expose my-app 8080 web openshell service list my-app +openshell service list my-app --output json openshell service get my-app web openshell service delete my-app web ``` diff --git a/.agents/skills/openshell-cli/cli-reference.md b/.agents/skills/openshell-cli/cli-reference.md index 4d39143d8d..f5fe82f191 100644 --- a/.agents/skills/openshell-cli/cli-reference.md +++ b/.agents/skills/openshell-cli/cli-reference.md @@ -57,17 +57,17 @@ openshell │ ├── download [dest] │ ├── ssh-config [name] │ └── provider -│ ├── list [name] +│ ├── list [name] [-o table|yaml|json] │ ├── attach │ └── detach ├── forward │ ├── start [name] [-d] │ ├── stop [name] -│ ├── list +│ ├── list [-o table|yaml|json] │ └── service [name] --target-port [opts] ├── service │ ├── expose [service] -│ ├── list [sandbox] +│ ├── list [sandbox] [-o table|yaml|json] │ ├── get [service] │ └── delete [service] ├── logs [name] [opts] @@ -75,7 +75,7 @@ openshell │ ├── set [name] --policy [--global] [--wait] │ ├── update [name] [opts] │ ├── get [name] [--full|--base] [--global] -│ ├── list [name] [--global] +│ ├── list [name] [--global] [-o table|yaml|json] │ ├── delete --global │ └── prove --policy --credentials [opts] ├── settings @@ -107,6 +107,15 @@ openshell │ │ └── delete │ ├── update [opts] │ └── delete ... +├── workspace +│ ├── create +│ ├── get +│ ├── list [opts] +│ ├── delete ... +│ └── member +│ ├── add --workspace --subject --role +│ ├── remove --workspace --subject +│ └── list --workspace [-o table|yaml|json] ├── doctor │ └── check ├── term @@ -321,10 +330,13 @@ Print an SSH config `Host` block. The name defaults to the last-used sandbox. Manage providers on an existing sandbox: -- `openshell sandbox provider list [name]` +- `openshell sandbox provider list [name] [--output table|yaml|json]` - `openshell sandbox provider attach ` - `openshell sandbox provider detach ` +Structured list output contains `name`, `type`, and sorted `credential_keys` +and `config_keys` arrays. It excludes all credential, handle, and config values. + --- ## Port Forwarding Commands @@ -343,9 +355,15 @@ Start forwarding a local port to a sandbox. Stop a background port forward. When the sandbox name is omitted, it is inferred from active forwards. -### `openshell forward list` +### `openshell forward list [--output table|yaml|json]` -List all active port forwards (sandbox, port, PID, status). +List all tracked port forwards. Table output shows the sandbox, bind address, +port, PID, and status. JSON and YAML output expose `sandbox`, `bind_address`, +`port`, `pid`, and the boolean `alive`; an empty result is an empty collection. + +| Flag | Default | Description | +|------|---------|-------------| +| `-o`, `--output ` | `table` | Output format: `table`, `yaml`, or `json` | ### `openshell forward service [name] --target-port ` @@ -364,10 +382,13 @@ Forward a local TCP port to a loopback service inside a sandbox over the gRPC re Gateway-managed HTTP service endpoints: - `openshell service expose [service]` -- `openshell service list [sandbox] [--limit N] [--offset N]` +- `openshell service list [sandbox] [--limit N] [--offset N] [--output table|yaml|json]` - `openshell service get [service]` - `openshell service delete [service]` +Structured list records contain `workspace`, `sandbox`, `service`, +`target_port`, and `url`. Empty lists serialize as empty collections. + --- ## Logs Command @@ -454,6 +475,11 @@ List policy revision history (version, hash, status, created, error). |------|---------|-------------| | `--limit ` | 20 | Max revisions to return | | `--global` | false | List global policy revisions | +| `-o`, `--output ` | `table` | Output format: `table`, `yaml`, or `json` | + +Structured records use the policy metadata contract from `policy get`: scope, +sandbox when applicable, version, full hash, normalized status, and available +timestamps, load error, and provenance. ### `openshell policy delete --global` @@ -486,6 +512,16 @@ Sandbox names default to the last-used sandbox. The CLI fetches and submits each --- +## Workspace Member Commands + +### `openshell workspace member list --workspace ` + +List workspace members. Add `--output table|yaml|json` to select the format. +Structured records contain `subject` and a normalized `role` of `admin`, +`user`, or `unknown`; empty lists serialize as empty collections. + +--- + ## Settings Commands Settings support sandbox and gateway-global scopes: diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index ad03fb4ec1..5bfae02387 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -742,7 +742,7 @@ fn normalize_completion_script(output: Vec, executable: &std::path::Path) -> Ok(script.replace(executable.to_string_lossy().as_ref(), "openshell")) } -#[derive(Clone, Debug, ValueEnum)] +#[derive(Clone, Debug, PartialEq, Eq, ValueEnum)] enum OutputFormat { Table, Yaml, @@ -1691,6 +1691,10 @@ enum SandboxProviderCommands { /// Sandbox name (defaults to last-used sandbox). #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))] name: Option, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, }, /// Attach a provider to a sandbox. @@ -1905,6 +1909,10 @@ enum PolicyCommands { /// List global policy revisions. #[arg(long)] global: bool, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, }, /// Delete the gateway-global policy lock, restoring sandbox-level policy control. @@ -2011,9 +2019,13 @@ enum ForwardCommands { name: Option, }, - /// List active port forwards. + /// List tracked port forwards. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] - List, + List { + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, + }, /// Forward a local TCP port to a loopback service inside a sandbox over gRPC. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] @@ -2071,6 +2083,10 @@ enum ServiceCommands { /// List services across all workspaces (overrides --workspace). #[arg(long)] all_workspaces: bool, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, }, /// Show one exposed sandbox service endpoint. @@ -2195,6 +2211,10 @@ enum WorkspaceMemberCommands { /// Offset into the member list. #[arg(long, default_value_t = 0)] offset: u32, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, }, } @@ -2445,8 +2465,23 @@ async fn run_async() -> Result<()> { ); } } - ForwardCommands::List => { + ForwardCommands::List { output } => { let forwards = run::list_forwards()?; + if openshell_cli::output::print_output_collection( + output.as_str(), + &forwards, + |forward| { + serde_json::json!({ + "sandbox": forward.sandbox_name, + "bind_address": forward.bind_addr, + "port": forward.port, + "pid": forward.pid, + "alive": forward.validated_alive, + }) + }, + )? { + return Ok(()); + } if forwards.is_empty() { eprintln!("No active forwards."); } else { @@ -2574,6 +2609,7 @@ async fn run_async() -> Result<()> { limit, offset, all_workspaces, + output, } => { run::service_list( &ctx.endpoint, @@ -2582,6 +2618,7 @@ async fn run_async() -> Result<()> { offset, &cli.workspace, all_workspaces, + output.as_str(), &tls, ) .await?; @@ -2745,14 +2782,28 @@ async fn run_async() -> Result<()> { name, limit, global, + output, } => { if global { - run::sandbox_policy_list_global(&ctx.endpoint, limit, &cli.workspace, &tls) - .await?; + run::sandbox_policy_list_global( + &ctx.endpoint, + limit, + output.as_str(), + &cli.workspace, + &tls, + ) + .await?; } else { let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; - run::sandbox_policy_list(&ctx.endpoint, &name, limit, &cli.workspace, &tls) - .await?; + run::sandbox_policy_list( + &ctx.endpoint, + &name, + limit, + output.as_str(), + &cli.workspace, + &tls, + ) + .await?; } } PolicyCommands::Delete { global, yes } => { @@ -3289,10 +3340,16 @@ async fn run_async() -> Result<()> { run::print_ssh_config(&ctx.name, &name, &cli.workspace); } SandboxCommands::Provider(command) => match command { - SandboxProviderCommands::List { name } => { + SandboxProviderCommands::List { name, output } => { let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; - run::sandbox_provider_list(endpoint, &name, &cli.workspace, &tls) - .await?; + run::sandbox_provider_list( + endpoint, + &name, + output.as_str(), + &cli.workspace, + &tls, + ) + .await?; } SandboxProviderCommands::Attach { name, provider } => { run::sandbox_provider_attach( @@ -3373,9 +3430,17 @@ async fn run_async() -> Result<()> { workspace, limit, offset, + output, } => { - run::workspace_member_list(endpoint, &workspace, limit, offset, &tls) - .await?; + run::workspace_member_list( + endpoint, + &workspace, + limit, + offset, + output.as_str(), + &tls, + ) + .await?; } }, } @@ -4527,6 +4592,152 @@ mod tests { )); } + #[test] + fn forward_list_default_output_is_table() { + let cli = Cli::try_parse_from(["openshell", "forward", "list"]) + .expect("forward list should parse"); + + assert!(matches!( + cli.command, + Some(Commands::Forward { + command: Some(ForwardCommands::List { + output: OutputFormat::Table, + }) + }) + )); + } + + #[test] + fn forward_list_accepts_structured_output() { + for (format, expected) in [("json", OutputFormat::Json), ("yaml", OutputFormat::Yaml)] { + let cli = Cli::try_parse_from(["openshell", "forward", "list", "-o", format]) + .unwrap_or_else(|error| panic!("forward list -o {format} should parse: {error}")); + + let Some(Commands::Forward { + command: Some(ForwardCommands::List { output }), + }) = cli.command + else { + panic!("expected forward list command"); + }; + assert_eq!(output, expected, "unexpected output format for {format}"); + } + } + + #[test] + fn remaining_list_commands_default_output_to_table() { + let sandbox_provider = Cli::try_parse_from(["openshell", "sandbox", "provider", "list"]) + .expect("sandbox provider list should parse"); + assert!(matches!( + sandbox_provider.command, + Some(Commands::Sandbox { + command: Some(SandboxCommands::Provider(SandboxProviderCommands::List { + output: OutputFormat::Table, + .. + })), + }) + )); + + let policy = + Cli::try_parse_from(["openshell", "policy", "list"]).expect("policy list should parse"); + assert!(matches!( + policy.command, + Some(Commands::Policy { + command: Some(PolicyCommands::List { + output: OutputFormat::Table, + .. + }), + }) + )); + + let service = Cli::try_parse_from(["openshell", "service", "list"]) + .expect("service list should parse"); + assert!(matches!( + service.command, + Some(Commands::Service { + command: Some(ServiceCommands::List { + output: OutputFormat::Table, + .. + }), + }) + )); + + let workspace_member = Cli::try_parse_from([ + "openshell", + "workspace", + "member", + "list", + "--workspace", + "default", + ]) + .expect("workspace member list should parse"); + assert!(matches!( + workspace_member.command, + Some(Commands::Workspace { + command: Some(WorkspaceCommands::Member(WorkspaceMemberCommands::List { + output: OutputFormat::Table, + .. + })), + }) + )); + } + + #[test] + fn remaining_list_commands_accept_structured_output() { + for (format, expected) in [("json", OutputFormat::Json), ("yaml", OutputFormat::Yaml)] { + let sandbox_provider = + Cli::try_parse_from(["openshell", "sandbox", "provider", "list", "-o", format]) + .unwrap_or_else(|error| panic!("sandbox provider list -o {format}: {error}")); + let Some(Commands::Sandbox { + command: + Some(SandboxCommands::Provider(SandboxProviderCommands::List { output, .. })), + }) = sandbox_provider.command + else { + panic!("expected sandbox provider list command"); + }; + assert_eq!(output, expected); + + let policy = Cli::try_parse_from(["openshell", "policy", "list", "--output", format]) + .unwrap_or_else(|error| panic!("policy list --output {format}: {error}")); + let Some(Commands::Policy { + command: Some(PolicyCommands::List { output, .. }), + }) = policy.command + else { + panic!("expected policy list command"); + }; + assert_eq!(output, expected); + + let service = Cli::try_parse_from(["openshell", "service", "list", "-o", format]) + .unwrap_or_else(|error| panic!("service list -o {format}: {error}")); + let Some(Commands::Service { + command: Some(ServiceCommands::List { output, .. }), + }) = service.command + else { + panic!("expected service list command"); + }; + assert_eq!(output, expected); + + let workspace_member = Cli::try_parse_from([ + "openshell", + "workspace", + "member", + "list", + "--workspace", + "default", + "-o", + format, + ]) + .unwrap_or_else(|error| panic!("workspace member list -o {format}: {error}")); + let Some(Commands::Workspace { + command: + Some(WorkspaceCommands::Member(WorkspaceMemberCommands::List { output, .. })), + }) = workspace_member.command + else { + panic!("expected workspace member list command"); + }; + assert_eq!(output, expected); + } + } + #[test] fn sandbox_list_default_output_is_table() { let cli = Cli::try_parse_from(["openshell", "sandbox", "list"]) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index b819be92f8..85d70713c6 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -2272,6 +2272,7 @@ fn sandbox_detail_to_json( pub async fn sandbox_provider_list( server: &str, name: &str, + output: &str, workspace: &str, tls: &TlsOptions, ) -> Result<()> { @@ -2285,6 +2286,10 @@ pub async fn sandbox_provider_list( .into_diagnostic()?; let providers = response.into_inner().providers; + if crate::output::print_output_collection(output, &providers, attached_provider_to_json)? { + return Ok(()); + } + if providers.is_empty() { println!("No providers attached to sandbox {name}."); return Ok(()); @@ -2410,6 +2415,18 @@ fn print_provider_attachment_table(providers: &[Provider]) { print!("{}", format_provider_attachment_table(providers, true)); } +fn attached_provider_to_json(provider: &Provider) -> serde_json::Value { + let mut config_keys = provider.config.keys().cloned().collect::>(); + config_keys.sort(); + + serde_json::json!({ + "name": provider.object_name(), + "type": provider.r#type, + "credential_keys": provider_credential_keys(provider), + "config_keys": config_keys, + }) +} + fn format_provider_attachment_table(providers: &[Provider], color: bool) -> String { use std::fmt::Write as _; @@ -3026,6 +3043,7 @@ fn service_expose_status_error(status: Status) -> miette::Report { service_status_error("expose service", "sandbox:write", status) } +#[allow(clippy::too_many_arguments)] // user-facing CLI command pub async fn service_list( server: &str, sandbox: Option<&str>, @@ -3033,6 +3051,7 @@ pub async fn service_list( offset: u32, workspace: &str, all_workspaces: bool, + output: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -3052,6 +3071,15 @@ pub async fn service_list( .map_err(|status| service_status_error("list services", "sandbox:read", status))? .into_inner(); + let services = response + .services + .iter() + .filter_map(|response| service_endpoint_to_json(response, server)) + .collect::>(); + if crate::output::print_output_collection(output, &services, Clone::clone)? { + return Ok(()); + } + if response.services.is_empty() { if let Some(sandbox) = sandbox { println!("No services exposed for sandbox {sandbox}."); @@ -3240,6 +3268,30 @@ fn print_service_endpoint_table( } } +fn service_endpoint_to_json( + response: &ServiceEndpointResponse, + gateway_endpoint: &str, +) -> Option { + let endpoint = response.endpoint.as_ref()?; + let workspace = endpoint + .metadata + .as_ref() + .map_or("", |metadata| metadata.workspace.as_str()); + let url = if response.url.is_empty() { + String::new() + } else { + service_url_for_gateway(&response.url, gateway_endpoint) + }; + + Some(serde_json::json!({ + "workspace": workspace, + "sandbox": endpoint.sandbox_name, + "service": endpoint.service_name, + "target_port": endpoint.target_port, + "url": url, + })) +} + fn service_display_name(service: &str) -> &str { if service.is_empty() { "-" } else { service } } @@ -5340,9 +5392,10 @@ pub async fn workspace_member_list( workspace: &str, limit: u32, offset: u32, + output: &str, tls: &TlsOptions, ) -> Result<()> { - use openshell_core::proto::{ListWorkspaceMembersRequest, WorkspaceRole}; + use openshell_core::proto::ListWorkspaceMembersRequest; let mut client = grpc_client(server, tls).await?; let response = client @@ -5355,6 +5408,10 @@ pub async fn workspace_member_list( .into_diagnostic()?; let members = response.into_inner().members; + if crate::output::print_output_collection(output, &members, workspace_member_to_json)? { + return Ok(()); + } + if members.is_empty() { println!("No members found in workspace {workspace}."); return Ok(()); @@ -5370,17 +5427,30 @@ pub async fn workspace_member_list( println!("{: "admin", - Ok(WorkspaceRole::User) => "user", - _ => "unknown", - }; + let role_str = workspace_member_role_name(member.role); println!("{: &'static str { + use openshell_core::proto::WorkspaceRole; + + match WorkspaceRole::try_from(role) { + Ok(WorkspaceRole::Admin) => "admin", + Ok(WorkspaceRole::User) => "user", + _ => "unknown", + } +} + +fn workspace_member_to_json(member: &openshell_core::proto::WorkspaceMember) -> serde_json::Value { + serde_json::json!({ + "subject": member.principal_subject, + "role": workspace_member_role_name(member.role), + }) +} + fn workspace_phase_str(workspace: &openshell_core::proto::Workspace) -> &'static str { use openshell_core::proto::datamodel::v1::WorkspacePhase; let phase = workspace @@ -6947,6 +7017,7 @@ pub async fn sandbox_policy_list( server: &str, name: &str, limit: u32, + output: &str, workspace: &str, tls: &TlsOptions, ) -> Result<()> { @@ -6964,6 +7035,11 @@ pub async fn sandbox_policy_list( .into_diagnostic()?; let revisions = resp.into_inner().revisions; + let structured = policy_revision_list_json("sandbox", Some(name), &revisions)?; + if crate::output::print_output_collection(output, &structured, Clone::clone)? { + return Ok(()); + } + if revisions.is_empty() { eprintln!("No policy history found for sandbox '{name}'"); return Ok(()); @@ -6976,6 +7052,7 @@ pub async fn sandbox_policy_list( pub async fn sandbox_policy_list_global( server: &str, limit: u32, + output: &str, workspace: &str, tls: &TlsOptions, ) -> Result<()> { @@ -6993,6 +7070,11 @@ pub async fn sandbox_policy_list_global( .into_diagnostic()?; let revisions = resp.into_inner().revisions; + let structured = policy_revision_list_json("global", None, &revisions)?; + if crate::output::print_output_collection(output, &structured, Clone::clone)? { + return Ok(()); + } + if revisions.is_empty() { eprintln!("No global policy history found"); return Ok(()); @@ -7002,6 +7084,28 @@ pub async fn sandbox_policy_list_global( Ok(()) } +fn policy_revision_list_json( + scope: &str, + sandbox: Option<&str>, + revisions: &[openshell_core::proto::SandboxPolicyRevision], +) -> Result> { + revisions + .iter() + .map(|revision| { + let status = + PolicyStatus::try_from(revision.status).unwrap_or(PolicyStatus::Unspecified); + policy_revision_to_json( + scope, + sandbox, + None, + revision, + status, + PolicyGetView::Metadata, + ) + }) + .collect() +} + fn print_policy_revision_table(revisions: &[openshell_core::proto::SandboxPolicyRevision]) { println!( "{:<8} {:<14} {:<12} {:<24} ERROR", @@ -7535,16 +7639,16 @@ fn format_endpoint(endpoint: &openshell_core::proto::NetworkEndpoint) -> String #[cfg(test)] mod tests { use super::{ - PolicyGetView, ProvisioningStep, build_sandbox_resource_limits, + PolicyGetView, ProvisioningStep, attached_provider_to_json, build_sandbox_resource_limits, dockerfile_sources_supported_for_gateway, format_endpoint, format_log_line, format_provider_attachment_table, git_sync_files, has_main_process_result, inferred_provider_type, parse_cli_setting_value, parse_credential_expiry_cli_value, parse_credential_expiry_pairs, parse_credential_pairs, parse_driver_config_json, - parse_secret_material_env_pairs, policy_revision_to_json, + parse_secret_material_env_pairs, policy_revision_list_json, policy_revision_to_json, provider_profile_allows_empty_credentials, provisioning_timeout_message, ready_false_condition_message, refresh_status_header, refresh_status_row, resolve_from, - sandbox_should_persist, sandbox_upload_plan, service_expose_status_error, - service_url_for_gateway, + sandbox_should_persist, sandbox_upload_plan, service_endpoint_to_json, + service_expose_status_error, service_url_for_gateway, workspace_member_to_json, }; use crate::TEST_ENV_LOCK; use crate::commands::common::progress_step_from_metadata; @@ -7561,12 +7665,13 @@ mod tests { PROGRESS_STEP_STARTING_SANDBOX, }; use openshell_core::proto::{ - GetSandboxConfigResponse, GpuResourceRequirements, PolicySource, PolicyStatus, Provider, - ProviderCredentialRefresh, ProviderCredentialRefreshRecoveryAction, + CredentialHandle, GetSandboxConfigResponse, GpuResourceRequirements, PolicySource, + PolicyStatus, Provider, ProviderCredentialRefresh, ProviderCredentialRefreshRecoveryAction, ProviderCredentialRefreshStatus, ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrant, ProviderProfile, ProviderProfileCredential, - ResourceRequirements, Sandbox, SandboxCondition, SandboxPhase, SandboxPolicyRevision, - SandboxStatus, datamodel::v1::ObjectMeta, + ResourceRequirements, Sandbox, SandboxCondition, SandboxPhase, SandboxPolicy, + SandboxPolicyRevision, SandboxStatus, ServiceEndpoint, ServiceEndpointResponse, + WorkspaceMember, WorkspaceRole, datamodel::v1::ObjectMeta, }; #[test] @@ -7597,6 +7702,174 @@ mod tests { ); } + #[test] + fn policy_list_json_reuses_metadata_contract() { + let load_error = "policy failed after checking café.example/非常に長いパス"; + let revisions = vec![SandboxPolicyRevision { + version: 7, + policy_hash: "0123456789abcdef".to_string(), + status: PolicyStatus::Failed as i32, + load_error: load_error.to_string(), + created_at_ms: 100, + loaded_at_ms: 200, + policy: Some(SandboxPolicy::default()), + provenance: std::collections::HashMap::from([( + "source".to_string(), + "provider-composition".to_string(), + )]), + }]; + + let values = policy_revision_list_json("sandbox", Some("dev"), &revisions) + .expect("policy list JSON"); + + assert_eq!( + values[0], + serde_json::json!({ + "scope": "sandbox", + "sandbox": "dev", + "version": 7, + "hash": "0123456789abcdef", + "status": "failed", + "created_at_ms": 100, + "loaded_at_ms": 200, + "load_error": load_error, + "provenance": {"source": "provider-composition"}, + }) + ); + assert!(values[0].get("policy").is_none()); + assert!(values[0].get("active_version").is_none()); + + let unknown = policy_revision_list_json( + "global", + None, + &[SandboxPolicyRevision { + version: 8, + status: 999, + ..Default::default() + }], + ) + .expect("global policy list JSON"); + assert_eq!(unknown[0]["scope"], "global"); + assert_eq!(unknown[0]["status"], "unspecified"); + assert!(unknown[0].get("sandbox").is_none()); + } + + #[test] + fn attached_provider_json_is_sorted_and_secret_safe() { + let provider = Provider { + metadata: Some(ObjectMeta { + name: "github".to_string(), + ..Default::default() + }), + r#type: "github".to_string(), + credentials: std::collections::HashMap::from([ + ("Z_TOKEN".to_string(), "inline-secret".to_string()), + ("SHARED".to_string(), "shared-secret".to_string()), + ]), + config: std::collections::HashMap::from([ + ("Z_URL".to_string(), "https://secret.example".to_string()), + ("A_MODE".to_string(), "sensitive-config".to_string()), + ]), + credential_expires_at_ms: std::collections::HashMap::from([( + "Z_TOKEN".to_string(), + 123, + )]), + profile_workspace: "internal".to_string(), + credential_handles: std::collections::HashMap::from([ + ( + "A_HANDLE".to_string(), + CredentialHandle { + driver: "vault".to_string(), + handle: "opaque-secret-handle".to_string(), + metadata: std::collections::HashMap::from([( + "internal".to_string(), + "metadata-value".to_string(), + )]), + }, + ), + ("SHARED".to_string(), CredentialHandle::default()), + ]), + }; + + let value = attached_provider_to_json(&provider); + assert_eq!( + value, + serde_json::json!({ + "name": "github", + "type": "github", + "credential_keys": ["A_HANDLE", "SHARED", "Z_TOKEN"], + "config_keys": ["A_MODE", "Z_URL"], + }) + ); + let serialized = value.to_string(); + for secret in [ + "inline-secret", + "shared-secret", + "https://secret.example", + "sensitive-config", + "opaque-secret-handle", + "metadata-value", + "internal", + "123", + ] { + assert!(!serialized.contains(secret), "leaked {secret}"); + } + } + + #[test] + fn service_endpoint_json_has_raw_fields_and_normalized_url() { + let response = ServiceEndpointResponse { + endpoint: Some(ServiceEndpoint { + metadata: Some(ObjectMeta { + workspace: "team-a".to_string(), + ..Default::default() + }), + sandbox_name: "api".to_string(), + service_name: String::new(), + target_port: 8080, + ..Default::default() + }), + url: "https://api.openshell.localhost:3000/".to_string(), + }; + + let value = service_endpoint_to_json(&response, "https://gateway.example:17670") + .expect("service endpoint JSON"); + assert_eq!( + value, + serde_json::json!({ + "workspace": "team-a", + "sandbox": "api", + "service": "", + "target_port": 8080, + "url": "https://api.openshell.localhost:17670/", + }) + ); + assert!(service_endpoint_to_json(&ServiceEndpointResponse::default(), "unused").is_none()); + } + + #[test] + fn workspace_member_json_uses_stable_role_names() { + for (role, expected) in [ + (WorkspaceRole::Admin as i32, "admin"), + (WorkspaceRole::User as i32, "user"), + (999, "unknown"), + ] { + let value = workspace_member_to_json(&WorkspaceMember { + metadata: Some(ObjectMeta { + id: "internal-id".to_string(), + ..Default::default() + }), + principal_subject: "oidc-subject".to_string(), + role, + }); + assert_eq!( + value, + serde_json::json!({"subject": "oidc-subject", "role": expected}) + ); + assert!(!value.to_string().contains("internal-id")); + } + } + #[test] fn parse_credential_pairs_accepts_key_value_form() { let parsed = parse_credential_pairs(&["API_KEY=abc123".to_string()]).expect("parse"); diff --git a/crates/openshell-cli/tests/forward_list_integration.rs b/crates/openshell-cli/tests/forward_list_integration.rs new file mode 100644 index 0000000000..50abfec37d --- /dev/null +++ b/crates/openshell-cli/tests/forward_list_integration.rs @@ -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> = 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 "), "{stdout}"); + assert!(stdout.contains("[default: table]"), "{stdout}"); + assert!( + stdout.contains("possible values: table, yaml, json"), + "{stdout}" + ); +} diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 84fb8f9163..bd4928d9f1 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -1686,7 +1686,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( diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index d8fa185a8c..c2337d49b9 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -335,6 +335,17 @@ List endpoints for one sandbox: openshell service list my-sandbox ``` +Use structured output for automation: + +```shell +openshell service list --output json +openshell service list my-sandbox --output yaml +``` + +Each record contains `workspace`, `sandbox`, `service`, `target_port`, and +`url`. The unnamed service uses an empty `service` string. An empty result is +an empty collection. + Show or delete one endpoint: ```shell @@ -433,6 +444,18 @@ openshell forward list openshell forward stop 8000 my-sandbox ``` +Use JSON or YAML output when inspecting tracked forwards from automation: + +```shell +openshell forward list --output json +openshell forward list --output yaml +``` + +Structured output includes `sandbox`, `bind_address`, `port`, `pid`, and +`alive`. The `alive` boolean reports whether the tracked PID still matches the +expected OpenShell SSH forward; it does not probe the forwarded socket. When no +forwards are tracked, structured output returns an empty collection. + You can also forward a port at creation time with `--forward`: diff --git a/docs/sandboxes/manage-workspaces.mdx b/docs/sandboxes/manage-workspaces.mdx index 5efbd4d09d..05378af5f5 100644 --- a/docs/sandboxes/manage-workspaces.mdx +++ b/docs/sandboxes/manage-workspaces.mdx @@ -121,6 +121,10 @@ openshell workspace member remove \ --subject 'oidc-subject-for-user' ``` +Add `--output json` or `--output yaml` to list membership records for +automation. Each record contains `subject` and a normalized `role` of `admin`, +`user`, or `unknown`. An empty result is an empty collection. + To change a member's role, remove the existing membership and add it again with the new role. A Platform Admin must perform any change to `admin`. diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index 16202751f8..b8a149a7bd 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -232,6 +232,11 @@ The following steps outline the hot-reload policy update workflow. openshell policy list ``` + Add `--output json` or `--output yaml` for automation. Structured policy + history contains the scope, sandbox name when applicable, version, full + hash, status, and available revision timestamps, load error, and provenance. + Use `openshell policy list --global --output json` for global history. + ### Validation failures OpenShell validates a complete candidate policy before activating any part of it. Endpoints may overlap when their connection and request-processing metadata agree. For example, two `api.example.com:443` REST entries can contribute different allow and deny rules when they use the same TLS, destination, credential, parser, and enforcement settings. A plain L4 endpoint may overlap an L7 endpoint because it authorizes the destination without contributing request-processing metadata. A more-specific path endpoint may override request-processing metadata from a broader endpoint, such as a `/graphql` GraphQL endpoint alongside a general REST endpoint for the same host. OpenShell rejects the candidate when overlapping exact or wildcard host selectors can both contribute equally specific endpoint configuration and disagree on those fields. diff --git a/docs/sandboxes/providers-v2.mdx b/docs/sandboxes/providers-v2.mdx index 7a0f96b5ac..49525d9143 100644 --- a/docs/sandboxes/providers-v2.mdx +++ b/docs/sandboxes/providers-v2.mdx @@ -861,6 +861,9 @@ openshell sandbox provider list provider-demo ``` The list output includes provider name, provider type, credential key count, and config key count. +Add `--output json` or `--output yaml` to return the provider name and type plus +sorted `credential_keys` and `config_keys` arrays. Structured output never +includes credential values, opaque credential handles, or config values. ## Policy Composition From f54a7a617760295cc101d6ec7f31df1dba50fc23 Mon Sep 17 00:00:00 2001 From: Simon Scatton <44714756+SDAChess@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:58:46 +0000 Subject: [PATCH 067/505] fix(release): provide conformance binary to e2e (#3097) Signed-off-by: Simon Scatton --- .github/workflows/release-dev.yml | 34 ++++++++++++++++++++++++++++--- .github/workflows/release-tag.yml | 34 ++++++++++++++++++++++++++++--- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 2d6fb31247..18b5f33a2e 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -63,6 +63,30 @@ 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 + 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-conformance-cli + binary: openshell-conformance + artifact-name: openshell-conformance-${{ matrix.triple }} + triple: ${{ matrix.triple }} + runner: ${{ matrix.runner }} + dev-shell: ${{ matrix.dev_shell }} + cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + secrets: inherit + build-gateway: needs: compute-versions permissions: @@ -125,7 +149,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 +158,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 +169,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 diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index c11b74c8a1..d47a6c7cb5 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -96,6 +96,31 @@ 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 + 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-conformance-cli + binary: openshell-conformance + artifact-name: openshell-conformance-${{ matrix.triple }} + triple: ${{ matrix.triple }} + runner: ${{ matrix.runner }} + dev-shell: ${{ matrix.dev_shell }} + cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + checkout-ref: ${{ inputs.tag || github.ref }} + secrets: inherit + build-gateway: needs: compute-versions permissions: @@ -165,7 +190,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 @@ -175,9 +200,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 @@ -186,9 +212,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 @@ -196,6 +223,7 @@ 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 From 7ceea968cdd46924d9187291fcacd47837aa51df Mon Sep 17 00:00:00 2001 From: "John T. Myers" <9696606+johntmyers@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:02:39 +0000 Subject: [PATCH 068/505] fix(policy): ignore advisor provenance during contract inference (#3069) * test(policy): reproduce advisor provenance contract conflict Signed-off-by: Piotr Mlocek * fix(policy): ignore advisor provenance during contract inference Signed-off-by: John Myers * test(policy): clarify advisor overlay provenance expectations Signed-off-by: John Myers --------- Signed-off-by: Piotr Mlocek Signed-off-by: John Myers Co-authored-by: Piotr Mlocek Co-authored-by: John Myers --- crates/openshell-policy/src/merge.rs | 66 +++++++++++++++++++++- crates/openshell-server/src/grpc/policy.rs | 9 ++- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/crates/openshell-policy/src/merge.rs b/crates/openshell-policy/src/merge.rs index a87defa340..24846bb407 100644 --- a/crates/openshell-policy/src/merge.rs +++ b/crates/openshell-policy/src/merge.rs @@ -45,9 +45,11 @@ pub fn canonicalize_advisor_add_rule( }) .cloned() .map(|mut endpoint| { - // This marker is derived from provider/credential context by the - // gateway and must never be persisted from an advisor proposal. + // Provenance does not change the endpoint contract. The gateway + // derives the credential marker, and the advisor marker records + // where a persisted endpoint came from. endpoint.provider_credentialed = false; + endpoint.advisor_proposed = false; // A denial observes one binary-to-port authorization. Preserve the // existing inspection contract, but never copy sibling ports from // a multi-port endpoint into the proposal. @@ -84,6 +86,7 @@ pub fn canonicalize_advisor_add_rule( .any(|endpoint| { let mut normalized = endpoint.clone(); normalized.provider_credentialed = false; + normalized.advisor_proposed = false; normalize_endpoint(&mut normalized); normalized == contract }) @@ -92,6 +95,12 @@ pub fn canonicalize_advisor_add_rule( .collect::>(); sandbox_owners.sort(); + let mut contract = contract; + if sandbox_owners.is_empty() { + // A provider-owned contract is mirrored into a new sandbox-owned + // advisor overlay, so retain the incoming proposal provenance. + contract.advisor_proposed = incoming_endpoint.advisor_proposed; + } let target_name = sandbox_owners .first() .cloned() @@ -2220,12 +2229,65 @@ mod tests { assert_eq!(canonical.endpoints[0].protocol, "rest"); assert_eq!(canonical.endpoints[0].access, "read-only"); assert!(!canonical.endpoints[0].provider_credentialed); + assert!(canonical.endpoints[0].advisor_proposed); assert_eq!( effective.network_policies["_provider_example"].endpoints[0], provider_endpoint ); } + #[test] + fn canonicalize_advisor_ignores_endpoint_provenance_when_inferring_contract() { + let mut provider_endpoint = endpoint("api.example.com", 443); + provider_endpoint.protocol = "rest".to_string(); + provider_endpoint.enforcement = "enforce".to_string(); + provider_endpoint.access = "read-only".to_string(); + provider_endpoint.provider_credentialed = true; + + let mut advisor_endpoint = provider_endpoint.clone(); + advisor_endpoint.provider_credentialed = false; + advisor_endpoint.advisor_proposed = true; + + let mut base = SandboxPolicy::default(); + base.network_policies.insert( + "existing_advisor".to_string(), + NetworkPolicyRule { + name: "existing-advisor".to_string(), + endpoints: vec![advisor_endpoint], + binaries: vec![advisor_binary("/usr/bin/curl")], + }, + ); + + let mut effective = base.clone(); + effective.network_policies.insert( + "_provider_example".to_string(), + NetworkPolicyRule { + name: "provider-example".to_string(), + endpoints: vec![provider_endpoint], + binaries: vec![binary("/usr/bin/gh")], + }, + ); + + let incoming = NetworkPolicyRule { + name: "advisor_example".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + advisor_proposed: true, + ..Default::default() + }], + binaries: vec![advisor_binary("/usr/bin/python")], + }; + + let (rule_name, canonical) = + canonicalize_advisor_add_rule(&base, &effective, "advisor_example", &incoming) + .expect("provenance alone must not create multiple endpoint contracts"); + + assert_eq!(rule_name, "existing_advisor"); + assert_eq!(canonical.endpoints[0].protocol, "rest"); + assert_eq!(canonical.endpoints[0].access, "read-only"); + } + #[test] fn canonicalize_advisor_narrows_multi_port_contract_and_keeps_overlay() { let mut existing_endpoint = endpoint("index.crates.io", 443); diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 6db1d12cc5..cee8739731 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -12973,7 +12973,10 @@ mod tests { let canonical = chunk.proposed_rule.as_ref().unwrap(); assert_eq!(canonical.endpoints[0].protocol, "rest"); assert_eq!(canonical.endpoints[0].access, "read-only"); - assert!(!canonical.endpoints[0].advisor_proposed); + assert!( + canonical.endpoints[0].advisor_proposed, + "a new advisor overlay must retain proposal provenance" + ); let revision = state .store @@ -12994,6 +12997,10 @@ mod tests { assert_eq!(curl_rule.endpoints[0].ports, vec![443]); assert_eq!(curl_rule.endpoints[0].protocol, "rest"); assert_eq!(curl_rule.endpoints[0].access, "read-only"); + assert!( + curl_rule.endpoints[0].advisor_proposed, + "the persisted advisor overlay must retain proposal provenance" + ); assert_eq!(curl_rule.binaries.len(), 1); assert_eq!(curl_rule.binaries[0].path, "/usr/bin/curl"); } From b960125e21621dcf95a76f0e85fd64ed68fc066a Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 1 Sep 2026 17:05:51 +0000 Subject: [PATCH 069/505] fix(gateway): batch SSH session cleanup writes (#3000) * fix(gateway): batch SSH session cleanup writes Part of #2999. Signed-off-by: Drew Newberry * test(server): reproduce SSH reaper pagination race Signed-off-by: Evan Lezar * fix(server): use keyset SSH session cleanup Signed-off-by: Evan Lezar --------- Signed-off-by: Drew Newberry Signed-off-by: Evan Lezar Co-authored-by: Evan Lezar --- architecture/compute-runtimes.md | 3 + crates/openshell-server/src/compute/mod.rs | 106 ++++++++++-- .../openshell-server/src/persistence/mod.rs | 86 ++++++++++ .../src/persistence/postgres.rs | 57 ++++++- .../src/persistence/sqlite.rs | 100 +++++++++++- .../openshell-server/src/persistence/tests.rs | 110 +++++++++++++ crates/openshell-server/src/ssh_sessions.rs | 152 +++++++++++++++--- 7 files changed, 566 insertions(+), 48 deletions(-) diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index b21dd0dc80..c41a793e4a 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -208,6 +208,9 @@ 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. The request acquires both locks before starting owned work, so cancellation while queued does not leave a delete armed. After that commitment point, the diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 4d4c227355..bf3215961c 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -20,8 +20,8 @@ pub use vm::VmComputeConfig; use crate::grpc::policy::SANDBOX_SETTINGS_OBJECT_TYPE; use crate::otel_tracing::TraceContextInterceptor; use crate::persistence::{ - DRAFT_CHUNK_OBJECT_TYPE, ObjectId, ObjectName, ObjectRecord, ObjectType, POLICY_OBJECT_TYPE, - Store, WriteCondition, + DRAFT_CHUNK_OBJECT_TYPE, ObjectCursor, ObjectId, ObjectName, ObjectRecord, ObjectType, + POLICY_OBJECT_TYPE, Store, WriteCondition, }; use crate::sandbox_index::SandboxIndex; use crate::sandbox_watch::SandboxWatchBus; @@ -70,7 +70,7 @@ use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::pin::Pin; use std::sync::{Arc, Mutex as StdMutex, Weak}; -use std::time::Duration; +use std::time::{Duration, Instant}; #[cfg(unix)] use tokio::net::UnixStream; use tokio::sync::{Mutex, watch}; @@ -3305,21 +3305,60 @@ impl ComputeRuntime { sandbox_id: &str, workspace: &str, ) -> Result<(), String> { - let records = self + let started = Instant::now(); + let mut cursor = None; + let mut scanned = 0_usize; + let mut decode_failures = 0_usize; + let mut session_ids = Vec::new(); + + loop { + let records = self + .store + .list_after( + SshSession::object_type(), + workspace, + cursor.as_ref(), + LIFECYCLE_SWEEP_PAGE_SIZE, + ) + .await + .map_err(|e| format!("list SSH sessions: {e}"))?; + let page_len = records.len(); + scanned += page_len; + + cursor = records.last().map(ObjectCursor::from); + for record in records { + match SshSession::decode(record.payload.as_slice()) { + Ok(session) if session.sandbox_id == sandbox_id => { + session_ids.push(session.object_id().to_string()); + } + Ok(_) => {} + Err(_) => decode_failures += 1, + } + } + + if page_len < LIFECYCLE_SWEEP_PAGE_SIZE as usize { + break; + } + } + + let matched = session_ids.len(); + let deleted = self .store - .list(SshSession::object_type(), workspace, 1000, 0) + .delete_many(SshSession::object_type(), &session_ids) .await - .map_err(|e| format!("list SSH sessions: {e}"))?; + .map_err(|e| format!("delete sandbox SSH sessions: {e}"))?; - for record in records { - if let Ok(session) = SshSession::decode(record.payload.as_slice()) - && session.sandbox_id == sandbox_id - { - self.store - .delete(SshSession::object_type(), session.object_id()) - .await - .map_err(|e| format!("delete SSH session {}: {e}", session.object_id()))?; - } + if matched > 0 || decode_failures > 0 { + debug!( + sandbox_id, + workspace, + scanned, + matched, + deleted, + decode_failures, + elapsed_ms = started.elapsed().as_millis(), + "Sandbox SSH session cleanup complete" + ); } Ok(()) @@ -7803,6 +7842,43 @@ mod tests { assert_eq!(driver.delete_calls(), 1); } + #[tokio::test] + async fn sandbox_ssh_session_cleanup_batches_across_list_pages() { + let runtime = test_runtime(ControlledDriver::new()).await; + for idx in 0..(LIFECYCLE_SWEEP_PAGE_SIZE + 5) { + let session = ssh_session_record(&format!("owned-{idx:04}"), "sb-owned"); + runtime.store.put_message(&session).await.unwrap(); + } + for idx in 0..7 { + let session = ssh_session_record(&format!("unrelated-{idx:04}"), "sb-unrelated"); + runtime.store.put_message(&session).await.unwrap(); + } + + runtime + .cleanup_sandbox_ssh_sessions("sb-owned", "default") + .await + .unwrap(); + + assert_eq!( + runtime + .store + .count_in_workspace(SshSession::object_type(), "default") + .await + .unwrap(), + 7 + ); + for idx in 0..7 { + assert!( + runtime + .store + .get_message::(&format!("unrelated-{idx:04}")) + .await + .unwrap() + .is_some() + ); + } + } + #[tokio::test] async fn already_absent_driver_resource_is_removed_synchronously() { let driver = ControlledDriver::new(); diff --git a/crates/openshell-server/src/persistence/mod.rs b/crates/openshell-server/src/persistence/mod.rs index 516faf4fe4..6934630110 100644 --- a/crates/openshell-server/src/persistence/mod.rs +++ b/crates/openshell-server/src/persistence/mod.rs @@ -26,6 +26,12 @@ pub const DRAFT_CHUNK_OBJECT_TYPE: &str = "draft_policy_chunk"; pub type PersistenceResult = Result; +/// Maximum number of object ids sent in one set-based delete statement. +/// +/// Keep this well below `SQLite`'s bind-variable limit. Backends split larger +/// requests into independently retryable, bounded write statements. +pub const DELETE_MANY_BATCH_SIZE: usize = 128; + /// Persistence-layer error type. #[derive(Debug, Error, Clone)] pub enum PersistenceError { @@ -101,6 +107,30 @@ pub struct ObjectRecord { pub resource_version: u64, } +/// Stable position in the global object-listing order. +/// +/// Keyset consumers must use the matching store method for the order encoded +/// here: workspace-scoped lists use `created_at_ms`, `name`, and `id`; global +/// lists additionally include `workspace`. +#[derive(Debug, Clone)] +pub struct ObjectCursor { + pub created_at_ms: i64, + pub name: String, + pub workspace: String, + pub id: String, +} + +impl From<&ObjectRecord> for ObjectCursor { + fn from(record: &ObjectRecord) -> Self { + Self { + created_at_ms: record.created_at_ms, + name: record.name.clone(), + workspace: record.workspace.clone(), + id: record.id.clone(), + } + } +} + /// Write condition for compare-and-swap operations. #[derive(Debug, Clone, Copy)] pub enum WriteCondition { @@ -415,6 +445,22 @@ impl Store { store_dispatch_traced!(self.delete(object_type, id)) } + /// Delete objects of one type by id in bounded, set-based statements. + #[tracing::instrument( + name = "store", + skip_all, + fields( + otel.name = "store.delete_many", + otel.status_code = tracing::field::Empty, + object_type = %object_type, + object_count = ids.len(), + batch_count = ids.len().div_ceil(DELETE_MANY_BATCH_SIZE), + ) + )] + pub async fn delete_many(&self, object_type: &str, ids: &[String]) -> PersistenceResult { + store_dispatch_traced!(self.delete_many(object_type, ids)) + } + /// Count objects of a given type within a workspace. #[tracing::instrument( name = "store", @@ -499,6 +545,46 @@ impl Store { store_dispatch_traced!(self.list_by_type(object_type, limit, offset)) } + /// List workspace objects after a stable cursor, without offset drift. + #[tracing::instrument( + name = "store", + skip_all, + fields( + otel.name = "store.list_after", + otel.status_code = tracing::field::Empty, + object_type = %object_type, + workspace = %workspace, + ) + )] + pub async fn list_after( + &self, + object_type: &str, + workspace: &str, + after: Option<&ObjectCursor>, + limit: u32, + ) -> PersistenceResult> { + store_dispatch_traced!(self.list_after(object_type, workspace, after, limit)) + } + + /// List objects across workspaces after a stable cursor, without offset drift. + #[tracing::instrument( + name = "store", + skip_all, + fields( + otel.name = "store.list_by_type_after", + otel.status_code = tracing::field::Empty, + object_type = %object_type, + ) + )] + pub async fn list_by_type_after( + &self, + object_type: &str, + after: Option<&ObjectCursor>, + limit: u32, + ) -> PersistenceResult> { + store_dispatch_traced!(self.list_by_type_after(object_type, after, limit)) + } + /// List objects by type and application-owned scope. /// /// Workspace filtering is intentionally omitted: scope values are sandbox diff --git a/crates/openshell-server/src/persistence/postgres.rs b/crates/openshell-server/src/persistence/postgres.rs index 8bab0ada96..89fe442695 100644 --- a/crates/openshell-server/src/persistence/postgres.rs +++ b/crates/openshell-server/src/persistence/postgres.rs @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 use super::{ - DraftChunkRecord, ObjectRecord, PersistenceError, PersistenceResult, PolicyRecord, - WriteCondition, WriteResult, current_time_ms, map_db_error, map_migrate_error, + DraftChunkRecord, ObjectCursor, ObjectRecord, PersistenceError, PersistenceResult, + PolicyRecord, WriteCondition, WriteResult, current_time_ms, map_db_error, map_migrate_error, }; use crate::policy_store::{ AtomicPolicyRevisionWrite, draft_chunk_payload_from_record, draft_chunk_record_from_parts, @@ -14,11 +14,11 @@ use openshell_core::SetResourceVersion; use openshell_core::proto::Sandbox; use prost::Message; use sqlx::postgres::PgPoolOptions; -use sqlx::{Connection, PgPool, Row}; +use sqlx::{Connection, PgPool, Postgres, QueryBuilder, Row}; static POSTGRES_MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations/postgres"); -use super::{DRAFT_CHUNK_OBJECT_TYPE, POLICY_OBJECT_TYPE}; +use super::{DELETE_MANY_BATCH_SIZE, DRAFT_CHUNK_OBJECT_TYPE, POLICY_OBJECT_TYPE}; #[derive(Debug, Clone)] pub struct PostgresStore { @@ -391,6 +391,28 @@ WHERE object_type = $1 AND workspace = $2 AND name = $3 Ok(result.rows_affected() > 0) } + pub async fn delete_many(&self, object_type: &str, ids: &[String]) -> PersistenceResult { + let mut deleted = 0_u64; + for ids in ids.chunks(DELETE_MANY_BATCH_SIZE) { + let mut query = + QueryBuilder::::new("DELETE FROM objects WHERE object_type = "); + query.push_bind(object_type).push(" AND id IN ("); + let mut separated = query.separated(", "); + for id in ids { + separated.push_bind(id); + } + separated.push_unseparated(")"); + + deleted += query + .build() + .execute(&self.pool) + .await + .map_err(|e| map_db_error(&e))? + .rows_affected(); + } + Ok(deleted) + } + pub async fn count_in_workspace( &self, object_type: &str, @@ -500,6 +522,33 @@ LIMIT $2 OFFSET $3 Ok(rows.into_iter().map(row_to_object_record).collect()) } + pub async fn list_after( + &self, + object_type: &str, + workspace: &str, + after: Option<&ObjectCursor>, + limit: u32, + ) -> PersistenceResult> { + let rows = if let Some(cursor) = after { + sqlx::query("SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version FROM objects WHERE object_type = $1 AND workspace = $2 AND (created_at_ms, name, id) > ($3, $4, $5) ORDER BY created_at_ms, name, id LIMIT $6").bind(object_type).bind(workspace).bind(cursor.created_at_ms).bind(&cursor.name).bind(&cursor.id).bind(i64::from(limit)).fetch_all(&self.pool).await + } else { + sqlx::query("SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version FROM objects WHERE object_type = $1 AND workspace = $2 ORDER BY created_at_ms, name, id LIMIT $3").bind(object_type).bind(workspace).bind(i64::from(limit)).fetch_all(&self.pool).await + }.map_err(|e| map_db_error(&e))?; + Ok(rows.into_iter().map(row_to_object_record).collect()) + } + pub async fn list_by_type_after( + &self, + object_type: &str, + after: Option<&ObjectCursor>, + limit: u32, + ) -> PersistenceResult> { + let rows = if let Some(cursor) = after { + sqlx::query("SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version FROM objects WHERE object_type = $1 AND (created_at_ms, name, workspace, id) > ($2, $3, $4, $5) ORDER BY created_at_ms, name, workspace, id LIMIT $6").bind(object_type).bind(cursor.created_at_ms).bind(&cursor.name).bind(&cursor.workspace).bind(&cursor.id).bind(i64::from(limit)).fetch_all(&self.pool).await + } else { + sqlx::query("SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version FROM objects WHERE object_type = $1 ORDER BY created_at_ms, name, workspace, id LIMIT $2").bind(object_type).bind(i64::from(limit)).fetch_all(&self.pool).await + }.map_err(|e| map_db_error(&e))?; + Ok(rows.into_iter().map(row_to_object_record).collect()) + } pub async fn list_with_membership( &self, diff --git a/crates/openshell-server/src/persistence/sqlite.rs b/crates/openshell-server/src/persistence/sqlite.rs index 658530f753..c28318d261 100644 --- a/crates/openshell-server/src/persistence/sqlite.rs +++ b/crates/openshell-server/src/persistence/sqlite.rs @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 use super::{ - DraftChunkRecord, ObjectRecord, PersistenceError, PersistenceResult, PolicyRecord, - WriteCondition, WriteResult, current_time_ms, map_db_error, map_migrate_error, + DraftChunkRecord, ObjectCursor, ObjectRecord, PersistenceError, PersistenceResult, + PolicyRecord, WriteCondition, WriteResult, current_time_ms, map_db_error, map_migrate_error, }; use crate::policy_store::{ AtomicPolicyRevisionWrite, draft_chunk_payload_from_record, draft_chunk_record_from_parts, @@ -15,13 +15,13 @@ use openshell_core::paths::set_file_owner_only; use openshell_core::proto::Sandbox; use prost::Message; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; -use sqlx::{Connection, Row, SqlitePool}; +use sqlx::{Connection, QueryBuilder, Row, Sqlite, SqlitePool}; use std::path::{Path, PathBuf}; use std::str::FromStr; static SQLITE_MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations/sqlite"); -use super::{DRAFT_CHUNK_OBJECT_TYPE, POLICY_OBJECT_TYPE}; +use super::{DELETE_MANY_BATCH_SIZE, DRAFT_CHUNK_OBJECT_TYPE, POLICY_OBJECT_TYPE}; #[derive(Debug, Clone)] pub struct SqliteStore { @@ -416,6 +416,27 @@ WHERE "object_type" = ?1 AND "id" = ?2 Ok(result.rows_affected() > 0) } + pub async fn delete_many(&self, object_type: &str, ids: &[String]) -> PersistenceResult { + let mut deleted = 0_u64; + for ids in ids.chunks(DELETE_MANY_BATCH_SIZE) { + let mut query = QueryBuilder::::new("DELETE FROM objects WHERE object_type = "); + query.push_bind(object_type).push(" AND id IN ("); + let mut separated = query.separated(", "); + for id in ids { + separated.push_bind(id); + } + separated.push_unseparated(")"); + + deleted += query + .build() + .execute(&self.pool) + .await + .map_err(|e| map_db_error(&e))? + .rows_affected(); + } + Ok(deleted) + } + pub async fn count_in_workspace( &self, object_type: &str, @@ -541,6 +562,77 @@ LIMIT ?2 OFFSET ?3 Ok(rows.into_iter().map(row_to_object_record).collect()) } + pub async fn list_after( + &self, + object_type: &str, + workspace: &str, + after: Option<&ObjectCursor>, + limit: u32, + ) -> PersistenceResult> { + let rows = if let Some(cursor) = after { + sqlx::query( + r#" +SELECT "object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" +FROM "objects" +WHERE "object_type" = ?1 AND "workspace" = ?2 + AND ("created_at_ms", "name", "id") > (?3, ?4, ?5) +ORDER BY "created_at_ms" ASC, "name" ASC, "id" ASC +LIMIT ?6 +"#, + ) + .bind(object_type) + .bind(workspace) + .bind(cursor.created_at_ms) + .bind(&cursor.name) + .bind(&cursor.id) + .bind(i64::from(limit)) + .fetch_all(&self.pool) + .await + } else { + sqlx::query( + r#" +SELECT "object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" +FROM "objects" +WHERE "object_type" = ?1 AND "workspace" = ?2 +ORDER BY "created_at_ms" ASC, "name" ASC, "id" ASC +LIMIT ?3 +"#, + ) + .bind(object_type) + .bind(workspace) + .bind(i64::from(limit)) + .fetch_all(&self.pool) + .await + } + .map_err(|e| map_db_error(&e))?; + Ok(rows.into_iter().map(row_to_object_record).collect()) + } + pub async fn list_by_type_after( + &self, + object_type: &str, + after: Option<&ObjectCursor>, + limit: u32, + ) -> PersistenceResult> { + let rows = if let Some(cursor) = after { + sqlx::query(r#" +SELECT "object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" +FROM "objects" +WHERE "object_type" = ?1 + AND ("created_at_ms", "name", "workspace", "id") > (?2, ?3, ?4, ?5) +ORDER BY "created_at_ms" ASC, "name" ASC, "workspace" ASC, "id" ASC +LIMIT ?6 +"#).bind(object_type).bind(cursor.created_at_ms).bind(&cursor.name).bind(&cursor.workspace).bind(&cursor.id).bind(i64::from(limit)).fetch_all(&self.pool).await + } else { + sqlx::query(r#" +SELECT "object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" +FROM "objects" +WHERE "object_type" = ?1 +ORDER BY "created_at_ms" ASC, "name" ASC, "workspace" ASC, "id" ASC +LIMIT ?2 +"#).bind(object_type).bind(i64::from(limit)).fetch_all(&self.pool).await + }.map_err(|e| map_db_error(&e))?; + Ok(rows.into_iter().map(row_to_object_record).collect()) + } pub async fn list_with_membership( &self, diff --git a/crates/openshell-server/src/persistence/tests.rs b/crates/openshell-server/src/persistence/tests.rs index 8802ac8d10..479f46e493 100644 --- a/crates/openshell-server/src/persistence/tests.rs +++ b/crates/openshell-server/src/persistence/tests.rs @@ -366,6 +366,116 @@ async fn sqlite_delete_behavior() { assert!(!deleted_again); } +#[tokio::test] +async fn delete_many_is_bounded_idempotent_and_type_scoped() { + let store = test_store().await; + let mut ids = Vec::new(); + for idx in 0..(super::DELETE_MANY_BATCH_SIZE + 12) { + let id = format!("sandbox-{idx}"); + store + .put( + "sandbox", + &id, + &format!("name-{idx}"), + "default", + b"payload", + None, + ) + .await + .unwrap(); + ids.push(id); + } + store + .put( + "provider", + "other-type", + "other-type", + "default", + b"payload", + None, + ) + .await + .unwrap(); + + ids.extend([ + "missing".to_string(), + "other-type".to_string(), + "sandbox-0".to_string(), + ]); + let expected = u64::try_from(super::DELETE_MANY_BATCH_SIZE + 12).unwrap(); + assert_eq!(store.delete_many("sandbox", &ids).await.unwrap(), expected); + assert_eq!(store.delete_many("sandbox", &ids).await.unwrap(), 0); + assert_eq!(store.delete_many("sandbox", &[]).await.unwrap(), 0); + assert!(store.get("provider", "other-type").await.unwrap().is_some()); +} + +#[tokio::test] +async fn file_backed_sqlite_bulk_delete_allows_concurrent_control_reads() { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + let tmp = tempfile::tempdir().expect("tempdir"); + let url = format!("sqlite:{}?mode=rwc", tmp.path().join("bulk.db").display()); + let store = Store::connect(&url) + .await + .expect("connect file-backed store"); + store + .put( + "provider", + "control-row", + "control-row", + "default", + b"control", + None, + ) + .await + .unwrap(); + + let mut ids = Vec::new(); + for idx in 0..500 { + let id = format!("session-{idx}"); + store + .put("ssh_session", &id, &id, "default", b"payload", None) + .await + .unwrap(); + ids.push(id); + } + + let stop = Arc::new(AtomicBool::new(false)); + let read_store = store.clone(); + let read_stop = stop.clone(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let reader = tokio::spawn(async move { + let mut reads = 0_usize; + let mut started_tx = Some(started_tx); + while !read_stop.load(Ordering::Relaxed) { + read_store + .get("provider", "control-row") + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "control row disappeared".to_string())?; + reads += 1; + if let Some(started_tx) = started_tx.take() { + let _ = started_tx.send(()); + } + tokio::task::yield_now().await; + } + Ok::(reads) + }); + started_rx.await.expect("reader started"); + + assert_eq!(store.delete_many("ssh_session", &ids).await.unwrap(), 500); + stop.store(true, Ordering::Relaxed); + assert!(reader.await.unwrap().unwrap() > 0); + assert!( + store + .get("provider", "control-row") + .await + .unwrap() + .is_some() + ); +} + #[tokio::test] async fn sqlite_protobuf_round_trip() { let store = test_store().await; diff --git a/crates/openshell-server/src/ssh_sessions.rs b/crates/openshell-server/src/ssh_sessions.rs index 490f0dbb25..12081c217d 100644 --- a/crates/openshell-server/src/ssh_sessions.rs +++ b/crates/openshell-server/src/ssh_sessions.rs @@ -7,11 +7,14 @@ use openshell_core::ObjectId; use openshell_core::proto::SshSession; use openshell_core::time::now_ms; use prost::Message; +use std::future::Future; use std::sync::Arc; use std::time::Duration; use tracing::{info, warn}; -use crate::persistence::{ObjectType, Store}; +use crate::persistence::{ObjectCursor, ObjectType, Store}; + +const SESSION_REAPER_PAGE_SIZE: u32 = 1000; impl ObjectType for SshSession { fn object_type() -> &'static str { @@ -34,37 +37,69 @@ pub fn spawn_session_reaper(store: Arc, interval: Duration) { } async fn reap_expired_sessions(store: &Store) -> Result<(), String> { - let now_ms = now_ms(); - - let records = store - .list_by_type(SshSession::object_type(), 1000, 0) - .await - .map_err(|e| e.to_string())?; + reap_expired_sessions_after_page(store, |_| std::future::ready(())).await +} - let mut reaped = 0u32; - for record in records { - let session: SshSession = match Message::decode(record.payload.as_slice()) { - Ok(s) => s, - Err(_) => continue, - }; +async fn reap_expired_sessions_after_page( + store: &Store, + mut after_page: F, +) -> Result<(), String> +where + F: FnMut(usize) -> Fut, + Fut: Future, +{ + let now_ms = now_ms(); + let started = std::time::Instant::now(); + let mut cursor = None; + let mut page_number = 0_usize; + let mut scanned = 0_usize; + let mut decode_failures = 0_usize; + let mut session_ids = Vec::new(); - let should_delete = - (session.expires_at_ms > 0 && now_ms > session.expires_at_ms) || session.revoked; + loop { + let records = store + .list_by_type_after( + SshSession::object_type(), + cursor.as_ref(), + SESSION_REAPER_PAGE_SIZE, + ) + .await + .map_err(|e| e.to_string())?; + let page_len = records.len(); + scanned += page_len; - if should_delete { - if let Err(e) = store - .delete(SshSession::object_type(), session.object_id()) - .await - { - warn!(session_id = %session.object_id(), error = %e, "Failed to reap SSH session"); - } else { - reaped += 1; + cursor = records.last().map(ObjectCursor::from); + for record in records { + let Ok(session) = SshSession::decode(record.payload.as_slice()) else { + decode_failures += 1; + continue; + }; + if (session.expires_at_ms > 0 && now_ms > session.expires_at_ms) || session.revoked { + session_ids.push(session.object_id().to_string()); } } + + if page_len < SESSION_REAPER_PAGE_SIZE as usize { + break; + } + page_number += 1; + after_page(page_number).await; } - if reaped > 0 { - info!(count = reaped, "SSH session reaper: cleaned up sessions"); + let matched = session_ids.len(); + let deleted = store + .delete_many(SshSession::object_type(), &session_ids) + .await + .map_err(|e| e.to_string())?; + if matched > 0 || decode_failures > 0 { + info!( + scanned, + matched, + deleted, + decode_failures, + elapsed_ms = started.elapsed().as_millis(), + "SSH session reaper sweep complete" + ); } Ok(()) } @@ -175,4 +210,71 @@ mod tests { "session with no expiry should be preserved" ); } + + #[tokio::test] + async fn reaper_batches_expired_and_revoked_sessions() { + let store = test_store().await; + let session_count = crate::persistence::DELETE_MANY_BATCH_SIZE + 9; + for idx in 0..session_count { + let session = make_session( + &format!("reap-{idx}"), + "sbx1", + if idx % 2 == 0 { now_ms() - 1 } else { 0 }, + idx % 2 != 0, + ); + store.put_message(&session).await.unwrap(); + } + let active = make_session("keep", "sbx1", now_ms() + 60_000, false); + store.put_message(&active).await.unwrap(); + + reap_expired_sessions(&store).await.unwrap(); + + assert_eq!( + store + .count_in_workspace(SshSession::object_type(), "default") + .await + .unwrap(), + 1 + ); + assert!( + store + .get_message::("keep") + .await + .unwrap() + .is_some() + ); + } + + #[tokio::test] + async fn reaper_removes_every_expired_session_when_an_earlier_page_row_is_deleted() { + let store = test_store().await; + for idx in 0..=SESSION_REAPER_PAGE_SIZE { + let session = make_session(&format!("reap-{idx:04}"), "sbx1", now_ms() - 1, false); + store.put_message(&session).await.unwrap(); + } + + let delete_store = store.clone(); + reap_expired_sessions_after_page(&store, move |page_number| { + let delete_store = delete_store.clone(); + async move { + if page_number == 1 { + delete_store + .delete(SshSession::object_type(), "reap-0000") + .await + .unwrap(); + } + } + }) + .await + .unwrap(); + + assert_eq!( + store + .count_in_workspace(SshSession::object_type(), "default") + .await + .unwrap(), + 0, + "the reaper must not leave an expired session behind" + ); + } } From e508c169ea3700ad822401ae363c475520c3eafb Mon Sep 17 00:00:00 2001 From: Yuedong Wu Date: Tue, 1 Sep 2026 18:10:40 +0000 Subject: [PATCH 070/505] fix(helm): honor empty clientCaSecretName for HTTPS-only mode (#2235) * fix(helm): honor empty clientCaSecretName for HTTPS-only mode Signed-off-by: Yuedong Wu * docs(skills): document HTTPS-only clientCaSecretName in debug-openshell-cluster Signed-off-by: Yuedong Wu --------- Signed-off-by: Yuedong Wu --- .../skills/debug-openshell-cluster/SKILL.md | 8 ++ deploy/helm/openshell/README.md | 2 +- .../openshell/templates/_gateway-workload.tpl | 4 +- deploy/helm/openshell/templates/_helpers.tpl | 17 ++++ .../openshell/templates/gateway-config.yaml | 2 + .../openshell/tests/gateway_config_test.yaml | 96 +++++++++++++++++++ .../tests/statefulset_client_ca_test.yaml | 36 +++++++ deploy/helm/openshell/values.yaml | 1 + docs/kubernetes/access-control.mdx | 8 +- docs/reference/gateway-config.mdx | 2 + 10 files changed, 172 insertions(+), 4 deletions(-) diff --git a/.agents/skills/debug-openshell-cluster/SKILL.md b/.agents/skills/debug-openshell-cluster/SKILL.md index b2019652b8..86395af148 100644 --- a/.agents/skills/debug-openshell-cluster/SKILL.md +++ b/.agents/skills/debug-openshell-cluster/SKILL.md @@ -316,6 +316,13 @@ kubectl -n openshell get secret \ openshell-jwt-keys ``` +When `server.tls.clientCaSecretName=""`, the chart intentionally omits +`client_ca_path` and the `tls-client-ca` mount, even with built-in PKI or +cert-manager. That is expected; do not treat a missing `tls-client-ca` pod +mount as a defect (`openshell-server-client-ca` may still exist from PKI). +User auth is OIDC or trusted proxy (`server.auth.allowUnauthenticatedUsers=true`); +supervisor transport still uses `openshell-client-tls`. + In cert-manager installs, `certManager.enabled=true` makes cert-manager own TLS generation. The Helm chart should still render the `openshell-certgen` pre-install/pre-upgrade hook in JWT-only mode to create `openshell-jwt-keys`, @@ -638,6 +645,7 @@ openshell logs | HTTPS ingress returns `Unauthenticated` after connecting | TLS terminates at Envoy, so the gateway never sees a client cert; no OIDC issuer is configured for identity | Configure `server.oidc.issuer` and register with `openshell gateway add https:// --oidc-issuer `, or set `server.auth.allowUnauthenticatedUsers=true` for a trusted-proxy/dev cluster | | External server `Certificate` never becomes Ready with `certManager.serverIssuerRef` set | ACME issuer rejected internal-only SANs, a loopback IP, or a `commonName` absent from the SANs | `kubectl -n openshell describe certificate openshell-server-external`; confirm `certManager.serverDnsNames` lists only real, externally-resolvable hostnames | | Sandbox supervisors fail TLS handshake with `UnknownCA` after configuring `certManager.serverIssuerRef` | `server.grpcEndpoint` is set to the external hostname, forcing supervisors to receive the ACME cert (via SNI) which they can't verify against chart CA | Remove `server.grpcEndpoint` or set it to the internal service name; supervisors should connect via internal service name to receive the internal cert | +| Browser `ERR_BAD_SSL_CLIENT_AUTH_CERT` or gateway logs show client cert verification when OIDC or direct HTTPS is expected | Listener client-CA verification still enabled (`clientCaSecretName` unset or `client_ca_path` in ConfigMap) | Set `server.tls.clientCaSecretName=""`, upgrade chart, confirm ConfigMap omits `client_ca_path` | ## Reporting diff --git a/deploy/helm/openshell/README.md b/deploy/helm/openshell/README.md index 1786a46f63..9d1b9cd7dd 100644 --- a/deploy/helm/openshell/README.md +++ b/deploy/helm/openshell/README.md @@ -278,7 +278,7 @@ discovery endpoint or its TLS CA. | server.sandboxNamespace | string | `""` | Namespace where sandbox pods are created. Defaults to the Helm release namespace (.Release.Namespace) when left empty. | | server.telemetryEnabled | bool | `true` | Enable anonymous OpenShell telemetry from the gateway and the sandbox supervisors it launches. | | server.tls.certSecretName | string | `"openshell-server-tls"` | K8s secret (type kubernetes.io/tls) with tls.crt and tls.key for the server. | -| server.tls.clientCaSecretName | string | `"openshell-server-client-ca"` | K8s secret with ca.crt for client certificate verification (mTLS). Set to "" to disable mTLS and run HTTPS-only (use OIDC for auth instead). | +| server.tls.clientCaSecretName | string | `"openshell-server-client-ca"` | K8s secret with ca.crt for client certificate verification (mTLS). Set to "" to disable mTLS and run HTTPS-only (use OIDC for auth instead). Do not set to null; omit the key to use the default secret name above. | | server.tls.clientTlsSecretName | string | `"openshell-client-tls"` | K8s secret mounted into sandbox pods for mTLS to the server. | | server.workspaceDefaultStorageSize | string | `""` | Default storage size for the workspace PVC in sandbox pods. Uses Kubernetes quantity syntax (e.g. "2Gi", "10Gi", "500Mi"). Empty = built-in default (2Gi). | | server.workspaceStorageClass | string | `""` | Kubernetes StorageClass for the workspace PVC in sandbox pods. Empty (default) = omit storageClassName, using the cluster's default StorageClass. Set this on clusters with no default StorageClass, otherwise the workspace PVC stays Pending and the sandbox never starts. | diff --git a/deploy/helm/openshell/templates/_gateway-workload.tpl b/deploy/helm/openshell/templates/_gateway-workload.tpl index a73acc9810..54d9e1ff99 100644 --- a/deploy/helm/openshell/templates/_gateway-workload.tpl +++ b/deploy/helm/openshell/templates/_gateway-workload.tpl @@ -102,7 +102,7 @@ spec: mountPath: /etc/openshell-tls/server-external readOnly: true {{- end }} - {{- if or .Values.server.tls.clientCaSecretName (and .Values.pkiInitJob.enabled (not .Values.certManager.enabled)) (and .Values.certManager.enabled .Values.certManager.clientCaFromServerTlsSecret) }} + {{- if eq (include "openshell.gatewayClientCaEnabled" .) "true" }} - name: tls-client-ca mountPath: /etc/openshell-tls/client-ca readOnly: true @@ -172,7 +172,7 @@ spec: secret: secretName: {{ include "openshell.fullname" . }}-server-external-tls {{- end }} - {{- if or .Values.server.tls.clientCaSecretName (and .Values.pkiInitJob.enabled (not .Values.certManager.enabled)) (and .Values.certManager.enabled .Values.certManager.clientCaFromServerTlsSecret) }} + {{- if eq (include "openshell.gatewayClientCaEnabled" .) "true" }} - name: tls-client-ca secret: {{- if or (and .Values.pkiInitJob.enabled (not .Values.certManager.enabled)) (and .Values.certManager.enabled .Values.certManager.clientCaFromServerTlsSecret) }} diff --git a/deploy/helm/openshell/templates/_helpers.tpl b/deploy/helm/openshell/templates/_helpers.tpl index 548418abc6..5b67c018c4 100644 --- a/deploy/helm/openshell/templates/_helpers.tpl +++ b/deploy/helm/openshell/templates/_helpers.tpl @@ -83,6 +83,20 @@ so a released chart automatically pulls the matching image without extra overrid ghcr.io/nvidia/openshell/supervisor {{- end }} +{{/* +Whether the gateway listener should verify client certificates (mTLS). +An explicit empty server.tls.clientCaSecretName disables client-CA wiring in +both gateway.toml and the workload, overriding built-in PKI and cert-manager +defaults. +*/}} +{{- define "openshell.gatewayClientCaEnabled" -}} +{{- if .Values.server.disableTls -}} +{{- else if eq .Values.server.tls.clientCaSecretName "" -}} +{{- else if or .Values.server.tls.clientCaSecretName (and .Values.pkiInitJob.enabled (not .Values.certManager.enabled)) (and .Values.certManager.enabled .Values.certManager.clientCaFromServerTlsSecret) -}} +true +{{- end -}} +{{- end -}} + {{/* Whether Helm must propagate a supervisor image override into gateway.toml. The chart's documented repository and empty tag are the gateway-owned default. @@ -261,4 +275,7 @@ Validate chart values that Helm would otherwise accept silently. {{- if gt (len $credentialDrivers) 1 -}} {{- fail "only one external server.credentialDrivers backend can be enabled at a time." -}} {{- end -}} +{{- if kindIs "invalid" .Values.server.tls.clientCaSecretName -}} +{{- fail "server.tls.clientCaSecretName cannot be null; omit the key to use the chart default (openshell-server-client-ca), or set to \"\" to disable client certificate verification for HTTPS-only mode" -}} +{{- end -}} {{- end }} diff --git a/deploy/helm/openshell/templates/gateway-config.yaml b/deploy/helm/openshell/templates/gateway-config.yaml index d7f3cb9a83..083748aee3 100644 --- a/deploy/helm/openshell/templates/gateway-config.yaml +++ b/deploy/helm/openshell/templates/gateway-config.yaml @@ -104,7 +104,9 @@ data: [openshell.gateway.tls] cert_path = "/etc/openshell-tls/server/tls.crt" key_path = "/etc/openshell-tls/server/tls.key" + {{- if eq (include "openshell.gatewayClientCaEnabled" .) "true" }} client_ca_path = "/etc/openshell-tls/client-ca/ca.crt" + {{- end }} {{- if .Values.certManager.serverIssuerRef.name }} external_cert_path = "/etc/openshell-tls/server-external/tls.crt" external_key_path = "/etc/openshell-tls/server-external/tls.key" diff --git a/deploy/helm/openshell/tests/gateway_config_test.yaml b/deploy/helm/openshell/tests/gateway_config_test.yaml index b54b036c0f..1380cb18c5 100644 --- a/deploy/helm/openshell/tests/gateway_config_test.yaml +++ b/deploy/helm/openshell/tests/gateway_config_test.yaml @@ -422,6 +422,94 @@ tests: path: data["gateway.toml"] pattern: '\[openshell\.gateway\.tls\]' + - it: renders client_ca_path for built-in PKI by default + template: templates/gateway-config.yaml + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: 'client_ca_path\s*=\s*"/etc/openshell-tls/client-ca/ca\.crt"' + + - it: omits client_ca_path when clientCaSecretName is empty for HTTPS-only mode + template: templates/gateway-config.yaml + set: + server.tls.clientCaSecretName: "" + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '\[openshell\.gateway\.tls\]' + - matchRegex: + path: data["gateway.toml"] + pattern: 'cert_path\s*=\s*"/etc/openshell-tls/server/tls\.crt"' + - matchRegex: + path: data["gateway.toml"] + pattern: 'key_path\s*=\s*"/etc/openshell-tls/server/tls\.key"' + - notMatchRegex: + path: data["gateway.toml"] + pattern: 'client_ca_path\s*=' + + - it: keeps external server certificate fields when clientCaSecretName is empty + template: templates/gateway-config.yaml + set: + certManager.enabled: true + certManager.serverIssuerRef.name: letsencrypt-prod + certManager.serverDnsNames: + - gateway.example.com + server.tls.clientCaSecretName: "" + asserts: + - notMatchRegex: + path: data["gateway.toml"] + pattern: 'client_ca_path\s*=' + - matchRegex: + path: data["gateway.toml"] + pattern: 'external_cert_path\s*=\s*"/etc/openshell-tls/server-external/tls\.crt"' + - matchRegex: + path: data["gateway.toml"] + pattern: 'external_key_path\s*=\s*"/etc/openshell-tls/server-external/tls\.key"' + - matchRegex: + path: data["gateway.toml"] + pattern: 'external_server_names\s*=\s*\["gateway\.example\.com"\]' + + - it: omits client_ca_path for cert-manager shared CA when clientCaSecretName is empty + template: templates/gateway-config.yaml + set: + certManager.enabled: true + certManager.clientCaFromServerTlsSecret: true + server.tls.clientCaSecretName: "" + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '\[openshell\.gateway\.tls\]' + - matchRegex: + path: data["gateway.toml"] + pattern: 'cert_path\s*=\s*"/etc/openshell-tls/server/tls\.crt"' + - matchRegex: + path: data["gateway.toml"] + pattern: 'key_path\s*=\s*"/etc/openshell-tls/server/tls\.key"' + - notMatchRegex: + path: data["gateway.toml"] + pattern: 'client_ca_path\s*=' + + - it: omits client_ca_path when cert-manager owns TLS and no client CA secret is set + template: templates/gateway-config.yaml + set: + certManager.enabled: true + certManager.clientCaFromServerTlsSecret: false + pkiInitJob.enabled: true + server.tls.clientCaSecretName: "" + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '\[openshell\.gateway\.tls\]' + - matchRegex: + path: data["gateway.toml"] + pattern: 'cert_path\s*=\s*"/etc/openshell-tls/server/tls\.crt"' + - matchRegex: + path: data["gateway.toml"] + pattern: 'key_path\s*=\s*"/etc/openshell-tls/server/tls\.key"' + - notMatchRegex: + path: data["gateway.toml"] + pattern: 'client_ca_path\s*=' + - it: renders server_sans from certManager.serverDnsNames set: certManager.enabled: true @@ -442,6 +530,14 @@ tests: path: spec.template.spec.containers[0].args content: "sqlite:/var/openshell/openshell.db" + - it: fails when clientCaSecretName is null + template: templates/statefulset.yaml + set: + server.tls.clientCaSecretName: null + asserts: + - failedTemplate: + errorMessage: "server.tls.clientCaSecretName cannot be null; omit the key to use the chart default (openshell-server-client-ca), or set to \"\" to disable client certificate verification for HTTPS-only mode" + - it: fails when legacy postgres.enabled is set template: templates/statefulset.yaml set: diff --git a/deploy/helm/openshell/tests/statefulset_client_ca_test.yaml b/deploy/helm/openshell/tests/statefulset_client_ca_test.yaml index 1d744b35aa..813e47fbe0 100644 --- a/deploy/helm/openshell/tests/statefulset_client_ca_test.yaml +++ b/deploy/helm/openshell/tests/statefulset_client_ca_test.yaml @@ -84,3 +84,39 @@ tests: name: tls-client-ca mountPath: /etc/openshell-tls/client-ca readOnly: true + + # Explicit HTTPS-only opt-out must suppress built-in PKI client-CA wiring even + # when pkiInitJob remains enabled (the default install path from #2095). + - it: omits the client CA volume for built-in PKI when clientCaSecretName is empty + template: templates/statefulset.yaml + set: + pkiInitJob.enabled: true + certManager.enabled: false + server.tls.clientCaSecretName: "" + asserts: + - lengthEqual: + path: spec.template.spec.volumes + count: 3 + - notContains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: tls-client-ca + mountPath: /etc/openshell-tls/client-ca + readOnly: true + + - it: omits the client CA volume when cert-manager shares server CA and clientCaSecretName is empty + template: templates/statefulset.yaml + set: + certManager.enabled: true + certManager.clientCaFromServerTlsSecret: true + server.tls.clientCaSecretName: "" + asserts: + - lengthEqual: + path: spec.template.spec.volumes + count: 3 + - notContains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: tls-client-ca + mountPath: /etc/openshell-tls/client-ca + readOnly: true diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index 6b0b6242b0..25236eb474 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -359,6 +359,7 @@ server: certSecretName: openshell-server-tls # -- K8s secret with ca.crt for client certificate verification (mTLS). # Set to "" to disable mTLS and run HTTPS-only (use OIDC for auth instead). + # Do not set to null; omit the key to use the default secret name above. clientCaSecretName: openshell-server-client-ca # -- K8s secret mounted into sandbox pods for mTLS to the server. clientTlsSecretName: openshell-client-tls diff --git a/docs/kubernetes/access-control.mdx b/docs/kubernetes/access-control.mdx index d6cb2140bd..549cc6679f 100644 --- a/docs/kubernetes/access-control.mdx +++ b/docs/kubernetes/access-control.mdx @@ -39,9 +39,12 @@ helm upgrade openshell \ --version \ --namespace openshell \ --set server.oidc.issuer=https://your-idp.example.com/realms/openshell \ - --set server.oidc.audience=openshell-cli + --set server.oidc.audience=openshell-cli \ + --set server.tls.clientCaSecretName="" ``` +Set `server.tls.clientCaSecretName=""` when the gateway terminates TLS directly and browsers or CLI clients connect without client certificates. The chart omits `client_ca_path` from `gateway.toml` and does not mount the client-CA volume, leaving HTTPS-only transport with OIDC for user authentication. Do not set the value to `null`; omit the key to use the chart default, or set it to `""` to disable client certificate verification. + The `audience` value must match the client ID configured in your identity provider for the OpenShell resource server. ### OIDC values reference @@ -69,6 +72,7 @@ helm upgrade openshell \ --namespace openshell \ --set server.oidc.issuer=https://your-idp.example.com/realms/openshell \ --set server.oidc.audience=openshell-cli \ + --set server.tls.clientCaSecretName="" \ --set server.oidc.rolesClaim=realm_access.roles \ --set server.oidc.adminRole=openshell-admin \ --set server.oidc.userRole=openshell-user @@ -100,6 +104,8 @@ helm upgrade openshell \ The gateway still serves TLS and sandbox supervisors still authenticate with gateway-minted sandbox JWTs. User-facing CLI/API calls without OIDC or mTLS credentials are accepted as an unauthenticated local developer principal. The proxy is responsible for authenticating callers and forwarding only authorized traffic. +When the gateway terminates TLS directly and callers connect without client certificates, also set `server.tls.clientCaSecretName=""` as described in the OIDC section above. + To also disable TLS entirely (when the proxy terminates TLS before the request reaches the gateway): ```shell diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 22b64e9e97..c04c0040d0 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -140,6 +140,8 @@ max_payload_bytes = 262144 timeout = "500ms" # Gateway listener TLS (distinct from the per-driver guest_tls_*). +# client_ca_path is optional; omit it for HTTPS-only listeners that do not +# verify client certificates. [openshell.gateway.tls] cert_path = "/etc/openshell/certs/gateway.pem" key_path = "/etc/openshell/certs/gateway-key.pem" From 4ef8423408edc76dcc15c05cabbf2aa727c1785e Mon Sep 17 00:00:00 2001 From: Polite_realism <119441270+politerealism@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:26:16 +0000 Subject: [PATCH 071/505] fix(server): release driver-owned sandbox resources on out-of-band removal (#3042) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(server): release driver-owned sandbox resources on out-of-band removal Neither the watch path nor the periodic prune sweep call the driver's DeleteSandbox RPC when a sandbox's compute resource is discovered gone out-of-band (container removed without going through OpenShell's own delete path, or the gateway crashing mid-create). Only store-side records were cleaned up, leaving driver-owned secrets and volumes behind indefinitely — a credential leak for the Podman driver's per-sandbox bearer-token and proxy-auth secrets. DeleteSandbox is already idempotent and already reclaims these resources when the underlying container is already gone, so no new RPC is needed: this wires the existing call into the two paths that discover a sandbox is gone without going through an explicit delete request. The watch path defers the actual call to a background task, gated by a non-blocking check of the sandbox's lifecycle gate: watch events are processed sequentially and must never block on a driver call, and skipping the call entirely when a concurrent request-side operation already holds the gate avoids racing that operation's own driver call. The prune sweep calls it inline, since it already makes a blocking GetSandbox call per sandbox as part of its normal operation. Closes #2352 Signed-off-by: politerealism * fix(server): stop holding sync_lock across the prune sweep's driver call The prune sweep's second sync_lock acquisition has no inner block scope, so it's held through the rest of prune_missing_sandbox, including the driver cleanup call added in the previous commit. That call is a network RPC to the compute driver; holding the gateway-wide sync_lock across it blocks every other sandbox operation on the gateway for as long as that one driver call takes, not just the sandbox being pruned. Use the same backgrounded spawn_driver_sandbox_cleanup already used by the watch path instead of the inline cleanup_driver_sandbox_resources, which is now unused and removed. Add a regression test that blocks the driver's delete call indefinitely and confirms the prune sweep still completes and removes the store record promptly. Addresses gator finding GATOR-9902b7bb-01 on #3042. Signed-off-by: politerealism --------- Signed-off-by: politerealism --- architecture/compute-runtimes.md | 13 ++ crates/openshell-server/src/compute/mod.rs | 246 ++++++++++++++++++++- 2 files changed, 258 insertions(+), 1 deletion(-) diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index c41a793e4a..200e15fe72 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -212,6 +212,19 @@ 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 owned task prevents cancellation from stranding a mutation. A gateway restart diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index bf3215961c..1b21395163 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -3249,6 +3249,13 @@ impl ComputeRuntime { .await .map_err(|e| e.to_string())?; if let Some(sandbox) = sandbox.as_ref() { + // The watcher told us this sandbox's compute resource is gone, so + // no request-side DeleteSandbox call is coming — release + // driver-owned resources in the background. Watch events are + // processed sequentially, so this must not block on the driver + // call itself, only on the (instant, non-blocking) decision to + // make it. + self.spawn_driver_sandbox_cleanup(sandbox.object_id(), sandbox.object_name()); self.cleanup_sandbox_owned_records(sandbox).await?; } @@ -3300,6 +3307,79 @@ impl ComputeRuntime { Ok(()) } + /// Best-effort driver-side cleanup for a sandbox discovered gone + /// out-of-band — a watch deletion event, or the periodic prune sweep + /// finding no matching driver resource — rather than through an + /// explicit `DeleteSandbox` request. + /// + /// Skips the driver call entirely if a request-side lifecycle operation + /// (e.g. an in-flight explicit delete) already holds this sandbox's + /// lifecycle gate: that operation already owns driver-side cleanup for + /// it, and calling `DeleteSandbox` again here would race its own + /// in-flight call. + /// + /// The gate check is synchronous, but the actual `DeleteSandbox` RPC is + /// always deferred to a background task, never awaited inline: both + /// call sites run while holding a broader lock (the watch loop's + /// sequential event processing; the prune sweep's gateway-wide + /// `sync_lock`), and a slow or stuck driver call must never block that + /// wider scope. The gate itself is held for the background call's + /// duration, so this still can't race a concurrent request-side + /// operation — only the potentially-slow RPC is backgrounded. + fn spawn_driver_sandbox_cleanup(&self, sandbox_id: &str, sandbox_name: &str) { + let gate = self.lifecycle_gates.gate_for(sandbox_id); + let Ok(guard) = gate.try_lock_owned() else { + debug!( + sandbox_id, + sandbox_name, + "Skipping driver cleanup while a lifecycle operation is already in flight for this sandbox" + ); + return; + }; + + let runtime = self.clone(); + let sandbox_id = sandbox_id.to_string(); + let sandbox_name = sandbox_name.to_string(); + tokio::spawn(async move { + let _guard = guard; + runtime + .call_driver_delete_sandbox(&sandbox_id, &sandbox_name) + .await; + }); + } + + /// `DeleteSandbox` is idempotent: drivers must reclaim owned + /// secrets/volumes/etc. even when the underlying compute resource is + /// already gone. Failures here are logged, not propagated — callers + /// already consider this sandbox gone, so a driver hiccup must not + /// block store cleanup. + async fn call_driver_delete_sandbox(&self, sandbox_id: &str, sandbox_name: &str) { + let result = self + .driver + .call("driver.delete_sandbox", Some(sandbox_id), |driver| { + let sandbox_id = sandbox_id.to_string(); + let sandbox_name = sandbox_name.to_string(); + async move { + driver + .delete_sandbox(Request::new(DeleteSandboxRequest { + sandbox_id, + sandbox_name, + })) + .await + } + }) + .await; + + if let Err(status) = result { + warn!( + sandbox_id, + sandbox_name, + error = %status, + "Failed to release driver-owned resources while cleaning up a sandbox discovered gone out-of-band" + ); + } + } + async fn cleanup_sandbox_ssh_sessions( &self, sandbox_id: &str, @@ -3592,6 +3672,14 @@ impl ComputeRuntime { age_secs = age_ms / 1000, "Removing sandbox from store after it disappeared from the compute driver snapshot" ); + // The driver's own snapshot never reported this sandbox, so no + // request-side DeleteSandbox call is coming for it either — release + // driver-owned resources in the background. This function holds + // `sync_lock` (the gateway-wide state guard) through the rest of its + // body, so the driver call must not be awaited here: doing so would + // block every other sandbox operation gateway-wide on a single, + // potentially slow or stuck driver RPC. + self.spawn_driver_sandbox_cleanup(&sandbox_id, &sandbox_name); self.apply_deleted_if_version_locked(&sandbox, expected_resource_version) .await } @@ -5023,6 +5111,7 @@ mod tests { delete_release: Semaphore, delete_blocked: AtomicBool, delete_calls: AtomicUsize, + delete_requests: TestMutex>, delete_outcome: TestMutex, stop_started: Notify, stop_finished: Notify, @@ -5055,6 +5144,7 @@ mod tests { delete_release: Semaphore::new(0), delete_blocked: AtomicBool::new(false), delete_calls: AtomicUsize::new(0), + delete_requests: TestMutex::new(Vec::new()), delete_outcome: TestMutex::new(ControlledDeleteOutcome::Ok(true)), stop_started: Notify::new(), stop_finished: Notify::new(), @@ -5138,6 +5228,13 @@ mod tests { self.delete_calls.load(Ordering::SeqCst) } + fn delete_requests(&self) -> Vec<(String, String)> { + self.delete_requests + .lock() + .expect("delete requests lock poisoned") + .clone() + } + fn stop_calls(&self) -> usize { self.stop_calls.load(Ordering::SeqCst) } @@ -5325,8 +5422,13 @@ mod tests { async fn delete_sandbox( &self, - _request: Request, + request: Request, ) -> Result, Status> { + let request = request.into_inner(); + self.delete_requests + .lock() + .expect("delete requests lock poisoned") + .push((request.sandbox_id, request.sandbox_name)); self.delete_calls.fetch_add(1, Ordering::SeqCst); self.delete_started.notify_one(); if self.delete_blocked.load(Ordering::SeqCst) { @@ -8050,6 +8152,148 @@ mod tests { )); } + #[tokio::test] + async fn apply_deleted_releases_driver_resources_for_out_of_band_removal() { + // Regression test for #2352: a container removed without going + // through OpenShell's own DeleteSandbox (e.g. `podman rm -f`) must + // still release driver-owned secrets/volumes, not just the store + // record. This never calls `runtime.delete_sandbox`, matching the + // out-of-band removal the issue reports. + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + let session = seed_sandbox_owned_records(&runtime, &sandbox).await; + + assert_eq!(driver.delete_calls(), 0); + + runtime.apply_deleted("sb-1").await.unwrap(); + + // The driver call is backgrounded (see `spawn_driver_sandbox_cleanup`) + // so it can't block the watch loop; wait for it to actually land + // before asserting on it. + tokio::time::timeout(Duration::from_secs(1), driver.delete_started.notified()) + .await + .expect("background driver cleanup did not run"); + assert_eq!( + driver.delete_requests(), + vec![("sb-1".to_string(), "sandbox-a".to_string())] + ); + assert_sandbox_owned_records(&runtime, &sandbox, &session, false).await; + assert!( + runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn apply_deleted_removes_store_record_even_when_driver_cleanup_fails() { + // The driver has already told us (via the watch/prune path that led + // here) that this sandbox is gone. A failure releasing its + // secrets/volumes must be logged, not block store cleanup — retrying + // forever on a driver hiccup would leave the store permanently out + // of sync with reality. + let driver = ControlledDriver::new(); + driver.set_delete_outcome(ControlledDeleteOutcome::Error("podman unreachable")); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + + runtime.apply_deleted("sb-1").await.unwrap(); + + // Store cleanup happens synchronously in `apply_deleted_locked`, so + // this is already true even though the driver call is backgrounded. + assert!( + runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .is_none() + ); + + tokio::time::timeout(Duration::from_secs(1), driver.delete_started.notified()) + .await + .expect("background driver cleanup did not run"); + assert_eq!(driver.delete_calls(), 1); + } + + #[tokio::test] + async fn prune_missing_sandbox_releases_driver_resources() { + // Regression test for #2352's second reproduction: a sandbox whose + // container never survived to exist at all (e.g. the gateway was + // killed mid-create) is discovered missing by the periodic sweep, + // not the watcher. That path must also release driver resources. + let driver = ControlledDriver::new(); + driver.set_get_outcome(ControlledGetOutcome::Missing); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Provisioning); + runtime.store.put_message(&sandbox).await.unwrap(); + + runtime + .reconcile_store_with_backend(Duration::ZERO) + .await + .unwrap(); + + assert!( + runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .is_none() + ); + + // The driver call is backgrounded (see `spawn_driver_sandbox_cleanup`) + // so the prune sweep never awaits it while holding the gateway-wide + // sync_lock; wait for it to actually land before asserting on it. + tokio::time::timeout(Duration::from_secs(1), driver.delete_started.notified()) + .await + .expect("background driver cleanup did not run"); + assert_eq!( + driver.delete_requests(), + vec![("sb-1".to_string(), "sandbox-a".to_string())] + ); + } + + #[tokio::test] + async fn prune_sweep_does_not_block_on_a_stuck_driver_delete_call() { + // Regression test: the prune sweep's driver cleanup must not be + // awaited while holding `sync_lock` (the gateway-wide state guard). + // Block the driver's delete call indefinitely and confirm the sweep + // itself still completes promptly and removes the store record. + let driver = ControlledDriver::new(); + driver.block_delete(); + driver.set_get_outcome(ControlledGetOutcome::Missing); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Provisioning); + runtime.store.put_message(&sandbox).await.unwrap(); + + tokio::time::timeout( + Duration::from_secs(1), + runtime.reconcile_store_with_backend(Duration::ZERO), + ) + .await + .expect("prune sweep blocked on the stuck driver delete call") + .unwrap(); + + assert!( + runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .is_none() + ); + tokio::time::timeout(Duration::from_secs(1), driver.delete_started.notified()) + .await + .expect("background driver cleanup did not run"); + } + #[tokio::test] async fn accepted_delete_cleans_local_state_after_another_replica_removes_row() { let driver = ControlledDriver::new(); From a547dc9f4d3c19f80f65ebbadeb2bde01fbd05e0 Mon Sep 17 00:00:00 2001 From: "John T. Myers" <9696606+johntmyers@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:42:48 +0000 Subject: [PATCH 072/505] fix(sandbox): reconcile early container exits (#3101) * fix(sandbox): reconcile early container exits Signed-off-by: John Myers * test(oidc): isolate RBAC checks from lifecycle exits Signed-off-by: John Myers --------- Signed-off-by: John Myers Co-authored-by: John Myers --- crates/openshell-server/src/compute/mod.rs | 48 +++++++++++++++++++++- e2e/rust/tests/oidc_pkce.rs | 19 ++++----- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 1b21395163..78ae6fddb3 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -3725,7 +3725,17 @@ impl ComputeRuntime { fn apply_main_process_exit(sandbox: &mut Sandbox, instance_id: &str, exit_code: i32) { let sandbox_name = sandbox.object_name().to_string(); - let preserve_infrastructure_error = sandbox.phase() == SandboxPhase::Error as i32; + // A driver can observe the container exit before the supervisor's + // authoritative main-process report arrives. In that ordering, + // ContainerExited is only a provisional classification: replace it with + // the canonical process result once its exit code is known. Preserve all + // other infrastructure errors. + let preserve_infrastructure_error = sandbox.phase() == SandboxPhase::Error as i32 + && !sandbox.status.as_ref().is_some_and(|status| { + status.conditions.iter().any(|condition| { + condition.r#type == "Ready" && condition.reason == "ContainerExited" + }) + }); let status = sandbox.status.get_or_insert_with(|| SandboxStatus { sandbox_name: sandbox_name.clone(), ..Default::default() @@ -5592,6 +5602,42 @@ mod tests { })); } + #[test] + fn main_process_exit_replaces_provisional_container_exit() { + let mut sandbox = error_sandbox_record("sb-1", "sandbox-a", "ContainerExited"); + apply_main_process_exit(&mut sandbox, "instance-1", 0); + + assert_eq!( + SandboxPhase::try_from(sandbox.phase()), + Ok(SandboxPhase::Completed) + ); + let status = sandbox.status.as_ref().unwrap(); + assert_eq!(status.exit_code, Some(0)); + assert_eq!(status.main_process_instance_id, "instance-1"); + assert!(status.conditions.iter().any(|condition| { + condition.r#type == "Ready" + && condition.status == "False" + && condition.reason == "MainProcessCompleted" + })); + } + + #[test] + fn main_process_exit_preserves_specific_infrastructure_error() { + let mut sandbox = error_sandbox_record("sb-1", "sandbox-a", "BackendResourceMissing"); + apply_main_process_exit(&mut sandbox, "instance-1", 0); + + assert_eq!( + SandboxPhase::try_from(sandbox.phase()), + Ok(SandboxPhase::Error) + ); + let status = sandbox.status.as_ref().unwrap(); + assert_eq!(status.exit_code, Some(0)); + assert_eq!(status.main_process_instance_id, "instance-1"); + assert!(status.conditions.iter().any(|condition| { + condition.r#type == "Ready" && condition.reason == "BackendResourceMissing" + })); + } + #[tokio::test] async fn stale_main_process_exit_is_acknowledged_without_replacing_active_instance() { let runtime = test_runtime(Arc::new(TestDriver::default())).await; diff --git a/e2e/rust/tests/oidc_pkce.rs b/e2e/rust/tests/oidc_pkce.rs index f8edc3d7b2..93d71b9586 100644 --- a/e2e/rust/tests/oidc_pkce.rs +++ b/e2e/rust/tests/oidc_pkce.rs @@ -1372,8 +1372,10 @@ async fn delete_workspace(admin: &LoginSession, workspace: &str) { } } +// Keep RBAC fixtures running until cleanup so authorization checks do not also +// exercise fast canonical-process completion. That lifecycle belongs to the +// dedicated sandbox_lifecycle suite. async fn assert_can_create_sandbox(session: &LoginSession, workspace: &str, sandbox_name: &str) { - let marker = format!("{sandbox_name}-ready"); let create = run_workspace_cli( session, workspace, @@ -1383,9 +1385,10 @@ async fn assert_can_create_sandbox(session: &LoginSession, workspace: &str, sand "--name", sandbox_name, "--no-tty", + "--detach", "--", - "echo", - &marker, + "sleep", + "infinity", ], ) .await; @@ -1404,10 +1407,6 @@ async fn assert_can_create_sandbox(session: &LoginSession, workspace: &str, sand let list_output = combined_output(&list); let cleanup = run_workspace_cli(session, workspace, &["sandbox", "delete", sandbox_name]).await; - assert!( - create_output.contains(&marker), - "sandbox command output should contain {marker}:\n{create_output}" - ); assert!( list.status.success() && list_output.contains(sandbox_name), "created sandbox {sandbox_name} should appear in the sandbox list:\n{list_output}" @@ -1420,7 +1419,6 @@ async fn assert_can_create_sandbox(session: &LoginSession, workspace: &str, sand } async fn assert_can_delete_sandbox(session: &LoginSession, workspace: &str, sandbox_name: &str) { - let marker = format!("{sandbox_name}-ready"); let create = run_workspace_cli( session, workspace, @@ -1430,9 +1428,10 @@ async fn assert_can_delete_sandbox(session: &LoginSession, workspace: &str, sand "--name", sandbox_name, "--no-tty", + "--detach", "--", - "echo", - &marker, + "sleep", + "infinity", ], ) .await; From 07df822090525229854d291d1be5bea4c0e74e12 Mon Sep 17 00:00:00 2001 From: "John T. Myers" <9696606+johntmyers@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:22:07 +0000 Subject: [PATCH 073/505] feat(providers): make profiles authoritative (#2962) * feat(providers): make profiles authoritative Closes #1988 Signed-off-by: John Myers * docs(providers): move profiles into provider navigation Signed-off-by: John Myers * docs(providers): clarify provider attachment lifecycle Signed-off-by: John Myers * fix(tui): scroll provider profile picker Signed-off-by: John Myers * fix(providers): honor profile credential semantics Signed-off-by: John Myers * fix(providers): prefer exact profile IDs Signed-off-by: John Myers * fix(providers): harden authoritative profile adoption Signed-off-by: John Myers * test(oidc): align provider fixtures with profiles Signed-off-by: John Myers * fix(providers): preserve authoritative profile lifecycle Signed-off-by: John Myers --------- Signed-off-by: John Myers Co-authored-by: John Myers --- .agents/skills/debug-inference/SKILL.md | 5 + .agents/skills/openshell-cli/SKILL.md | 24 +- .agents/skills/openshell-cli/cli-reference.md | 4 +- .agents/skills/tui-development/SKILL.md | 21 +- architecture/google-vertex-ai-provider.md | 2 +- crates/openshell-cli/src/main.rs | 25 +- crates/openshell-cli/src/run.rs | 265 ++- .../tests/ensure_providers_integration.rs | 24 +- .../tests/provider_commands_integration.rs | 310 +++- .../sandbox_create_lifecycle_integration.rs | 39 +- crates/openshell-core/src/settings.rs | 25 +- crates/openshell-providers/src/discovery.rs | 33 + crates/openshell-providers/src/lib.rs | 108 +- crates/openshell-providers/src/profiles.rs | 100 +- .../src/providers/google_cloud.rs | 20 +- .../openshell-providers/src/providers/mod.rs | 41 - .../src/providers/vertex.rs | 34 +- crates/openshell-server/src/grpc/policy.rs | 374 ++--- crates/openshell-server/src/grpc/provider.rs | 1492 +++++++++++------ crates/openshell-server/src/grpc/sandbox.rs | 9 + crates/openshell-server/src/lib.rs | 8 + .../src/provider_profile_sources.rs | 149 +- .../openshell-server/src/provider_refresh.rs | 147 -- .../openshell-supervisor-network/src/sigv4.rs | 21 +- crates/openshell-tui/src/app.rs | 311 +++- crates/openshell-tui/src/lib.rs | 166 +- .../openshell-tui/src/ui/create_provider.rs | 85 +- crates/openshell-tui/src/ui/mod.rs | 31 +- crates/openshell-tui/src/ui/providers.rs | 10 +- docs/get-started/tutorials/index.mdx | 2 +- .../microsoft-graph-provider-refresh.mdx | 14 +- docs/providers/google-vertex-ai.mdx | 18 +- .../profiles.mdx} | 82 +- docs/reference/policy-schema.mdx | 2 +- docs/sandboxes/inference-routing.mdx | 9 + docs/sandboxes/manage-providers.mdx | 89 +- docs/sandboxes/manage-sandboxes.mdx | 2 +- docs/sandboxes/policies.mdx | 4 +- docs/sandboxes/policy-advisor.mdx | 2 +- e2e/python/oidc/oidc_auth_test.py | 4 +- e2e/python/oidc/workspace_authz_test.py | 10 +- e2e/python/test_sandbox_providers.py | 138 +- e2e/rust/tests/credential_gating.rs | 92 +- e2e/rust/tests/oidc_pkce.rs | 43 +- e2e/rust/tests/provider_refresh_keycloak.rs | 79 +- e2e/rust/tests/provider_token_exchange.rs | 13 - e2e/rust/tests/workspace_lifecycle.rs | 12 +- .../agent-driven-policy-management/demo.sh | 32 +- examples/aws-s3-sts.md | 3 - examples/governance-interceptor/smoke.sh | 1 - examples/governance-interceptor/src/tests.rs | 2 +- examples/multi-agent-notepad/demo.sh | 6 +- examples/spiffe-token-exchange-demo/README.md | 4 - examples/spiffe-token-exchange-demo/demo.sh | 1 - .../spiffe-token-exchange-demo/podman/demo.sh | 1 - examples/spiffe-token-grant-demo/README.md | 4 - examples/spiffe-token-grant-demo/demo.sh | 1 - fern/docs.yml | 2 + proto/openshell.proto | 5 +- providers/anthropic.yaml | 24 + providers/aws-s3.yaml | 2 - providers/openai.yaml | 24 + scripts/agents/gator/README.md | 2 +- scripts/agents/gator/agent.yaml | 2 - sdk/go/proto/openshellv1/openshell_grpc.pb.go | 10 +- 65 files changed, 2629 insertions(+), 1995 deletions(-) rename docs/{sandboxes/providers-v2.mdx => providers/profiles.mdx} (93%) create mode 100644 providers/anthropic.yaml create mode 100644 providers/openai.yaml diff --git a/.agents/skills/debug-inference/SKILL.md b/.agents/skills/debug-inference/SKILL.md index 08462a4751..5ac61b0d3a 100644 --- a/.agents/skills/debug-inference/SKILL.md +++ b/.agents/skills/debug-inference/SKILL.md @@ -136,6 +136,11 @@ Check: - Required credential key exists - `*_BASE_URL` override is correct when using a self-hosted endpoint +An `OPENAI_BASE_URL` or `ANTHROPIC_BASE_URL` override is route-only. It does not +authorize the fixed public-vendor endpoint or expose the alternate-upstream key +for substitution there. Use an imported endpoint-bearing custom profile when a +sandbox needs direct access to the alternate upstream. + Fix examples: ```bash diff --git a/.agents/skills/openshell-cli/SKILL.md b/.agents/skills/openshell-cli/SKILL.md index c9b4d20268..a1ed3bef9b 100644 --- a/.agents/skills/openshell-cli/SKILL.md +++ b/.agents/skills/openshell-cli/SKILL.md @@ -121,9 +121,8 @@ The `--from-existing` flag discovers credentials from local state (e.g., `gh aut ### Create a provider with explicit credentials ```bash -openshell provider create --name my-api --type generic \ - --credential API_KEY \ - --config base_url=https://api.example.com +openshell provider create --name my-openai --type openai \ + --credential OPENAI_API_KEY ``` Bare `KEY` reads the value from the environment variable of that name and avoids placing the secret in shell history. Use `KEY=VALUE` only when the user explicitly accepts that exposure. @@ -143,14 +142,9 @@ must match. For a tunnel to `api.example.com:8443`, send port and is rejected. An absolute-form request target must use the same authority. -Profile-backed provider policy composition is controlled by the gateway-global -`providers_v2_enabled` setting. Static credential endpoint binding remains -active even when policy composition is disabled: - -```bash -openshell settings get --global -openshell settings set --global --key providers_v2_enabled --value true -``` +Profile-backed providers always contribute policy unless a gateway-global +policy is active. Static credential endpoint binding remains independently +enforced. ### Inspect and manage provider profiles @@ -177,13 +171,13 @@ openshell provider delete my-github Use refresh commands only when the provider profile and gateway support refreshable credentials: ```bash -openshell provider refresh status my-outlook -openshell provider refresh configure my-outlook \ +openshell provider refresh status my-provider +openshell provider refresh configure my-provider \ --credential-key MS_GRAPH_ACCESS_TOKEN \ --strategy oauth2-refresh-token \ --secret-material-env REFRESH_TOKEN=MS_GRAPH_REFRESH_TOKEN \ --credential-expires-at 2026-07-16T00:00:00Z -openshell provider refresh rotate my-outlook --credential-key MS_GRAPH_ACCESS_TOKEN +openshell provider refresh rotate my-provider --credential-key ACCESS_TOKEN ``` Prefer `--secret-material-env KEY[=ENVVAR]` for secret refresh material. `--material KEY=VALUE` is for non-secret material; `--secret-material-key` marks supplied material keys as secret. @@ -729,7 +723,7 @@ openshell settings set work-session --key ocsf_json_enabled --value true openshell settings delete work-session --key ocsf_json_enabled openshell settings get --global --json -openshell settings set --global --key providers_v2_enabled --value true +openshell settings set --global --key ocsf_json_enabled --value true ``` Global mutations prompt for confirmation. Use `--yes` only in reviewed automation. diff --git a/.agents/skills/openshell-cli/cli-reference.md b/.agents/skills/openshell-cli/cli-reference.md index f5fe82f191..27c2ed78d5 100644 --- a/.agents/skills/openshell-cli/cli-reference.md +++ b/.agents/skills/openshell-cli/cli-reference.md @@ -552,7 +552,9 @@ Create a provider configuration. | `--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. +Credential-source flags conflict with one another when supplied. A credential +source is optional when the selected profile declares no required static +credentials or can resolve its required credentials at runtime. ### `openshell provider get ` diff --git a/.agents/skills/tui-development/SKILL.md b/.agents/skills/tui-development/SKILL.md index 7f11db26ff..5d61af6ecd 100644 --- a/.agents/skills/tui-development/SKILL.md +++ b/.agents/skills/tui-development/SKILL.md @@ -43,8 +43,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. @@ -170,7 +170,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 +321,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 ` | `[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,10 +383,7 @@ 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):** +**Dashboard (Providers focus):** `[Tab] Switch Panel [h/l] Switch Tab [j/k] Navigate [Enter] Detail [c] Create [u] Update [d] Delete │ [:] Command [q] Quit` **Dashboard (Global Settings focus):** @@ -412,7 +409,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) | @@ -526,7 +523,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,9 +530,8 @@ 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 diff --git a/architecture/google-vertex-ai-provider.md b/architecture/google-vertex-ai-provider.md index aac0c11c27..e161471c9f 100644 --- a/architecture/google-vertex-ai-provider.md +++ b/architecture/google-vertex-ai-provider.md @@ -375,7 +375,7 @@ The `discovery` section lists `[service_account_token, gcloud_adc_token]` as the 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`: +policies permit through provider profile composition: - `*-aiplatform.googleapis.com:443` (regional endpoints) - `aiplatform.googleapis.com:443` (global endpoint) diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 5bfae02387..9ba6b77612 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -368,10 +368,9 @@ const POLICY_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m const SETTINGS_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m $ openshell settings get my-sandbox $ openshell settings get --global - $ openshell settings set --global --key providers_v2_enabled --value true $ openshell settings set my-sandbox --key ocsf_json_enabled --value true $ openshell settings set --global --key ocsf_json_enabled --value true - $ openshell settings delete --global --key providers_v2_enabled + $ openshell settings delete --global --key ocsf_json_enabled "; const PROVIDER_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m @@ -811,7 +810,7 @@ impl From for openshell_cli::ssh::Editor { #[derive(Subcommand, Debug)] enum ProviderCommands { /// Create a provider config. - #[command(group = clap::ArgGroup::new("cred_source").required(true).multiple(true).args(["from_existing", "credentials", "from_gcloud_adc", "runtime_credentials", "from_oidc_token"]), help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + #[command(group = clap::ArgGroup::new("cred_source").required(false).multiple(true).args(["from_existing", "credentials", "from_gcloud_adc", "runtime_credentials", "from_oidc_token"]), help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Create { /// Provider name. #[arg(long)] @@ -4944,8 +4943,8 @@ mod tests { } #[test] - fn provider_create_requires_credential_source() { - let err = Cli::try_parse_from([ + fn provider_create_accepts_no_credential_source() { + let cli = Cli::try_parse_from([ "openshell", "provider", "create", @@ -4954,9 +4953,21 @@ mod tests { "--type", "spiffe-token-demo", ]) - .expect_err("provider create should require a credential source"); + .expect("provider create should allow profiles without static credentials"); - assert!(err.to_string().contains("--runtime-credentials")); + assert!(matches!( + cli.command, + Some(Commands::Provider { + command: Some(ProviderCommands::Create { + from_existing: false, + credentials, + from_gcloud_adc: false, + from_oidc_token: false, + runtime_credentials: false, + .. + }) + }) if credentials.is_empty() + )); } #[test] diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 85d70713c6..658251b34e 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -55,13 +55,13 @@ use openshell_core::proto::{ SetInferenceRouteRequest, SettingScope, StartSandboxRequest, StopSandboxRequest, TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest, UpdateProviderProfilesRequest, UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, - setting_value, tcp_forward_init, + tcp_forward_init, }; use openshell_core::settings; use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; use openshell_providers::{ - ProviderRegistry, ProviderTypeProfile, RealDiscoveryContext, detect_provider_from_command, - discover_from_profile, normalize_provider_type, parse_profile_json, parse_profile_yaml, + ProviderTypeProfile, RealDiscoveryContext, detect_provider_from_command, discover_from_profile, + normalize_profile_id, normalize_provider_type, parse_profile_json, parse_profile_yaml, profile_to_json, profile_to_yaml, profiles_to_json, profiles_to_yaml, }; use owo_colors::OwoColorize; @@ -515,18 +515,7 @@ pub async fn sandbox_create( } None => None, }; - let inferred_provider = inferred_provider_type(command); - let providers_v2_enabled = - if inferred_provider.is_some() && auto_providers_override != Some(false) { - gateway_providers_v2_enabled(&mut client).await? - } else { - false - }; - let inferred_types: Vec = if providers_v2_enabled { - Vec::new() - } else { - inferred_provider.into_iter().collect() - }; + let inferred_types: Vec = inferred_provider_type(command).into_iter().collect(); let configured_providers = ensure_required_providers( &mut client, providers, @@ -2761,10 +2750,20 @@ pub async fn ensure_required_providers( if seen_names.insert(name.clone()) { configured_names.push(name.clone()); } - } else if let Some(provider_type) = normalize_provider_type(name) { + } else { + let profile_id = name.trim(); + let profile = fetch_provider_profile(client, profile_id, workspace) + .await + .map_err(|_| { + miette::miette!( + "provider '{name}' not found and no provider profile named '{profile_id}' is available. \ + Create or import the profile first, then create the provider" + ) + })?; + let provider_type = profile.id; auto_create_provider( client, - provider_type, + &provider_type, Some(name), auto_providers_override, &mut seen_names, @@ -2777,11 +2776,6 @@ pub async fn ensure_required_providers( type_to_name .entry(provider_type.to_ascii_lowercase()) .or_insert_with(|| name.clone()); - } else { - return Err(miette::miette!( - "provider '{name}' not found and '{name}' is not a recognized provider type. \ - Create it first with `openshell provider create --type --name {name}`" - )); } } @@ -2872,17 +2866,21 @@ async fn auto_create_provider( return Ok(()); } + let profile = fetch_provider_profile(client, provider_type, workspace).await?; let discovered = discover_existing_provider_data(client, provider_type, workspace) .await .map_err(|err| miette::miette!("failed to discover provider '{provider_type}': {err}"))?; - let Some(discovered) = discovered else { - eprintln!( - "{} No existing local credentials/config found for '{}'. You can configure it from inside the sandbox.", - "!".yellow(), - provider_type - ); - eprintln!(); - return Ok(()); + let discovered = match discovered { + Some(discovered) => discovered, + None if provider_profile_allows_empty_credentials(&profile) => { + openshell_providers::DiscoveredProvider::default() + } + None => { + return Err(miette::miette!( + "no existing local credentials found for provider profile '{provider_type}'. \ + Create it first with `openshell provider create --type {provider_type} --name {provider_type} --credential `" + )); + } }; if let Some(exact_name) = preferred_name { @@ -3441,50 +3439,57 @@ fn service_url_for_gateway(service_url: &str, gateway_endpoint: &str) -> String service_url.to_string() } -async fn gateway_providers_v2_enabled(client: &mut crate::tls::GrpcClient) -> Result { - let response = client - .get_gateway_config(GetGatewayConfigRequest {}) - .await - .into_diagnostic()? - .into_inner(); - let Some(setting) = response.settings.get(settings::PROVIDERS_V2_ENABLED_KEY) else { - return Ok(false); +async fn fetch_provider_profile( + client: &mut crate::tls::GrpcClient, + provider_type: &str, + workspace: &str, +) -> Result { + let requested = provider_type.trim(); + let response = match fetch_provider_profile_exact(client, requested, workspace).await { + Ok(response) => response, + Err(status) if status.code() == Code::NotFound => { + let Some(alias) = normalize_provider_type(requested) + .filter(|alias| normalize_profile_id(requested).as_deref() != Some(*alias)) + else { + return Err(miette::miette!( + "provider profile '{requested}' not found; import a matching profile before using this provider type" + )); + }; + fetch_provider_profile_exact(client, alias, workspace) + .await + .map_err(|fallback_status| { + if fallback_status.code() == Code::NotFound { + miette::miette!( + "provider profile '{requested}' not found; import a matching profile before using this provider type" + ) + } else { + miette::miette!(fallback_status.to_string()) + } + })? + } + Err(status) => return Err(miette::miette!(status.to_string())), }; - match setting.value.as_ref() { - Some(setting_value::Value::BoolValue(enabled)) => Ok(*enabled), - None => Ok(false), - Some(_) => Err(miette::miette!( - "gateway setting '{}' has invalid value type; expected bool", - settings::PROVIDERS_V2_ENABLED_KEY - )), - } + + Ok(response) } -async fn fetch_provider_profile( +async fn fetch_provider_profile_exact( client: &mut crate::tls::GrpcClient, provider_type: &str, workspace: &str, -) -> Result { - let response = client +) -> std::result::Result { + client .get_provider_profile(GetProviderProfileRequest { id: provider_type.to_string(), workspace: workspace.to_string(), }) .await - .map_err(|status| { - if status.code() == Code::NotFound { - miette::miette!( - "provider profile '{provider_type}' not found; providers v2 discovery requires a provider profile" - ) - } else { - miette::miette!(status.to_string()) - } - })?; - - response - .into_inner() - .profile - .ok_or_else(|| miette::miette!("provider profile '{provider_type}' missing from response")) + .and_then(|response| { + response + .into_inner() + .profile + .ok_or_else(|| Status::internal("provider profile missing from response")) + }) } async fn discover_existing_provider_data( @@ -3492,36 +3497,13 @@ async fn discover_existing_provider_data( provider_type: &str, workspace: &str, ) -> Result> { - if gateway_providers_v2_enabled(client).await? { - let profile = fetch_provider_profile(client, provider_type, workspace).await?; - let profile = ProviderTypeProfile::from_proto(&profile); - let mut discovered = - discover_from_profile(&profile, &RealDiscoveryContext).map_err(|err| { - miette::miette!("failed to discover existing provider data from profile: {err}") - })?; - - // Vertex AI config keys (project ID, region, base URL, publisher) are not - // declared in the profile's discovery.credentials list, so discover_from_profile - // does not scan them. Scan them directly here so --from-existing captures them. - if provider_type == VERTEX_AI_PROVIDER_TYPE { - let discovered = discovered.get_or_insert_with(Default::default); - for key in openshell_core::inference::VERTEX_AI_CONFIG_KEY_NAMES { - if let Ok(val) = std::env::var(key) { - let val = val.trim().to_string(); - if !val.is_empty() { - discovered.config.entry(key.to_string()).or_insert(val); - } - } - } - } + let profile = fetch_provider_profile(client, provider_type, workspace).await?; + let profile = ProviderTypeProfile::from_proto(&profile); + let discovered = discover_from_profile(&profile, &RealDiscoveryContext).map_err(|err| { + miette::miette!("failed to discover existing provider data from profile: {err}") + })?; - Ok(discovered) - } else { - let registry = ProviderRegistry::new(); - registry - .discover_existing(provider_type) - .map_err(|err| miette::miette!("failed to discover existing provider data: {err}")) - } + Ok(discovered) } /// Canonical provider type string for Google Vertex AI. @@ -3768,44 +3750,19 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) -> let mut client = grpc_client(server, tls).await?; - let provider_type = if let Some(provider_type) = normalize_provider_type(provider_type) { - provider_type.to_string() - } else { - let profile_id = provider_type.trim(); - if profile_id.is_empty() { - return Err(miette::miette!("provider type is required")); - } - let response = client - .get_provider_profile(GetProviderProfileRequest { - id: profile_id.to_string(), - workspace: profile_workspace.to_string(), - }) - .await; - match response { - Ok(response) => response - .into_inner() - .profile - .map(|profile| profile.id) - .filter(|id| !id.trim().is_empty()) - .unwrap_or_else(|| profile_id.to_string()), - Err(status) if status.code() == Code::NotFound => { - return Err(miette::miette!( - "unsupported provider type or profile: {provider_type}" - )); - } - Err(status) => return Err(status).into_diagnostic(), - } - }; + let profile_id = provider_type.trim(); + if profile_id.is_empty() { + return Err(miette::miette!("provider type is required")); + } + let provider_profile = fetch_provider_profile(&mut client, profile_id, profile_workspace) + .await + .map_err(|err| { + miette::miette!("unsupported provider type or profile: {profile_id} ({err})") + })?; + let provider_type = provider_profile.id.clone(); let adc_credential_key = if from_gcloud_adc { - let profile = fetch_provider_profile(&mut client, &provider_type, profile_workspace) - .await - .map_err(|err| { - miette::miette!( - "--from-gcloud-adc is not supported for '{provider_type}' providers ({err})" - ) - })?; - let profile = ProviderTypeProfile::from_proto(&profile); + let profile = ProviderTypeProfile::from_proto(&provider_profile); let adc_cred = profile.adc_credential().ok_or_else(|| { miette::miette!( "--from-gcloud-adc is not supported for '{provider_type}' providers \ @@ -3828,7 +3785,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) -> }; let oidc_profile = if from_oidc_token { - Some(fetch_provider_profile(&mut client, &provider_type, profile_workspace).await?) + Some(provider_profile.clone()) } else { None }; @@ -3861,25 +3818,12 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) -> if from_existing { return Err(missing_credentials_error(&provider_type)); } - if !from_gcloud_adc && !runtime_credentials { - return Err(missing_credentials_error(&provider_type)); + if runtime_credentials && !provider_profile_allows_runtime_credentials(&provider_profile) { + return Err(miette::miette!( + "--runtime-credentials is only valid for provider profiles whose required credentials are resolved at runtime" + )); } - let allows_empty_credentials = if runtime_credentials { - provider_profile_allows_empty_credentials( - &fetch_provider_profile(&mut client, &provider_type, profile_workspace).await?, - ) - } else { - fetch_provider_profile(&mut client, &provider_type, profile_workspace) - .await - .ok() - .is_some_and(|profile| provider_profile_allows_empty_credentials(&profile)) - }; - if !allows_empty_credentials { - if runtime_credentials { - return Err(miette::miette!( - "--runtime-credentials is only valid for provider profiles whose required credentials are resolved at runtime" - )); - } + if !provider_profile_allows_empty_credentials(&provider_profile) { return Err(missing_credentials_error(&provider_type)); } } @@ -3990,6 +3934,10 @@ fn provider_profile_allows_empty_credentials(profile: &ProviderProfile) -> bool ProviderTypeProfile::from_proto(profile).allows_empty_provider_credentials() } +fn provider_profile_allows_runtime_credentials(profile: &ProviderProfile) -> bool { + ProviderTypeProfile::from_proto(profile).allows_runtime_provider_credentials() +} + pub async fn provider_get( server: &str, name: &str, @@ -5013,12 +4961,10 @@ pub async fn provider_update(options: ProviderUpdateOptions<'_>) -> Result<()> { .into_inner() .provider .ok_or_else(|| miette::miette!("provider '{name}' not found"))?; - let profile_workspace = if existing.profile_workspace.is_empty() { - workspace - } else { - &existing.profile_workspace - }; - Some(fetch_provider_profile(&mut client, &existing.r#type, profile_workspace).await?) + Some( + fetch_provider_profile(&mut client, &existing.r#type, &existing.profile_workspace) + .await?, + ) } else { None }; @@ -5046,8 +4992,12 @@ pub async fn provider_update(options: ProviderUpdateOptions<'_>) -> Result<()> { .ok_or_else(|| miette::miette!("provider '{name}' not found"))?; let provider_type = existing.r#type; - let discovered = - discover_existing_provider_data(&mut client, &provider_type, workspace).await?; + let discovered = discover_existing_provider_data( + &mut client, + &provider_type, + &existing.profile_workspace, + ) + .await?; let Some(discovered) = discovered else { return Err(miette::miette!( "no existing local credentials/config found for provider type '{provider_type}'" @@ -8335,9 +8285,10 @@ mod tests { #[test] fn inferred_provider_type_normalizes_aliases() { - // `glab` should resolve to `gitlab` + // Retired legacy types are not inferred, even when a custom profile + // with the same ID could be imported and attached explicitly. let result = inferred_provider_type(&["glab".to_string()]); - assert_eq!(result, Some("gitlab".to_string())); + assert_eq!(result, None); // `gh` should resolve to `github` let result = inferred_provider_type(&["gh".to_string()]); diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 9999a7c083..743cfec42c 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -314,14 +314,30 @@ impl OpenShell for TestOpenShell { &self, _request: tonic::Request, ) -> Result, 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, + request: tonic::Request, ) -> Result, 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( @@ -850,7 +866,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}" ); } diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index bd4928d9f1..373f79b9a3 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -23,11 +23,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,6 +45,7 @@ use tonic::{Response, Status}; struct ProviderState { providers: Arc>>, profiles: Arc>>, + scoped_profiles: Arc>>, refresh_statuses: Arc>>, refresh_requests: Arc>>, provider_update_requests: Arc>>, @@ -489,8 +491,18 @@ impl OpenShell for TestOpenShell { &self, request: tonic::Request, ) -> Result, 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) { @@ -1173,11 +1185,19 @@ 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() }, ); } @@ -1362,11 +1382,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 +1472,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 +1628,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 +1648,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 +1660,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 +1671,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; @@ -1872,9 +1923,8 @@ binaries: [/usr/bin/custom] } #[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 +1974,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 +2008,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 +2070,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 +2092,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 +2101,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 +2148,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 +2213,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 +2550,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 +2566,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 +2610,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 +2718,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 +2736,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}" ); } @@ -2928,7 +3131,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"), diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 118daff902..e712e5291e 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -28,7 +28,6 @@ use openshell_core::proto::{ RevokeSshSessionResponse, Sandbox, SandboxCondition, SandboxLogLine, SandboxPhase, SandboxResponse, SandboxStatus, SandboxStreamEvent, ServiceStatus, SettingValue, SupervisorMessage, UpdateProviderRequest, WatchSandboxRequest, sandbox_stream_event, - setting_value, }; use std::collections::HashMap; use std::fs; @@ -340,14 +339,30 @@ impl OpenShell for TestOpenShell { &self, _request: tonic::Request, ) -> Result, 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, + request: tonic::Request, ) -> Result, 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( @@ -1189,15 +1204,6 @@ async fn create_requests(server: &TestServer) -> Vec { server.openshell.state.create_requests.lock().await.clone() } -async fn enable_providers_v2(server: &TestServer) { - server.openshell.state.global_settings.lock().await.insert( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - SettingValue { - value: Some(setting_value::Value::BoolValue(true)), - }, - ); -} - fn test_tls(server: &TestServer) -> TlsOptions { server.tls.with_gateway_name("openshell") } @@ -1553,9 +1559,8 @@ async fn sandbox_create_sends_gpu_count_request() { } #[tokio::test] -async fn sandbox_create_does_not_infer_command_providers_when_v2_enabled() { +async fn sandbox_create_skips_inferred_provider_without_local_credentials() { let server = run_server().await; - enable_providers_v2(&server).await; let fake_ssh_dir = tempfile::tempdir().unwrap(); let xdg_dir = tempfile::tempdir().unwrap(); let _env = test_env(&fake_ssh_dir, &xdg_dir); @@ -1566,7 +1571,7 @@ async fn sandbox_create_does_not_infer_command_providers_when_v2_enabled() { &server.endpoint, "openshell", run::SandboxCreateConfig { - name: Some("v2-no-inferred-provider"), + name: Some("no-inferred-provider"), command: &["claude".into(), "--version".into()], tty_override: Some(true), ..test_config() @@ -1586,7 +1591,7 @@ async fn sandbox_create_does_not_infer_command_providers_when_v2_enabled() { .clone(); assert!( providers.is_empty(), - "providers v2 should not infer command providers, got {providers:?}" + "missing local credentials should skip inferred providers, got {providers:?}" ); } diff --git a/crates/openshell-core/src/settings.rs b/crates/openshell-core/src/settings.rs index 6407071581..625ba4c856 100644 --- a/crates/openshell-core/src/settings.rs +++ b/crates/openshell-core/src/settings.rs @@ -74,8 +74,7 @@ impl RegisteredSetting { /// settable via `settings set`. The server validates that only registered /// keys are accepted. /// 5. Add a unit test in this module's `tests` section to cover the new key. -pub const PROVIDERS_V2_ENABLED_KEY: &str = "providers_v2_enabled"; - +/// /// Sandbox-level opt-in for the agent-driven policy proposal surface. /// /// When true, the supervisor installs the `policy_advisor` skill, serves @@ -108,13 +107,6 @@ pub const PROPOSAL_APPROVAL_MODE_KEY: &str = "proposal_approval_mode"; pub const PROPOSAL_APPROVAL_MODE_VALUES: &[&str] = &["manual", "auto"]; pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[ - // Gateway-level opt-in for provider profile policy composition. Defaults - // to false when unset. - RegisteredSetting { - key: PROVIDERS_V2_ENABLED_KEY, - kind: SettingValueKind::Bool, - allowed_string_values: None, - }, // When true the sandbox writes OCSF v1.8.0 JSONL records to // `/var/log/openshell-ocsf*.log` (daily rotation, 3 files) in addition // to the human-readable shorthand log. Defaults to false (no JSONL written). @@ -168,9 +160,8 @@ pub fn parse_bool_like(raw: &str) -> Option { #[cfg(test)] mod tests { use super::{ - PROPOSAL_APPROVAL_MODE_KEY, PROPOSAL_APPROVAL_MODE_VALUES, PROVIDERS_V2_ENABLED_KEY, - REGISTERED_SETTINGS, RegisteredSetting, SettingValueKind, parse_bool_like, - registered_keys_csv, setting_for_key, + PROPOSAL_APPROVAL_MODE_KEY, PROPOSAL_APPROVAL_MODE_VALUES, REGISTERED_SETTINGS, + RegisteredSetting, SettingValueKind, parse_bool_like, registered_keys_csv, setting_for_key, }; #[test] @@ -185,18 +176,16 @@ mod tests { } #[test] - fn setting_for_key_returns_providers_v2_enabled() { - let setting = setting_for_key(PROVIDERS_V2_ENABLED_KEY) - .expect("providers_v2_enabled should be registered"); - assert_eq!(setting.kind, SettingValueKind::Bool); + fn setting_for_key_rejects_removed_providers_v2_enabled() { + assert!(setting_for_key("providers_v2_enabled").is_none()); } // ---- RegisteredSetting::validate_string_value ---- #[test] fn validate_string_value_accepts_anything_when_unconstrained() { - let setting = setting_for_key(PROVIDERS_V2_ENABLED_KEY) - .expect("providers_v2_enabled should be registered"); + let setting = + setting_for_key("ocsf_json_enabled").expect("ocsf_json_enabled should be registered"); // Bool-kind entries currently leave `allowed_string_values = None`; // the helper still returns Ok for arbitrary strings. assert!(setting.validate_string_value("anything").is_ok()); diff --git a/crates/openshell-providers/src/discovery.rs b/crates/openshell-providers/src/discovery.rs index f1af8fbfa3..16cf497e92 100644 --- a/crates/openshell-providers/src/discovery.rs +++ b/crates/openshell-providers/src/discovery.rs @@ -66,6 +66,16 @@ pub fn discover_from_profile( } } + if profile.id == "google-vertex-ai" { + for key in openshell_core::inference::VERTEX_AI_CONFIG_KEY_NAMES { + if let Some(value) = context.env_var(key) + && !value.trim().is_empty() + { + discovered.config.entry((*key).to_string()).or_insert(value); + } + } + } + if discovered.is_empty() { Ok(None) } else { @@ -165,4 +175,27 @@ mod tests { } if profile_id == "custom" && credential_name == "missing" )); } + + #[test] + fn vertex_profile_discovery_includes_supported_configuration() { + let mut profile = profile(); + profile.id = "google-vertex-ai".to_string(); + let ctx = MockDiscoveryContext::new() + .with_env("CUSTOM_API_KEY", "vertex-token") + .with_env("VERTEX_AI_PROJECT_ID", "project-a") + .with_env("VERTEX_AI_REGION", "us-central1"); + + let discovered = discover_from_profile(&profile, &ctx) + .expect("discovery should succeed") + .expect("provider should be discovered"); + + assert_eq!( + discovered.config.get("VERTEX_AI_PROJECT_ID"), + Some(&"project-a".to_string()) + ); + assert_eq!( + discovered.config.get("VERTEX_AI_REGION"), + Some(&"us-central1".to_string()) + ); + } } diff --git a/crates/openshell-providers/src/lib.rs b/crates/openshell-providers/src/lib.rs index fabe1a5bed..c5112ee3f0 100644 --- a/crates/openshell-providers/src/lib.rs +++ b/crates/openshell-providers/src/lib.rs @@ -56,21 +56,10 @@ pub struct ProviderDiscoverySpec { pub credential_env_vars: &'static [&'static str], } -pub trait ProviderPlugin: Send + Sync { - /// Canonical provider id (for example: "claude", "gitlab"). +trait ProviderPlugin: Send + Sync { + /// Canonical provider id. fn id(&self) -> &'static str; - /// Discover provider credentials and config from the local machine. - fn discover_existing(&self) -> Result, ProviderError>; - - /// Return the known credential environment variable names for this provider type. - /// - /// Used by the TUI to label BYO key entry fields and to choose which - /// env var name to store a manually-entered credential under. - fn credential_env_vars(&self) -> &'static [&'static str] { - &[] - } - /// Inject provider-specific environment variables into the sandbox env. /// /// Called during sandbox creation to project provider config (project IDs, @@ -79,25 +68,6 @@ pub trait ProviderPlugin: Send + Sync { fn inject_env(&self, _provider: &Provider, _env: &mut HashMap) {} } -/// Blanket implementation of [`ProviderPlugin`] for [`ProviderDiscoverySpec`]. -/// -/// Providers that only need standard env-var discovery can register their -/// `SPEC` constant directly, instead of defining a dedicated struct and -/// repeating the same three-method delegation. -impl ProviderPlugin for ProviderDiscoverySpec { - fn id(&self) -> &'static str { - self.id - } - - fn discover_existing(&self) -> Result, ProviderError> { - discover_with_spec(self, &RealDiscoveryContext) - } - - fn credential_env_vars(&self) -> &'static [&'static str] { - self.credential_env_vars - } -} - #[derive(Default)] pub struct ProviderRegistry { plugins: HashMap<&'static str, Box>, @@ -107,24 +77,15 @@ impl ProviderRegistry { #[must_use] pub fn new() -> Self { let mut registry = Self::default(); - registry.register(providers::claude::SPEC); - registry.register(providers::codex::SPEC); - registry.register(providers::copilot::SPEC); - registry.register(providers::opencode::OpencodeProvider); - registry.register(providers::generic::GenericProvider); - registry.register(providers::openai::SPEC); - registry.register(providers::anthropic::SPEC); - registry.register(providers::nvidia::SPEC); - registry.register(providers::deepinfra::SPEC); - registry.register(providers::github::SPEC); - registry.register(providers::gitlab::SPEC); + // Keep only the legacy config projectors required to run existing + // Google Cloud and Vertex records. Public provider discovery is + // profile-driven; this registry is an internal compatibility adapter. registry.register(providers::google_cloud::GoogleCloudProvider); - registry.register(providers::outlook::OutlookProvider); registry.register(providers::vertex::VertexProvider); registry } - pub fn register

(&mut self, plugin: P) + fn register

(&mut self, plugin: P) where P: ProviderPlugin + 'static, { @@ -132,36 +93,10 @@ impl ProviderRegistry { } #[must_use] - pub fn get(&self, id: &str) -> Option<&dyn ProviderPlugin> { + fn get(&self, id: &str) -> Option<&dyn ProviderPlugin> { self.plugins.get(id).map(Box::as_ref) } - pub fn discover_existing(&self, id: &str) -> Result, ProviderError> { - let Some(plugin) = self.get(id) else { - return Err(ProviderError::UnsupportedProvider(id.to_string())); - }; - plugin.discover_existing() - } - - /// Return the known credential env var names for a provider type. - #[must_use] - pub fn credential_env_vars(&self, id: &str) -> &'static [&'static str] { - self.get(id) - .map_or(&[], ProviderPlugin::credential_env_vars) - } - - #[must_use] - pub fn profile(&self, id: &str) -> Option<&'static ProviderTypeProfile> { - builtin_profiles() - .iter() - .find(|profile| profile.id.eq_ignore_ascii_case(id)) - } - - #[must_use] - pub fn profiles(&self) -> Vec<&'static ProviderTypeProfile> { - builtin_profiles().iter().collect() - } - /// Inject provider-specific env vars via the registered plugin. /// /// Normalizes the provider type and delegates to the plugin's `inject_env`. @@ -176,11 +111,18 @@ impl ProviderRegistry { } } - #[must_use] - pub fn known_types(&self) -> Vec<&'static str> { - let mut types = self.plugins.keys().copied().collect::>(); - types.sort_unstable(); - types + /// Inject config for an already-resolved profile ID without alias + /// normalization. This prevents an exact custom profile whose ID resembles + /// a legacy alias from selecting an unrelated built-in compatibility plugin. + pub fn inject_env_for_profile_id( + &self, + provider: &Provider, + profile_id: &str, + env: &mut HashMap, + ) { + if let Some(plugin) = self.get(profile_id) { + plugin.inject_env(provider, env); + } } } @@ -196,12 +138,8 @@ pub fn normalize_provider_type(input: &str) -> Option<&'static str> { "claude" | "claude-code" | "claude_code" => Some("claude-code"), "codex" => Some("codex"), "copilot" => Some("copilot"), - "opencode" => Some("opencode"), "gcp" | "google-cloud" => Some("google-cloud"), - "generic" => Some("generic"), - "gitlab" | "glab" => Some("gitlab"), "github" | "gh" => Some("github"), - "outlook" => Some("outlook"), _ => None, } } @@ -222,12 +160,12 @@ mod tests { #[test] fn normalizes_known_provider_aliases() { - assert_eq!(normalize_provider_type("gitlab"), Some("gitlab")); - assert_eq!(normalize_provider_type("glab"), Some("gitlab")); assert_eq!(normalize_provider_type("gh"), Some("github")); assert_eq!(normalize_provider_type("CLAUDE"), Some("claude-code")); assert_eq!(normalize_provider_type("claude-code"), Some("claude-code")); - assert_eq!(normalize_provider_type("generic"), Some("generic")); + for retired in ["generic", "gitlab", "glab", "opencode", "outlook"] { + assert_eq!(normalize_provider_type(retired), None); + } assert_eq!(normalize_provider_type("openai"), Some("openai")); assert_eq!(normalize_provider_type("anthropic"), Some("anthropic")); assert_eq!(normalize_provider_type("nvidia"), Some("nvidia")); @@ -252,7 +190,7 @@ mod tests { ); assert_eq!( detect_provider_from_command(&["/usr/bin/glab".to_string()]), - Some("gitlab") + None ); assert_eq!( detect_provider_from_command(&["/usr/bin/bash".to_string()]), diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 6e87826c4d..a1f9f130a1 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -29,6 +29,7 @@ const BUILT_IN_PROFILE_YAMLS: &[&str] = &[ include_str!("../../../providers/aws.yaml"), include_str!("../../../providers/aws-bedrock.yaml"), include_str!("../../../providers/aws-s3.yaml"), + include_str!("../../../providers/anthropic.yaml"), include_str!("../../../providers/claude-code.yaml"), include_str!("../../../providers/codex.yaml"), include_str!("../../../providers/copilot.yaml"), @@ -38,6 +39,7 @@ const BUILT_IN_PROFILE_YAMLS: &[&str] = &[ include_str!("../../../providers/google-cloud.yaml"), include_str!("../../../providers/google-vertex-ai.yaml"), include_str!("../../../providers/nvidia.yaml"), + include_str!("../../../providers/openai.yaml"), include_str!("../../../providers/pypi.yaml"), ]; @@ -492,13 +494,25 @@ impl ProviderTypeProfile { /// Whether this profile can be created without initial static credentials. /// - /// Empty provider creation is allowed when at least one credential can be - /// resolved at runtime, and every required credential can be resolved at - /// runtime. Runtime-resolvable credentials are either gateway-mintable - /// refresh credentials, sandbox-side dynamic token grants, or additional - /// outputs co-minted by another credential's gateway-mintable refresh. + /// Empty provider creation is allowed when every required credential can + /// be resolved at runtime. This includes profiles with no credentials and + /// profiles whose credentials are all optional. #[must_use] pub fn allows_empty_provider_credentials(&self) -> bool { + let co_minted = self.co_minted_credential_names(); + self.credentials.iter().all(|credential| { + let is_runtime_resolvable = + credential.is_runtime_resolvable() || co_minted.contains(credential.name.as_str()); + !credential.required || is_runtime_resolvable + }) + } + + /// Whether `--runtime-credentials` is meaningful for this profile. + /// + /// At least one credential must be runtime-resolvable and every required + /// credential must be resolvable without an initial static value. + #[must_use] + pub fn allows_runtime_provider_credentials(&self) -> bool { let co_minted = self.co_minted_credential_names(); let mut has_runtime_resolvable_credential = false; for credential in &self.credentials { @@ -512,6 +526,20 @@ impl ProviderTypeProfile { has_runtime_resolvable_credential } + /// Required credentials that must have an initial static value. + #[must_use] + pub fn required_static_credentials(&self) -> Vec<&CredentialProfile> { + let co_minted = self.co_minted_credential_names(); + self.credentials + .iter() + .filter(|credential| { + credential.required + && !credential.is_runtime_resolvable() + && !co_minted.contains(credential.name.as_str()) + }) + .collect() + } + /// Names of credentials produced as `additional_outputs` of a /// gateway-mintable refresh on some other credential. fn co_minted_credential_names(&self) -> HashSet<&str> { @@ -702,6 +730,21 @@ fn is_u64_zero(value: &u64) -> bool { } impl CredentialProfile { + /// Keys accepted when storing this credential on a provider. + /// + /// Workload-injectable credentials use their declared environment aliases. + /// Broker-only credentials have no environment aliases and use their + /// logical profile name instead. + #[must_use] + pub fn accepted_stored_keys(&self) -> Vec<&str> { + if self.env_vars.is_empty() { + let name = self.name.trim(); + return (!name.is_empty()).then_some(name).into_iter().collect(); + } + + self.env_vars.iter().map(String::as_str).collect() + } + #[must_use] pub fn is_runtime_resolvable(&self) -> bool { self.token_grant.is_some() @@ -3042,6 +3085,31 @@ mod tests { ); } + #[test] + fn accepted_stored_keys_use_logical_name_for_broker_only_credentials() { + let profile = parse_profile_yaml( + r" +id: token-exchange +display_name: Token Exchange +credentials: + - name: subject_token + required: true + - name: access_token + env_vars: [ACCESS_TOKEN, ACCESS_TOKEN_FALLBACK] +", + ) + .expect("profile"); + + assert_eq!( + profile.credentials[0].accepted_stored_keys(), + vec!["subject_token"] + ); + assert_eq!( + profile.credentials[1].accepted_stored_keys(), + vec!["ACCESS_TOKEN", "ACCESS_TOKEN_FALLBACK"] + ); + } + #[test] fn vertex_profile_declares_discovery_and_fallback_token_env_vars() { let profile = builtin_profile("google-vertex-ai"); @@ -3081,8 +3149,7 @@ mod tests { } #[test] - fn empty_provider_credentials_require_a_runtime_resolvable_path_and_no_required_static_credentials() - { + fn empty_provider_credentials_require_no_required_static_credentials() { let optional_refresh_profile = parse_profile_yaml( r" id: optional-refresh @@ -3096,6 +3163,7 @@ credentials: ) .expect("profile"); assert!(optional_refresh_profile.allows_empty_provider_credentials()); + assert!(optional_refresh_profile.allows_runtime_provider_credentials()); let token_grant_profile = parse_profile_yaml( r" @@ -3110,6 +3178,7 @@ credentials: ) .expect("profile"); assert!(token_grant_profile.allows_empty_provider_credentials()); + assert!(token_grant_profile.allows_runtime_provider_credentials()); let mixed_required_profile = parse_profile_yaml( r" @@ -3126,6 +3195,7 @@ credentials: ) .expect("profile"); assert!(!mixed_required_profile.allows_empty_provider_credentials()); + assert!(!mixed_required_profile.allows_runtime_provider_credentials()); let static_only_profile = parse_profile_yaml( r" @@ -3137,7 +3207,21 @@ credentials: ", ) .expect("profile"); - assert!(!static_only_profile.allows_empty_provider_credentials()); + assert!(static_only_profile.allows_empty_provider_credentials()); + assert!(!static_only_profile.allows_runtime_provider_credentials()); + + let policy_only_profile = parse_profile_yaml( + r" +id: policy-only +display_name: Policy Only +endpoints: + - host: example.com + port: 443 +", + ) + .expect("profile"); + assert!(policy_only_profile.allows_empty_provider_credentials()); + assert!(!policy_only_profile.allows_runtime_provider_credentials()); } #[test] diff --git a/crates/openshell-providers/src/providers/google_cloud.rs b/crates/openshell-providers/src/providers/google_cloud.rs index 470d1d206f..3bea53d825 100644 --- a/crates/openshell-providers/src/providers/google_cloud.rs +++ b/crates/openshell-providers/src/providers/google_cloud.rs @@ -5,29 +5,13 @@ use std::collections::HashMap; use openshell_core::google_cloud; -use crate::{ - DiscoveredProvider, Provider, ProviderDiscoverySpec, ProviderError, ProviderPlugin, - RealDiscoveryContext, discover_with_spec, -}; +use crate::{Provider, ProviderPlugin}; pub struct GoogleCloudProvider; -const SPEC: ProviderDiscoverySpec = ProviderDiscoverySpec { - id: "google-cloud", - credential_env_vars: google_cloud::TOKEN_ENV_KEYS, -}; - impl ProviderPlugin for GoogleCloudProvider { fn id(&self) -> &'static str { - SPEC.id - } - - fn discover_existing(&self) -> Result, ProviderError> { - discover_with_spec(&SPEC, &RealDiscoveryContext) - } - - fn credential_env_vars(&self) -> &'static [&'static str] { - SPEC.credential_env_vars + "google-cloud" } fn inject_env(&self, provider: &Provider, env: &mut HashMap) { diff --git a/crates/openshell-providers/src/providers/mod.rs b/crates/openshell-providers/src/providers/mod.rs index 2770210318..b6450e170e 100644 --- a/crates/openshell-providers/src/providers/mod.rs +++ b/crates/openshell-providers/src/providers/mod.rs @@ -1,46 +1,5 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -/// Generate a standard discovery smoke-test for a provider whose only test is -/// checking that an env-var credential is picked up by `discover_with_spec`. -/// -/// # Usage -/// ```ignore -/// test_discovers_env_credential!(discovers_openai_env_credentials, "OPENAI_API_KEY", "sk-test"); -/// ``` -macro_rules! test_discovers_env_credential { - ($test_name:ident, $env_var:expr, $env_value:expr) => { - #[cfg(test)] - mod tests { - use super::SPEC; - use crate::discover_with_spec; - use crate::test_helpers::MockDiscoveryContext; - - #[test] - fn $test_name() { - let ctx = MockDiscoveryContext::new().with_env($env_var, $env_value); - let discovered = discover_with_spec(&SPEC, &ctx) - .expect("discovery") - .expect("provider"); - assert_eq!( - discovered.credentials.get($env_var), - Some(&$env_value.to_string()) - ); - } - } - }; -} -pub mod anthropic; -pub mod claude; -pub mod codex; -pub mod copilot; -pub mod deepinfra; -pub mod generic; -pub mod github; -pub mod gitlab; pub mod google_cloud; -pub mod nvidia; -pub mod openai; -pub mod opencode; -pub mod outlook; pub mod vertex; diff --git a/crates/openshell-providers/src/providers/vertex.rs b/crates/openshell-providers/src/providers/vertex.rs index ad52bc51c0..ba3aacd007 100644 --- a/crates/openshell-providers/src/providers/vertex.rs +++ b/crates/openshell-providers/src/providers/vertex.rs @@ -6,43 +6,13 @@ use std::collections::HashMap; use openshell_core::google_cloud; use openshell_core::inference; -use crate::{ - DiscoveredProvider, Provider, ProviderDiscoverySpec, ProviderError, ProviderPlugin, - RealDiscoveryContext, discover_with_spec, -}; +use crate::{Provider, ProviderPlugin}; pub struct VertexProvider; -const SPEC: ProviderDiscoverySpec = ProviderDiscoverySpec { - id: "google-vertex-ai", - credential_env_vars: inference::VERTEX_AI_CREDENTIAL_KEY_NAMES, -}; - impl ProviderPlugin for VertexProvider { fn id(&self) -> &'static str { - SPEC.id - } - - fn discover_existing(&self) -> Result, ProviderError> { - let mut discovered = discover_with_spec(&SPEC, &RealDiscoveryContext)?.unwrap_or_default(); - - for key in inference::VERTEX_AI_CONFIG_KEY_NAMES { - if let Ok(val) = std::env::var(key) - && !val.trim().is_empty() - { - discovered.config.entry(key.to_string()).or_insert(val); - } - } - - if discovered.is_empty() { - Ok(None) - } else { - Ok(Some(discovered)) - } - } - - fn credential_env_vars(&self) -> &'static [&'static str] { - SPEC.credential_env_vars + "google-vertex-ai" } fn inject_env(&self, provider: &Provider, env: &mut HashMap) { diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index cee8739731..83bdc0b00b 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -71,7 +71,6 @@ use openshell_prover::{ registry::load_embedded_binary_registry, report::finding_shorthand, }; -use openshell_providers::normalize_provider_type; use prost::Message; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashMap, HashSet}; @@ -809,10 +808,9 @@ async fn build_credential_set_for_sandbox_with_catalog( }; let provider_type = provider.r#type.trim(); - let profile_id = normalize_provider_type(provider_type).unwrap_or(provider_type); let Some(profile) = super::provider::get_provider_type_profile_for_scope( catalog, - profile_id, + provider_type, &provider.profile_workspace, ) else { warn!( @@ -1582,8 +1580,8 @@ async fn auto_approve_chunk( } // TODO: share effective-policy lookup with `load_sandbox_policy` / -// `GetSandboxConfig`. They re-implement very similar global-settings + -// providers_v2 + compose logic; consolidating them is out of scope for the +// `GetSandboxConfig`. They re-implement very similar global-settings and +// profile-composition logic; consolidating them is out of scope for the // agent-authored proposal validation slice. async fn current_effective_policy_for_sandbox( state: &ServerState, @@ -1632,8 +1630,6 @@ async fn effective_policy_for_source( }, ); - let providers_v2_enabled = - bool_setting_enabled(&global_settings, settings::PROVIDERS_V2_ENABLED_KEY)?; clear_provider_credentialed_markers(&mut policy); let mut provider_context = provider_policy_context_with_catalog( state.store.as_ref(), @@ -1642,10 +1638,7 @@ async fn effective_policy_for_source( provider_names, ) .await?; - if providers_v2_enabled - && !matches!(policy_source, PolicySource::Global) - && !provider_context.layers.is_empty() - { + if !matches!(policy_source, PolicySource::Global) && !provider_context.layers.is_empty() { policy = compose_effective_policy(&policy, &provider_context.layers); } let policy_credential_bindings = policy_static_credential_endpoint_bindings(Some(&policy))?; @@ -1782,11 +1775,9 @@ fn validate_policy_credential_binding_context( "credential_binding references provider '{provider_name}', but that provider is not attached to the sandbox" )) })?; - let profile_id = normalize_provider_type(&record.provider.r#type) - .unwrap_or(record.provider.r#type.as_str()); let profile = super::provider::get_provider_type_profile_for_scope( catalog, - profile_id, + &record.provider.r#type, &record.provider.profile_workspace, ) .ok_or_else(|| { @@ -1859,11 +1850,9 @@ fn signing_profile_for_record( catalog: &EffectiveProviderProfileCatalog, record: &super::provider::ProviderEnvironmentRecord, ) -> Option { - let profile_id = - normalize_provider_type(&record.provider.r#type).unwrap_or(record.provider.r#type.as_str()); super::provider::get_provider_type_profile_for_scope( catalog, - profile_id, + &record.provider.r#type, &record.provider.profile_workspace, ) } @@ -1966,9 +1955,7 @@ async fn provider_policy_layers_for_sandbox( provider_names: &[String], ) -> Result, Status> { let global_settings = load_global_settings(state.store.as_ref()).await?; - if decode_policy_from_global_settings(&global_settings)?.is_some() - || !bool_setting_enabled(&global_settings, settings::PROVIDERS_V2_ENABLED_KEY)? - { + if decode_policy_from_global_settings(&global_settings)?.is_some() { return Ok(Vec::new()); } let catalog = state @@ -2046,8 +2033,7 @@ pub(super) async fn provider_policy_composition_enabled(store: &Store) -> Result } fn provider_policy_composition_enabled_in(settings: &StoredSettings) -> Result { - Ok(decode_policy_from_global_settings(settings)?.is_none() - && bool_setting_enabled(settings, settings::PROVIDERS_V2_ENABLED_KEY)?) + Ok(decode_policy_from_global_settings(settings)?.is_none()) } async fn validate_provider_composition_for_existing_sandboxes( @@ -2114,6 +2100,15 @@ async fn validate_provider_composition_for_existing_sandboxes( Ok(()) } +pub async fn validate_provider_composition_startup_preflight( + state: &ServerState, +) -> Result<(), Status> { + if provider_policy_composition_enabled(state.store.as_ref()).await? { + validate_provider_composition_for_existing_sandboxes(state).await?; + } + Ok(()) +} + pub(super) async fn validate_candidate_sandbox_credential_policy( state: &ServerState, workspace: &str, @@ -2408,8 +2403,6 @@ pub(super) async fn handle_get_sandbox_config( let global_settings = load_global_settings(state.store.as_ref()).await?; let sandbox_settings = load_sandbox_settings(state.store.as_ref(), &workspace, sandbox.object_name()).await?; - let providers_v2_enabled = - bool_setting_enabled(&global_settings, settings::PROVIDERS_V2_ENABLED_KEY)?; let mut provider_policy_context = provider_policy_context_with_catalog( state.store.as_ref(), &provider_profile_catalog, @@ -2442,8 +2435,7 @@ pub(super) async fn handle_get_sandbox_config( clear_provider_credentialed_markers(source_policy); } - if providers_v2_enabled - && !matches!(policy_source, PolicySource::Global) + if !matches!(policy_source, PolicySource::Global) && let Some(source_policy) = policy.as_ref() && !provider_policy_context.layers.is_empty() { @@ -2743,8 +2735,7 @@ fn hash_provider_profile_revision( profile_workspace: &str, hasher: &mut Sha256, ) { - let profile_id = normalize_provider_type(provider_type).unwrap_or(provider_type); - catalog.hash_type_profile_revision_for_scope(profile_id, profile_workspace, hasher); + catalog.hash_type_profile_revision_for_scope(provider_type, profile_workspace, hasher); } #[cfg(test)] @@ -2804,10 +2795,9 @@ async fn provider_policy_context_with_catalog( .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; let provider_type = provider.r#type.trim(); - let profile_id = normalize_provider_type(provider_type).unwrap_or(provider_type); let Some(profile) = super::provider::get_provider_type_profile_for_scope( catalog, - profile_id, + provider_type, &provider.profile_workspace, ) else { warn!( @@ -2818,6 +2808,11 @@ async fn provider_policy_context_with_catalog( continue; }; + if !super::provider::provider_profile_endpoints_are_active(&profile, &provider) { + endpointless_provider_names.insert(name.clone()); + continue; + } + let rule_name = openshell_policy::provider_rule_name(provider.object_name()); let mut rule = profile.network_policy_rule(&rule_name); if rule.endpoints.is_empty() { @@ -3005,16 +3000,6 @@ fn report_uninspected_credentialed_endpoints(policy: &ProtoSandboxPolicy, sandbo } } -pub(super) fn bool_setting_enabled(settings: &StoredSettings, key: &str) -> Result { - match settings.settings.get(key) { - None => Ok(false), - Some(StoredSettingValue::Bool(value)) => Ok(*value), - Some(_) => Err(Status::internal(format!( - "setting '{key}' has invalid value type; expected bool" - ))), - } -} - pub(super) async fn handle_get_gateway_config( state: &Arc, _request: Request, @@ -6721,30 +6706,6 @@ pub(super) async fn load_global_settings(store: &Store) -> Result Result { - let global_settings = load_global_settings(store).await?; - bool_setting_enabled(&global_settings, key) -} - -/// Test helper: set a boolean global setting, loading current settings first so -/// the CAS write succeeds whether the record already exists or not. Available to -/// sibling test modules without exposing the private `StoredSettings` type. -#[cfg(test)] -pub async fn set_global_bool_setting_for_test( - store: &Store, - key: &str, - value: bool, -) -> Result<(), Status> { - let mut settings = load_global_settings(store).await?; - settings - .settings - .insert(key.to_string(), StoredSettingValue::Bool(value)); - save_global_settings(store, &settings).await -} - pub(super) async fn save_global_settings( store: &Store, settings: &StoredSettings, @@ -8087,21 +8048,6 @@ mod tests { sandbox } - async fn enable_providers_v2(state: &Arc) { - let global_settings = StoredSettings { - revision: 1, - settings: std::iter::once(( - settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }; - save_global_settings(state.store.as_ref(), &global_settings) - .await - .unwrap(); - } - async fn get_sandbox_policy(state: &Arc, sandbox_id: &str) -> ProtoSandboxPolicy { handle_get_sandbox_config( state, @@ -8199,7 +8145,6 @@ mod tests { Arc::clone(&fetch_count), ); let state = Arc::new(state); - enable_providers_v2(&state).await; let mut provider_a = test_provider("provider-a", "moving-a"); provider_a.credentials = HashMap::from([("TOKEN_A".to_string(), "a".to_string())]); @@ -8382,6 +8327,48 @@ mod tests { assert_eq!(layers[0].rule.endpoints[0].host, "backdoor.example"); } + #[tokio::test] + async fn provider_policy_layers_prefer_exact_imported_alias_profile() { + let store = test_store().await; + store + .put_message(&test_provider("enterprise-github", "gh")) + .await + .unwrap(); + store + .put_message(&openshell_core::proto::StoredProviderProfile { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "profile-gh".to_string(), + name: "gh".to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + profile: Some(openshell_core::proto::ProviderProfile { + id: "gh".to_string(), + display_name: "Enterprise GitHub".to_string(), + endpoints: vec![NetworkEndpoint { + host: "github.enterprise.example".to_string(), + port: 443, + ..Default::default() + }], + ..Default::default() + }), + }) + .await + .unwrap(); + + let layers = + profile_provider_policy_layers(&store, "default", &["enterprise-github".to_string()]) + .await + .unwrap(); + + assert_eq!(layers.len(), 1); + assert_eq!(layers[0].rule.endpoints.len(), 1); + assert_eq!( + layers[0].rule.endpoints[0].host, + "github.enterprise.example" + ); + } + #[tokio::test] #[allow(deprecated)] async fn provider_policy_layers_include_custom_provider_profiles() { @@ -8598,6 +8585,36 @@ mod tests { ); } + #[tokio::test] + async fn provider_policy_layers_skip_public_vendor_endpoints_for_alternate_upstreams() { + let store = test_store().await; + let mut openai = test_provider("alternate-openai", "openai"); + openai.config.insert( + "OPENAI_BASE_URL".to_string(), + "https://api.example.com/v1".to_string(), + ); + let mut anthropic = test_provider("alternate-anthropic", "anthropic"); + anthropic.config.insert( + "ANTHROPIC_BASE_URL".to_string(), + "https://api.example.com/v1".to_string(), + ); + store.put_message(&openai).await.unwrap(); + store.put_message(&anthropic).await.unwrap(); + + let layers = profile_provider_policy_layers( + &store, + "default", + &[ + "alternate-openai".to_string(), + "alternate-anthropic".to_string(), + ], + ) + .await + .unwrap(); + + assert!(layers.is_empty()); + } + #[tokio::test] async fn provider_policy_layers_respect_profile_workspace_scope() { let store = test_store().await; @@ -8671,65 +8688,9 @@ mod tests { ); } - #[test] - fn providers_v2_enabled_defaults_false_when_unset() { - assert!( - !bool_setting_enabled( - &StoredSettings::default(), - settings::PROVIDERS_V2_ENABLED_KEY - ) - .unwrap() - ); - } - - #[test] - fn providers_v2_enabled_reads_global_bool_setting() { - let mut settings = StoredSettings::default(); - settings.settings.insert( - settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - ); - - assert!(bool_setting_enabled(&settings, settings::PROVIDERS_V2_ENABLED_KEY).unwrap()); - } - - #[tokio::test] - async fn sandbox_config_omits_provider_layers_when_v2_disabled() { - let state = test_server_state().await; - state - .store - .put_message(&test_provider("work-github", "github")) - .await - .unwrap(); - state - .store - .put_message(&test_sandbox( - "sb-v2-disabled", - "v2-disabled", - test_policy_with_rule("sandbox_only", "sandbox.example.com"), - vec!["work-github".to_string()], - )) - .await - .unwrap(); - - let effective_policy = get_sandbox_policy(&state, "sb-v2-disabled").await; - - assert!( - effective_policy - .network_policies - .contains_key("sandbox_only") - ); - assert!( - !effective_policy - .network_policies - .contains_key("_provider_work_github") - ); - } - #[tokio::test] - async fn sandbox_config_composes_provider_layers_when_v2_enabled() { + async fn sandbox_config_always_composes_provider_layers() { let state = test_server_state().await; - enable_providers_v2(&state).await; state .store .put_message(&test_provider("work-github", "github")) @@ -9190,12 +9151,17 @@ mod tests { sandbox.spec.as_mut().unwrap().policy = None; state.store.put_message(&sandbox).await.unwrap(); + let mut policy = test_sigv4_policy("bucket.s3.amazonaws.com", None); + let endpoint = &mut policy.network_policies.get_mut("aws").unwrap().endpoints[0]; + endpoint.access = "read-write".to_string(); + endpoint.enforcement = "enforce".to_string(); + handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { name: "signing-profile-endpoint".to_string(), workspace: "default".to_string(), - policy: Some(test_sigv4_policy("bucket.s3.amazonaws.com", None)), + policy: Some(policy), ..Default::default() })), ) @@ -9287,7 +9253,6 @@ mod tests { }; let state = test_server_state().await; - enable_providers_v2(&state).await; state .store .put_message(&StoredProviderProfile { @@ -9360,7 +9325,6 @@ mod tests { }; let state = test_server_state().await; - enable_providers_v2(&state).await; let profile = StoredProviderProfile { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { @@ -9434,9 +9398,8 @@ mod tests { } #[tokio::test] - async fn sandbox_config_skips_profileless_provider_types_when_v2_enabled() { + async fn sandbox_config_skips_profileless_provider_types() { let state = test_server_state().await; - enable_providers_v2(&state).await; state .store .put_message(&test_provider("legacy-generic", "generic")) @@ -9471,7 +9434,6 @@ mod tests { #[tokio::test] async fn sandbox_config_composition_is_jit_and_does_not_persist_provider_layers() { let state = test_server_state().await; - enable_providers_v2(&state).await; state .store .put_message(&test_provider("work-github", "github")) @@ -9558,7 +9520,6 @@ mod tests { } let state = test_server_state().await; - enable_providers_v2(&state).await; state .store .put_message(&stored_profile("api.before.example")) @@ -9661,7 +9622,6 @@ mod tests { #[tokio::test] async fn sandbox_config_composes_user_and_provider_rules() { let state = test_server_state().await; - enable_providers_v2(&state).await; state .store .put_message(&test_provider("work-github", "github")) @@ -9710,7 +9670,7 @@ mod tests { } #[tokio::test] - async fn provider_environment_resolution_is_unchanged_by_providers_v2_setting() { + async fn provider_environment_resolution_is_stable_across_policy_composition() { use openshell_core::proto::GetSandboxProviderEnvironmentRequest; let state = test_server_state().await; @@ -9742,7 +9702,6 @@ mod tests { .into_inner() .environment; - enable_providers_v2(&state).await; let v2_env = handle_get_sandbox_provider_environment( &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { @@ -9805,7 +9764,7 @@ mod tests { .put_message(&test_provider("work-github", "github")) .await .unwrap(); - let mut profileless_openai = test_provider("gateway-openai", "openai"); + let mut profileless_openai = test_provider("gateway-openai", "legacy-openai"); profileless_openai.credentials = HashMap::from([("OPENAI_API_KEY".to_string(), "openai-secret".to_string())]); state.store.put_message(&profileless_openai).await.unwrap(); @@ -9871,6 +9830,11 @@ mod tests { id: "endpointless".to_string(), display_name: "Endpointless".to_string(), category: ProviderProfileCategory::Other as i32, + credentials: vec![openshell_core::proto::ProviderProfileCredential { + name: "cloud_token".to_string(), + env_vars: vec!["CLOUD_TOKEN".to_string()], + ..Default::default() + }], endpoints: Vec::new(), ..Default::default() }), @@ -10394,6 +10358,7 @@ mod tests { category: ProviderProfileCategory::Other as i32, credentials: vec![ProviderProfileCredential { name: "access_token".to_string(), + env_vars: vec!["GITHUB_TOKEN".to_string()], auth_style: "bearer".to_string(), header_name: "authorization".to_string(), token_grant: Some(ProviderCredentialTokenGrant { @@ -10769,7 +10734,6 @@ mod tests { }; let state = test_server_state().await; - enable_providers_v2(&state).await; state .store .put_message(&test_provider("work-github", "github")) @@ -10892,7 +10856,6 @@ mod tests { }; let state = test_server_state().await; - enable_providers_v2(&state).await; handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { @@ -11051,7 +11014,7 @@ mod tests { } #[tokio::test] - async fn global_policy_suppresses_provider_profile_layers_when_v2_enabled() { + async fn global_policy_suppresses_provider_profile_layers() { use openshell_core::proto::{ GetSandboxConfigRequest, NetworkEndpoint, NetworkPolicyRule, SandboxPhase, SandboxPolicy, SandboxSpec, @@ -11119,17 +11082,10 @@ mod tests { }; let global_settings = StoredSettings { revision: 1, - settings: [ - ( - settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - ), - ( - POLICY_SETTING_KEY.to_string(), - StoredSettingValue::Bytes(hex::encode(global_policy.encode_to_vec())), - ), - ] - .into_iter() + settings: std::iter::once(( + POLICY_SETTING_KEY.to_string(), + StoredSettingValue::Bytes(hex::encode(global_policy.encode_to_vec())), + )) .collect(), ..Default::default() }; @@ -12655,13 +12611,13 @@ mod tests { .find(|c| c.id == mechanistic_chunk_id) .expect("mechanistic chunk present"); assert_eq!(mech.status, "pending"); - // Mechanistic L4 with credential in scope flags as new credentialed - // reach for the binary on the host. + // The attached GitHub profile already grants credentialed reach for + // this host, so the mechanistic proposal does not expand reach. assert!( - mech.validation_result + !mech + .validation_result .contains("credential_reach_expansion"), - "mechanistic L4 with credential in scope should emit \ - credential_reach_expansion; got: {}", + "profile-composed reach should prevent a duplicate expansion finding; got: {}", mech.validation_result ); @@ -14348,7 +14304,7 @@ mod tests { } #[tokio::test] - async fn agent_authored_validation_uses_providers_v2_effective_policy() { + async fn agent_authored_validation_uses_profile_composed_effective_policy() { use openshell_core::proto::{ FilesystemPolicy, L7Allow, L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, ProviderProfile, ProviderProfileCategory, SandboxPhase, SandboxPolicy, SandboxSpec, @@ -14356,7 +14312,6 @@ mod tests { }; let state = test_server_state().await; - enable_providers_v2(&state).await; state .store .put_message(&test_provider("work-custom", "custom-api")) @@ -14580,7 +14535,6 @@ mod tests { }; let state = test_server_state().await; - enable_providers_v2(&state).await; // Github provider attached: a credential ends up in scope for // api.github.com (PUT proposal flags MEDIUM). raw.githubusercontent.com @@ -16554,32 +16508,6 @@ mod tests { .unwrap(); } - #[tokio::test] - async fn enabling_provider_composition_rejects_existing_ambiguous_binding() { - let state = test_server_state().await; - install_ambiguous_provider_binding(&state, "enable").await; - - let error = handle_update_config( - &state, - with_user(Request::new(UpdateConfigRequest { - global: true, - setting_key: settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - setting_value: Some(SettingValue { - value: Some(setting_value::Value::BoolValue(true)), - }), - ..Default::default() - })), - ) - .await - .expect_err("provider composition must be validated before activation"); - - assert_eq!(error.code(), Code::FailedPrecondition); - assert!(error.message().contains("sandbox-enable")); - assert!(error.message().contains("tls")); - let settings = load_global_settings(state.store.as_ref()).await.unwrap(); - assert!(!bool_setting_enabled(&settings, settings::PROVIDERS_V2_ENABLED_KEY).unwrap()); - } - #[tokio::test] async fn deleting_global_policy_rejects_reactivated_ambiguous_provider_binding() { let state = test_server_state().await; @@ -16595,20 +16523,6 @@ mod tests { ) .await .expect("global policy should suppress provider composition"); - handle_update_config( - &state, - with_user(Request::new(UpdateConfigRequest { - global: true, - setting_key: settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - setting_value: Some(SettingValue { - value: Some(setting_value::Value::BoolValue(true)), - }), - ..Default::default() - })), - ) - .await - .expect("providers may be enabled while a global policy is active"); - let error = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { @@ -16627,21 +16541,28 @@ mod tests { assert!(settings.settings.contains_key(POLICY_SETTING_KEY)); } + #[tokio::test] + async fn startup_preflight_rejects_persisted_ambiguous_provider_binding() { + let state = test_server_state().await; + install_ambiguous_provider_binding(&state, "upgrade").await; + + let error = validate_provider_composition_startup_preflight(&state) + .await + .expect_err("startup must reject policy that unconditional composition would activate"); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("sandbox-upgrade")); + assert!(error.message().contains("invalid effective policy")); + } + #[test] fn merge_effective_settings_global_overrides_sandbox_key() { let global = StoredSettings { revision: 2, - settings: [ - ( - settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(false), - ), - ( - settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(false), - ), - ] - .into_iter() + settings: std::iter::once(( + settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY.to_string(), + StoredSettingValue::Bool(false), + )) .collect(), ..Default::default() }; @@ -16649,7 +16570,7 @@ mod tests { revision: 1, settings: [ ( - settings::PROVIDERS_V2_ENABLED_KEY.to_string(), + settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY.to_string(), StoredSettingValue::Bool(true), ), ( @@ -16663,15 +16584,6 @@ mod tests { }; let merged = merge_effective_settings(&global, &sandbox).unwrap(); - let providers_v2 = merged - .get(settings::PROVIDERS_V2_ENABLED_KEY) - .expect("providers_v2_enabled present"); - assert_eq!(providers_v2.scope, SettingScope::Global as i32); - assert_eq!( - providers_v2.value.as_ref().and_then(|v| v.value.as_ref()), - Some(&setting_value::Value::BoolValue(false)) - ); - let ocsf_json = merged .get("ocsf_json_enabled") .expect("ocsf_json_enabled present"); diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index c8c8149a82..35765781c4 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -167,13 +167,18 @@ async fn create_provider_record_validating( "provider.credential_handles is internal gateway state and cannot be supplied", )); } + if !provider.profile_workspace.is_empty() && provider.profile_workspace != workspace { return Err(Status::invalid_argument( "profile_workspace must be empty (global) or match the provider workspace", )); } if provider.credentials.is_empty() - && !provider_type_allows_empty_credentials(catalog, &provider.r#type) + && !provider_type_allows_empty_credentials( + catalog, + &provider.r#type, + &provider.profile_workspace, + ) { return Err(Status::invalid_argument( "provider.credentials must not be empty", @@ -462,6 +467,13 @@ async fn update_provider_record_validating( let cas_result = async { validate_provider_mutable_fields(&candidate)?; + if let Some(profile) = get_provider_type_profile_for_scope( + catalog, + &candidate.r#type, + &candidate.profile_workspace, + ) { + validate_provider_credentials(&profile, &candidate, &updated_credential_values)?; + } validate_provider_update_against_attached_sandboxes_with_catalog( store, catalog, workspace, &candidate, ) @@ -645,6 +657,55 @@ where Ok(out) } +async fn providers_using_profile( + store: &Store, + workspace: &str, + profile_id: &str, +) -> Result, Status> { + let is_platform_scope = workspace.is_empty(); + let mut offset = 0u32; + let mut blocking = Vec::new(); + loop { + let records = if is_platform_scope { + store + .list_by_type(Provider::object_type(), 1000, offset) + .await + } else { + store + .list(Provider::object_type(), workspace, 1000, offset) + .await + } + .map_err(|e| Status::internal(format!("list providers failed: {e}")))?; + if records.is_empty() { + break; + } + offset = offset + .checked_add( + u32::try_from(records.len()) + .map_err(|_| Status::internal("provider page size exceeded u32"))?, + ) + .ok_or_else(|| Status::internal("provider pagination offset overflow"))?; + for record in records { + let provider = Provider::decode(record.payload.as_slice()) + .map_err(|e| Status::internal(format!("decode provider failed: {e}")))?; + if provider.profile_workspace != workspace + || normalize_profile_id(&provider.r#type).as_deref() != Some(profile_id) + { + continue; + } + let label = if is_platform_scope { + format!("{}/{}", provider.object_workspace(), provider.object_name()) + } else { + provider.object_name().to_string() + }; + blocking.push(label); + } + } + blocking.sort(); + blocking.dedup(); + Ok(blocking) +} + async fn sandboxes_using_provider( store: &Store, workspace: &str, @@ -1094,16 +1155,31 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin let name = &record.name; let provider = &record.provider; let mut provider_env = HashMap::new(); - let profile_id = - normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); - let profile = - get_provider_type_profile_for_scope(catalog, profile_id, &provider.profile_workspace); + let profile = get_provider_type_profile_for_scope( + catalog, + &provider.r#type, + &provider.profile_workspace, + ); + let accepted_stored_credential_keys = profile.as_ref().map(|profile| { + profile + .credentials + .iter() + .flat_map(|credential| credential.accepted_stored_keys()) + .map(str::to_string) + .collect::>() + }); + let profile_endpoints_are_active = profile + .as_ref() + .is_none_or(|profile| provider_profile_endpoints_are_active(profile, provider)); let profile_proto = profile.as_ref().map(ProviderTypeProfile::to_proto); let broker_only_credential_keys = profile_proto .as_ref() .map(broker_only_provider_credential_keys) .unwrap_or_default(); let profile_endpoints = profile_proto.as_ref().map(|profile| { + if !profile_endpoints_are_active { + return Vec::new(); + } profile .endpoints .iter() @@ -1143,6 +1219,17 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin let refresh_epochs = refresh_authorization_epochs_by_key(record)?; for (key, value) in &provider.credentials { + if accepted_stored_credential_keys + .as_ref() + .is_some_and(|accepted| !accepted.contains(key)) + { + warn!( + provider_name = %name, + key = %key, + "withholding provider credential not declared by resolved profile" + ); + continue; + } if is_non_injectable_provider_credential(provider, key) || broker_only_credential_keys.contains(key) { @@ -1215,6 +1302,17 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin .resolve_provider_handles(provider, now_ms) .await?; for (key, value) in resolved_refs.values { + if accepted_stored_credential_keys + .as_ref() + .is_some_and(|accepted| !accepted.contains(&key)) + { + warn!( + provider_name = %name, + key = %key, + "withholding provider credential handle not declared by resolved profile" + ); + continue; + } if is_non_injectable_provider_credential(provider, &key) || broker_only_credential_keys.contains(&key) { @@ -1274,7 +1372,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin // provider's earlier output cannot change how this provider classifies // or populates its own keys. Cross-provider credential/config // collisions have already been rejected by the validation above. - registry.inject_env(provider, &mut provider_env); + inject_provider_plugin_environment(catalog, provider, ®istry, &mut provider_env); for (key, value) in provider_env { env.entry(key).or_insert(value); } @@ -1400,11 +1498,11 @@ fn resolve_dynamic_credentials_from_records( let mut dynamic_creds = HashMap::new(); for record in records { let provider = &record.provider; - let profile_id = - normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); - let Some(profile) = - get_provider_type_profile_for_scope(catalog, profile_id, &provider.profile_workspace) - else { + let Some(profile) = get_provider_type_profile_for_scope( + catalog, + &provider.r#type, + &provider.profile_workspace, + ) else { continue; }; insert_dynamic_credentials_for_profile( @@ -1807,7 +1905,7 @@ async fn validate_provider_environment_keys_unique_at( &mut seen_plugin_config, &provider_name, active_provider_environment_keys(store, catalog, &provider, now_ms).await?, - provider_plugin_environment_keys(&provider), + provider_plugin_environment_keys(catalog, &provider), )?; dynamic_bindings.extend(dynamic_token_grant_bindings_for_provider_with_catalog( catalog, &provider, @@ -1840,7 +1938,7 @@ async fn validate_provider_environment_records_unique_at( now_ms, ) .await?, - provider_plugin_environment_keys(provider), + provider_plugin_environment_keys(catalog, provider), )?; dynamic_bindings.extend(dynamic_token_grant_bindings_for_provider_with_catalog( catalog, provider, @@ -1850,12 +1948,33 @@ async fn validate_provider_environment_records_unique_at( Ok(()) } -fn provider_plugin_environment_keys(provider: &Provider) -> Vec { +fn provider_plugin_environment_keys( + catalog: &EffectiveProviderProfileCatalog, + provider: &Provider, +) -> Vec { let mut plugin_environment = HashMap::new(); - openshell_providers::ProviderRegistry::new().inject_env(provider, &mut plugin_environment); + let registry = openshell_providers::ProviderRegistry::new(); + inject_provider_plugin_environment(catalog, provider, ®istry, &mut plugin_environment); plugin_environment.into_keys().collect() } +fn inject_provider_plugin_environment( + catalog: &EffectiveProviderProfileCatalog, + provider: &Provider, + registry: &openshell_providers::ProviderRegistry, + environment: &mut HashMap, +) { + if let Some(profile) = + get_provider_type_profile_for_scope(catalog, &provider.r#type, &provider.profile_workspace) + { + registry.inject_env_for_profile_id(provider, &profile.id, environment); + } else { + // Preserve config projection for legacy records when their profile + // source is temporarily unavailable. + registry.inject_env(provider, environment); + } +} + fn validate_provider_environment_key_ownership( seen_credentials: &mut HashMap, seen_plugin_config: &mut HashMap, @@ -1926,9 +2045,8 @@ fn dynamic_token_grant_bindings_for_provider_with_catalog( provider: &Provider, ) -> Vec { let provider_name = provider.object_name().to_string(); - let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); let Some(profile) = - get_provider_type_profile_for_scope(catalog, profile_id, &provider.profile_workspace) + get_provider_type_profile_for_scope(catalog, &provider.r#type, &provider.profile_workspace) else { return Vec::new(); }; @@ -2141,8 +2259,7 @@ fn broker_only_provider_credential_keys_for_provider( catalog: &EffectiveProviderProfileCatalog, provider: &Provider, ) -> HashSet { - let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); - get_provider_type_profile_for_scope(catalog, profile_id, &provider.profile_workspace) + get_provider_type_profile_for_scope(catalog, &provider.r#type, &provider.profile_workspace) .as_ref() .map(ProviderTypeProfile::to_proto) .map(|profile| broker_only_provider_credential_keys(&profile)) @@ -2373,14 +2490,22 @@ pub(super) async fn handle_create_provider( if let Some(metadata) = provider.metadata.as_mut() { metadata.workspace.clone_from(&workspace); } - let provider_type = provider.r#type.clone(); - if state.credentials.stores_provider_credentials() && !provider.credentials.is_empty() { - state.compute.ensure_workspace(&workspace).await?; + if !provider.credential_handles.is_empty() { + return Err(Status::invalid_argument( + "provider.credential_handles is internal gateway state and cannot be supplied", + )); } + let provider_type = provider.r#type.clone(); + let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; let catalog = state .provider_profile_sources .snapshot_catalog(state.store.as_ref(), &workspace) .await?; + let profile = resolve_provider_create_profile(&catalog, &mut provider)?; + validate_provider_create_credentials(&profile, &provider)?; + if state.credentials.stores_provider_credentials() && !provider.credentials.is_empty() { + state.compute.ensure_workspace(&workspace).await?; + } let result = create_provider_record_validating( state.store.as_ref(), &workspace, @@ -2824,11 +2949,11 @@ pub(super) async fn handle_delete_provider_profile( return Err(Status::not_found("provider profile not found")); } - let blocking_sandboxes = sandboxes_using_profile(state.store.as_ref(), &workspace, &id).await?; - if !blocking_sandboxes.is_empty() { + let blocking_providers = providers_using_profile(state.store.as_ref(), &workspace, &id).await?; + if !blocking_providers.is_empty() { return Err(Status::failed_precondition(format!( - "provider profile '{id}' is in use by sandboxes: {}", - blocking_sandboxes.join(", ") + "provider profile '{id}' is in use by providers: {}", + blocking_providers.join(", ") ))); } @@ -2841,13 +2966,6 @@ pub(super) async fn handle_delete_provider_profile( Ok(Response::new(DeleteProviderProfileResponse { deleted })) } -pub(super) fn get_provider_type_profile_with_catalog( - catalog: &EffectiveProviderProfileCatalog, - id: &str, -) -> Option { - catalog.get_type_profile(id) -} - pub(super) fn get_provider_type_profile_for_scope( catalog: &EffectiveProviderProfileCatalog, id: &str, @@ -2856,6 +2974,33 @@ pub(super) fn get_provider_type_profile_for_scope( catalog.get_type_profile_for_scope(id, profile_workspace) } +pub(super) fn provider_profile_endpoints_are_active( + profile: &ProviderTypeProfile, + provider: &Provider, +) -> bool { + if profile.source != "builtin" { + return true; + } + let Some(inference_profile) = openshell_core::inference::profile_for(&profile.id) else { + return true; + }; + if !matches!(inference_profile.provider_type, "openai" | "anthropic") { + return true; + } + + let configured_base_url = inference_profile + .base_url_config_keys + .iter() + .find_map(|key| provider.config.get(*key)) + .map(|value| value.trim()) + .filter(|value| !value.is_empty()); + configured_base_url.is_none_or(|configured| { + configured + .trim_end_matches('/') + .eq_ignore_ascii_case(inference_profile.default_base_url.trim_end_matches('/')) + }) +} + #[cfg(test)] pub(super) async fn get_provider_type_profile( store: &Store, @@ -3004,13 +3149,112 @@ fn validate_refresh_material( fn provider_type_allows_empty_credentials( catalog: &EffectiveProviderProfileCatalog, provider_type: &str, + profile_workspace: &str, ) -> bool { - let Some(profile) = get_provider_type_profile_with_catalog(catalog, provider_type) else { + let Some(profile) = + get_provider_type_profile_for_scope(catalog, provider_type, profile_workspace) + else { return false; }; profile.allows_empty_provider_credentials() } +fn resolve_provider_create_profile( + catalog: &EffectiveProviderProfileCatalog, + provider: &mut Provider, +) -> Result { + let requested_type = provider.r#type.trim(); + if requested_type.is_empty() { + return Err(Status::invalid_argument("provider.type is required")); + } + let profile = get_provider_type_profile_for_scope( + catalog, + requested_type, + &provider.profile_workspace, + ) + .ok_or_else(|| { + Status::invalid_argument(format!( + "provider profile '{requested_type}' was not found in the requested scope; import a matching profile before creating this provider" + )) + })?; + provider.r#type.clone_from(&profile.id); + Ok(profile) +} + +fn validate_provider_create_credentials( + profile: &ProviderTypeProfile, + provider: &Provider, +) -> Result<(), Status> { + validate_provider_credentials(profile, provider, &HashMap::new()) +} + +fn validate_provider_credentials( + profile: &ProviderTypeProfile, + provider: &Provider, + pending_credentials: &HashMap, +) -> Result<(), Status> { + let declared_keys = profile + .credentials + .iter() + .flat_map(|credential| credential.accepted_stored_keys()) + .collect::>(); + let mut unknown_keys = provider + .credentials + .keys() + .chain(provider.credential_handles.keys()) + .chain(pending_credentials.keys()) + .filter(|key| !declared_keys.contains(key.as_str())) + .cloned() + .collect::>(); + unknown_keys.sort(); + unknown_keys.dedup(); + if !unknown_keys.is_empty() { + return Err(Status::invalid_argument(format!( + "provider credentials are not declared by profile '{}': {}", + profile.id, + unknown_keys.join(", ") + ))); + } + + validate_required_static_credentials(profile, provider, pending_credentials) +} + +fn validate_required_static_credentials( + profile: &ProviderTypeProfile, + provider: &Provider, + pending_credentials: &HashMap, +) -> Result<(), Status> { + let mut missing = Vec::new(); + for credential in profile.required_static_credentials() { + let accepted_keys = credential.accepted_stored_keys(); + let supplied = accepted_keys.iter().any(|key| { + provider + .credentials + .get(*key) + .is_some_and(|value| !value.trim().is_empty()) + || provider.credential_handles.contains_key(*key) + || pending_credentials + .get(*key) + .is_some_and(|value| !value.trim().is_empty()) + }); + if !supplied { + missing.push( + accepted_keys + .first() + .map_or_else(|| credential.name.clone(), |key| (*key).to_string()), + ); + } + } + if !missing.is_empty() { + return Err(Status::invalid_argument(format!( + "provider profile '{}' requires static credentials: {}", + profile.id, + missing.join(", ") + ))); + } + Ok(()) +} + fn normalize_profile_id_request(id: &str) -> Result { if id.trim().is_empty() { return Err(Status::invalid_argument("id is required")); @@ -3261,8 +3505,15 @@ async fn profile_attached_sandbox_diagnostics( else { continue; }; - let profile_id = - normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); + let requested_profile_id = normalize_profile_id(&provider.r#type) + .unwrap_or_else(|| provider.r#type.trim().to_string()); + let profile_id = if candidate_profiles.contains_key(&requested_profile_id) { + requested_profile_id + } else { + normalize_provider_type(&provider.r#type) + .filter(|alias| candidate_profiles.contains_key(*alias)) + .map_or(requested_profile_id, str::to_string) + }; let scope_mismatch = (is_platform_scope && !provider.profile_workspace.is_empty()) || (!is_platform_scope && provider.profile_workspace.is_empty()); if scope_mismatch { @@ -3272,7 +3523,7 @@ async fn profile_attached_sandbox_diagnostics( if validate_policy_composition && let Some(profile) = get_provider_type_profile_for_scope( catalog, - profile_id, + &provider.r#type, &provider.profile_workspace, ) { @@ -3284,7 +3535,7 @@ async fn profile_attached_sandbox_diagnostics( } continue; } - if let Some((source, profile)) = candidate_profiles.get(profile_id) { + if let Some((source, profile)) = candidate_profiles.get(&profile_id) { let has_static_credentials = provider .credentials .keys() @@ -3300,7 +3551,7 @@ async fn profile_attached_sandbox_diagnostics( if has_static_credentials && !has_usable_endpoint && !has_policy_binding { diagnostics.push(ProfileValidationDiagnostic { source: source.clone(), - profile_id: profile_id.to_string(), + profile_id: profile_id.clone(), field: "endpoints".to_string(), message: format!( "{operation} would leave static provider credentials without an authorized endpoint on sandbox '{sandbox_name}'" @@ -3311,7 +3562,7 @@ async fn profile_attached_sandbox_diagnostics( if has_usable_endpoint && has_policy_binding { diagnostics.push(ProfileValidationDiagnostic { source: source.clone(), - profile_id: profile_id.to_string(), + profile_id: profile_id.clone(), field: "endpoints".to_string(), message: format!( "{operation} would give provider '{provider_name}' both profile endpoint bindings and sandbox policy credential bindings on sandbox '{sandbox_name}'" @@ -3330,7 +3581,7 @@ async fn profile_attached_sandbox_diagnostics( rule_name, }); } - let used = (source.clone(), profile_id.to_string()); + let used = (source.clone(), profile_id.clone()); if !imported_profiles_used.contains(&used) { imported_profiles_used.push(used); } @@ -3341,7 +3592,7 @@ async fn profile_attached_sandbox_diagnostics( if validate_policy_composition && let Some(profile) = get_provider_type_profile_for_scope( catalog, - profile_id, + &provider.r#type, &provider.profile_workspace, ) { @@ -3437,72 +3688,6 @@ fn has_errors(diagnostics: &[ProfileValidationDiagnostic]) -> bool { .any(|diagnostic| diagnostic.severity == "error") } -async fn sandboxes_using_profile( - store: &Store, - workspace: &str, - profile_id: &str, -) -> Result, Status> { - let is_platform_scope = workspace.is_empty(); - - let candidates = if is_platform_scope { - scan_sandboxes_all(store, |sandbox| { - let has_providers = sandbox - .spec - .as_ref() - .is_some_and(|s| !s.providers.is_empty()); - has_providers.then_some(sandbox) - }) - .await? - } else { - scan_sandboxes(store, workspace, |sandbox| { - let has_providers = sandbox - .spec - .as_ref() - .is_some_and(|s| !s.providers.is_empty()); - has_providers.then_some(sandbox) - }) - .await? - }; - - let mut blocking = Vec::new(); - for sandbox in candidates { - let sandbox_workspace = sandbox.object_workspace().to_string(); - let spec = sandbox.spec.as_ref().expect("filtered by scan_sandboxes"); - for provider_name in &spec.providers { - let provider_ws = if is_platform_scope { - &sandbox_workspace - } else { - workspace - }; - let Some(provider) = store - .get_message_by_name::(provider_ws, provider_name) - .await - .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? - else { - continue; - }; - if is_platform_scope && !provider.profile_workspace.is_empty() { - continue; - } - if !is_platform_scope && provider.profile_workspace.is_empty() { - continue; - } - if normalize_profile_id(&provider.r#type).as_deref() == Some(profile_id) { - let label = if is_platform_scope { - format!("{}/{}", sandbox_workspace, sandbox.object_name()) - } else { - sandbox.object_name().to_string() - }; - blocking.push(label); - break; - } - } - } - blocking.sort(); - blocking.dedup(); - Ok(blocking) -} - pub(super) async fn handle_update_provider( state: &Arc, request: Request, @@ -3615,14 +3800,16 @@ pub(super) async fn handle_exchange_provider_subject_token( .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| Status::not_found("provider not found"))?; - let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); let catalog = state .provider_profile_sources .snapshot_catalog(state.store.as_ref(), &workspace) .await?; - let profile = - get_provider_type_profile_for_scope(&catalog, profile_id, &provider.profile_workspace) - .ok_or_else(|| Status::failed_precondition("provider profile not found"))?; + let profile = get_provider_type_profile_for_scope( + &catalog, + &provider.r#type, + &provider.profile_workspace, + ) + .ok_or_else(|| Status::failed_precondition("provider profile not found"))?; let profile_proto = profile.to_proto(); let credential = profile_proto .credentials @@ -4126,18 +4313,6 @@ pub(super) async fn handle_configure_provider_refresh( crate::provider_refresh::refresh_strategy_name(strategy as i32) ))); } - if strategy == ProviderCredentialRefreshStrategy::AwsStsAssumeRole { - let global_settings = - crate::grpc::policy::load_global_settings(state.store.as_ref()).await?; - if !crate::grpc::policy::bool_setting_enabled( - &global_settings, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - )? { - return Err(Status::failed_precondition( - "aws_sts_assume_role requires providers_v2_enabled=true", - )); - } - } if request.material.len() > MAX_PROVIDER_CONFIG_ENTRIES { return Err(Status::invalid_argument(format!( "material exceeds maximum entries ({} > {MAX_PROVIDER_CONFIG_ENTRIES})", @@ -4749,14 +4924,15 @@ mod tests { use crate::grpc::{MAX_MAP_KEY_LEN, MAX_PROVIDER_TYPE_LEN}; use crate::persistence::test_store; use openshell_core::proto::{ - ConfigureProviderRefreshRequest, CreateProviderRequest, CreateWorkspaceRequest, - DeleteProviderProfileRequest, DeleteProviderRefreshRequest, DeleteProviderRequest, - GetProviderProfileRequest, GetProviderRefreshStatusRequest, GetProviderRequest, - ImportProviderProfilesRequest, L7Allow, L7Rule, LintProviderProfilesRequest, - ListProviderProfilesRequest, ListProvidersRequest, NetworkBinary, NetworkEndpoint, - NetworkPolicyRule, ProviderCredentialRefresh, ProviderCredentialRefreshMaterial, - ProviderCredentialRefreshRecoveryAction, ProviderCredentialTokenGrant, - ProviderCredentialTokenGrantAudienceOverride, ProviderProfile, ProviderProfileCategory, + AttachSandboxProviderRequest, ConfigureProviderRefreshRequest, CreateProviderRequest, + CreateWorkspaceRequest, DeleteProviderProfileRequest, DeleteProviderRefreshRequest, + DeleteProviderRequest, GetProviderProfileRequest, GetProviderRefreshStatusRequest, + GetProviderRequest, ImportProviderProfilesRequest, L7Allow, L7Rule, + LintProviderProfilesRequest, ListProviderProfilesRequest, ListProvidersRequest, + NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProviderCredentialRefresh, + ProviderCredentialRefreshMaterial, ProviderCredentialTokenGrant, + ProviderCredentialTokenGrantAudienceOverride, ProviderCredentialTokenGrantSubjectToken, + ProviderCredentialTokenGrantType, ProviderProfile, ProviderProfileCategory, ProviderProfileCredential, ProviderProfileImportItem, RotateProviderCredentialRequest, Sandbox, SandboxPolicy, SandboxSpec, StoredProviderProfile, UpdateProviderProfilesRequest, UpdateProviderRequest, @@ -4781,6 +4957,40 @@ mod tests { assert!(!is_valid_env_key("X;rm -rf /")); } + #[test] + fn create_validation_accepts_broker_only_credential_by_logical_name() { + let profile = ProviderTypeProfile::from_proto(&ProviderProfile { + id: "token-exchange".to_string(), + credentials: vec![ + ProviderProfileCredential { + name: "subject_token".to_string(), + required: true, + ..Default::default() + }, + ProviderProfileCredential { + name: "access_token".to_string(), + token_grant: Some(ProviderCredentialTokenGrant { + grant_type: ProviderCredentialTokenGrantType::TokenExchange as i32, + subject_token: Some(ProviderCredentialTokenGrantSubjectToken { + source: "provider_credential".to_string(), + credential: "subject_token".to_string(), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }, + ], + ..Default::default() + }); + let provider = Provider { + credentials: HashMap::from([("subject_token".to_string(), "test-token".to_string())]), + ..Default::default() + }; + + validate_provider_create_credentials(&profile, &provider).unwrap(); + } + #[test] fn telemetry_provider_profile_maps_unknown_to_custom() { assert_eq!( @@ -4797,11 +5007,11 @@ mod tests { ); assert_eq!( telemetry_provider_profile("glab"), - TelemetryProviderProfile::Gitlab + TelemetryProviderProfile::Custom ); assert_eq!( telemetry_provider_profile("outlook"), - TelemetryProviderProfile::Outlook + TelemetryProviderProfile::Custom ); assert_eq!( telemetry_provider_profile("generic"), @@ -5657,6 +5867,7 @@ mod tests { assert_eq!( ids, vec![ + "anthropic", "aws", "aws-bedrock", "aws-s3", @@ -5669,6 +5880,7 @@ mod tests { "google-cloud", "google-vertex-ai", "nvidia", + "openai", "pypi" ] ); @@ -5773,20 +5985,6 @@ mod tests { #[tokio::test] async fn profile_update_rejects_fanout_endpoint_ambiguity_without_persisting() { let state = test_server_state().await; - crate::grpc::policy::save_global_settings( - state.store.as_ref(), - &crate::grpc::StoredSettings { - revision: 1, - settings: std::iter::once(( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - crate::grpc::StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }, - ) - .await - .unwrap(); let mut initial_profile = custom_profile("fanout-ambiguity"); initial_profile.endpoints.push(NetworkEndpoint { @@ -6295,7 +6493,7 @@ mod tests { } #[tokio::test] - async fn delete_provider_profile_rejects_builtin_and_in_use_custom_profiles() { + async fn delete_provider_profile_rejects_builtin_and_provider_referenced_profiles() { let state = test_server_state().await; handle_import_provider_profiles( &state, @@ -6333,7 +6531,7 @@ mod tests { .put_message(&Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sandbox-id".to_string(), - name: "sandbox-using-custom".to_string(), + name: "sandbox-custom".to_string(), created_at_ms: 0, labels: HashMap::new(), resource_version: 0, @@ -6342,7 +6540,7 @@ mod tests { deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { - providers: vec!["custom-provider".to_string()], + providers: Vec::new(), ..Default::default() }), ..Default::default() @@ -6360,15 +6558,65 @@ mod tests { .await .unwrap_err(); assert_eq!(in_use_err.code(), Code::FailedPrecondition); - assert!(in_use_err.message().contains("sandbox-using-custom")); + assert!(in_use_err.message().contains("custom-provider")); + + let attached = super::super::sandbox::handle_attach_sandbox_provider( + &state, + authed_request(AttachSandboxProviderRequest { + sandbox_name: "sandbox-custom".to_string(), + provider_name: "custom-provider".to_string(), + expected_resource_version: 0, + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(attached.attached); } #[tokio::test] - async fn configure_provider_refresh_stores_scoped_status_and_provider_expiry() { + async fn delete_global_provider_profile_checks_providers_in_every_workspace() { let state = test_server_state().await; - import_test_graph_refresh_profile(&state).await; - create_provider_record( - state.store.as_ref(), + state + .store + .put_message(&stored_provider_profile_for_workspace( + custom_profile("global-custom"), + "", + )) + .await + .unwrap(); + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), "default") + .await + .unwrap(); + let mut provider = provider_with_values("global-provider", "global-custom"); + provider.profile_workspace = String::new(); + create_provider_record_with_catalog(state.store.as_ref(), &catalog, "default", provider) + .await + .unwrap(); + + let err = handle_delete_provider_profile( + &state, + authed_request(DeleteProviderProfileRequest { + id: "global-custom".to_string(), + workspace: String::new(), + }), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(err.message().contains("default/global-provider")); + } + + #[tokio::test] + async fn configure_provider_refresh_stores_scoped_status_and_provider_expiry() { + let state = test_server_state().await; + import_test_graph_refresh_profile(&state).await; + create_provider_record( + state.store.as_ref(), "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { @@ -7105,17 +7353,7 @@ mod tests { #[tokio::test] async fn delete_aws_sts_refresh_clears_all_pinned_output_expiries() { - use crate::grpc::policy::set_global_bool_setting_for_test; - let state = test_server_state().await; - set_global_bool_setting_for_test( - state.store.as_ref(), - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); - create_provider_record( state.store.as_ref(), "default", @@ -7705,6 +7943,57 @@ mod tests { assert!(response.deleted); } + #[tokio::test] + async fn create_provider_waits_for_provider_profile_delete_guard() { + let state = test_server_state().await; + handle_import_provider_profiles( + &state, + authed_request(ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(custom_profile("guarded-create")), + source: "guarded-create.yaml".to_string(), + }], + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + let guard = state.compute.sandbox_sync_guard().await; + let task_state = state.clone(); + let task = tokio::spawn(async move { + let mut provider = provider_with_values("guarded-provider", "guarded-create"); + provider.credentials.clear(); + provider.config.clear(); + handle_create_provider( + &task_state, + authed_request(CreateProviderRequest { + provider: Some(provider), + workspace: "default".to_string(), + }), + ) + .await + }); + + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + assert!( + !task.is_finished(), + "provider create should wait for provider profile mutation guard" + ); + drop(guard); + + let response = tokio::time::timeout(std::time::Duration::from_secs(5), task) + .await + .expect("create should finish after guard release") + .expect("join create task") + .expect("create should succeed") + .into_inner(); + assert_eq!( + response.provider.expect("provider").object_name(), + "guarded-provider" + ); + } + #[tokio::test] async fn provider_crud_round_trip_and_semantics() { let store = test_store().await; @@ -7969,7 +8258,7 @@ mod tests { create_provider_record( &store, "default", - provider_with_values("legacy-provider", "openai"), + provider_with_values("legacy-provider", "legacy-custom"), ) .await .unwrap(); @@ -8034,7 +8323,7 @@ mod tests { create_provider_record( &store, "default", - provider_with_values("legacy-provider", "openai"), + provider_with_values("legacy-provider", "legacy-custom"), ) .await .unwrap(); @@ -8124,6 +8413,165 @@ mod tests { assert!(err.message().contains("internal gateway state")); } + #[tokio::test] + async fn handle_create_provider_rejects_profileless_type() { + let state = test_server_state().await; + let err = handle_create_provider( + &state, + authed_request(CreateProviderRequest { + provider: Some(provider_with_values("legacy-gitlab", "gitlab")), + workspace: "default".to_string(), + }), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::InvalidArgument); + assert!( + err.message() + .contains("provider profile 'gitlab' was not found") + ); + } + + #[tokio::test] + async fn handle_create_provider_allows_credentialless_policy_profile() { + let state = test_server_state().await; + let response = handle_create_provider( + &state, + authed_request(CreateProviderRequest { + provider: Some(Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + name: "pypi".to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + r#type: "pypi".to_string(), + profile_workspace: "default".to_string(), + ..Default::default() + }), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner() + .provider + .expect("provider"); + + assert_eq!(response.r#type, "pypi"); + assert!(response.credentials.is_empty()); + } + + #[tokio::test] + async fn handle_create_provider_allows_retired_type_with_imported_profile() { + let state = test_server_state().await; + handle_import_provider_profiles( + &state, + authed_request(ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(custom_profile("gitlab")), + source: "custom-gitlab.yaml".to_string(), + }], + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + let response = handle_create_provider( + &state, + authed_request(CreateProviderRequest { + provider: Some(Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + name: "private-gitlab".to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + r#type: "gitlab".to_string(), + profile_workspace: "default".to_string(), + ..Default::default() + }), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner() + .provider + .expect("provider"); + + assert_eq!(response.r#type, "gitlab"); + } + + #[tokio::test] + async fn handle_create_provider_prefers_exact_imported_alias_profile() { + let mut state = test_server_state().await; + let config = state + .config + .clone() + .with_credential_drivers(["test-static"]); + let credentials = crate::credentials::CredentialRuntime::from_config(&config).unwrap(); + let state_mut = Arc::get_mut(&mut state).unwrap(); + state_mut.config = config; + state_mut.credentials = credentials; + + let mut profile = custom_profile("gh"); + profile.credentials = vec![static_credential("token", "GITHUB_TOKEN", true)]; + profile.endpoints = vec![NetworkEndpoint { + host: "github.enterprise.example".to_string(), + port: 443, + allow_uninspected_credentials: true, + ..Default::default() + }]; + let imported = handle_import_provider_profiles( + &state, + authed_request(ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(profile), + source: "enterprise-github.yaml".to_string(), + }], + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(imported.imported, "diagnostics: {:?}", imported.diagnostics); + + let provider = handle_create_provider( + &state, + authed_request(CreateProviderRequest { + provider: Some(provider_with_credential_value( + "enterprise-github", + "gh", + "GITHUB_TOKEN", + "test-token", + )), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner() + .provider + .expect("provider"); + + assert_eq!(provider.r#type, "gh"); + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), "default") + .await + .unwrap(); + let resolved = get_provider_type_profile_for_scope( + &catalog, + &provider.r#type, + &provider.profile_workspace, + ) + .expect("exact imported profile"); + assert_eq!(resolved.id, "gh"); + assert_eq!(resolved.endpoints[0].host, "github.enterprise.example"); + } + #[tokio::test] async fn handle_create_provider_stores_inline_credentials_with_enabled_driver() { let mut state = test_server_state().await; @@ -8188,6 +8636,94 @@ mod tests { assert_eq!(result.get("OPENAI_API_KEY"), Some(&"sk-test".to_string())); } + #[tokio::test] + async fn handle_create_provider_accepts_broker_only_subject_token() { + let mut state = test_server_state().await; + let config = state + .config + .clone() + .with_credential_drivers(["test-static"]); + let credentials = crate::credentials::CredentialRuntime::from_config(&config).unwrap(); + let state_mut = Arc::get_mut(&mut state).unwrap(); + state_mut.config = config; + state_mut.credentials = credentials; + + let mut profile = custom_profile("spiffe-token-exchange-demo"); + profile.credentials = vec![ + ProviderProfileCredential { + name: "subject_token".to_string(), + description: "Broker-only subject token".to_string(), + required: true, + ..Default::default() + }, + ProviderProfileCredential { + name: "access_token".to_string(), + required: false, + auth_style: "bearer".to_string(), + header_name: "Authorization".to_string(), + token_grant: Some(ProviderCredentialTokenGrant { + grant_type: ProviderCredentialTokenGrantType::TokenExchange as i32, + token_endpoint: "https://issuer.example.com/token".to_string(), + jwt_svid_audience: "https://issuer.example.com".to_string(), + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + .to_string(), + subject_token: Some(ProviderCredentialTokenGrantSubjectToken { + source: "provider_credential".to_string(), + credential: "subject_token".to_string(), + subject_token_type: "urn:ietf:params:oauth:token-type:access_token" + .to_string(), + }), + ..Default::default() + }), + ..Default::default() + }, + ]; + profile.endpoints = vec![NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + allow_uninspected_credentials: true, + ..Default::default() + }]; + let imported = handle_import_provider_profiles( + &state, + authed_request(ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(profile), + source: "provider-profile.yaml".to_string(), + }], + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(imported.imported, "diagnostics: {:?}", imported.diagnostics); + + handle_create_provider( + &state, + authed_request(CreateProviderRequest { + provider: Some(provider_with_credential_value( + "exchange", + "spiffe-token-exchange-demo", + "subject_token", + "test-token", + )), + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + let stored: Provider = state + .store + .get_message_by_name("default", "exchange") + .await + .unwrap() + .unwrap(); + assert!(stored.credentials.is_empty()); + assert!(stored.credential_handles.contains_key("subject_token")); + } + #[tokio::test] async fn handle_update_provider_rejects_user_supplied_credential_handles() { let state = test_server_state().await; @@ -8316,7 +8852,7 @@ mod tests { let store = test_store().await; // Create provider and verify resource_version: 1 in response - let created = provider_with_values("test-provider", "openai"); + let created = provider_with_values("test-provider", "legacy-custom"); let persisted = create_provider_record(&store, "default", created) .await .unwrap(); @@ -8341,7 +8877,7 @@ mod tests { workspace: "default".to_string(), deletion_timestamp_ms: 0, }), - r#type: "openai".to_string(), + r#type: "legacy-custom".to_string(), credentials: std::iter::once(( "OPENAI_API_KEY".to_string(), "updated-key".to_string(), @@ -8376,7 +8912,7 @@ mod tests { workspace: "default".to_string(), deletion_timestamp_ms: 0, }), - r#type: "openai".to_string(), + r#type: "legacy-custom".to_string(), credentials: std::iter::once(( "OPENAI_API_KEY".to_string(), "third-key".to_string(), @@ -8690,7 +9226,7 @@ mod tests { async fn update_provider_empty_maps_is_noop() { let store = test_store().await; - let created = provider_with_values("noop-test", "nvidia"); + let created = provider_with_values("noop-test", "legacy-custom"); let persisted = create_provider_record(&store, "default", created) .await .unwrap(); @@ -8721,7 +9257,7 @@ mod tests { .unwrap(); assert_eq!(updated.object_id(), persisted.object_id()); - assert_eq!(updated.r#type, "nvidia"); + assert_eq!(updated.r#type, "legacy-custom"); assert_eq!(updated.credentials.len(), 2); assert_eq!( updated.credentials.get("API_TOKEN"), @@ -8745,7 +9281,7 @@ mod tests { async fn update_provider_empty_value_deletes_key() { let store = test_store().await; - let created = provider_with_values("delete-key-test", "openai"); + let created = provider_with_values("delete-key-test", "legacy-custom"); create_provider_record(&store, "default", created) .await .unwrap(); @@ -8804,7 +9340,7 @@ mod tests { async fn update_provider_empty_type_preserves_existing() { let store = test_store().await; - let created = provider_with_values("type-preserve-test", "anthropic"); + let created = provider_with_values("type-preserve-test", "legacy-custom"); create_provider_record(&store, "default", created) .await .unwrap(); @@ -8834,7 +9370,7 @@ mod tests { .await .unwrap(); - assert_eq!(updated.r#type, "anthropic"); + assert_eq!(updated.r#type, "legacy-custom"); } #[tokio::test] @@ -9487,10 +10023,155 @@ mod tests { } #[tokio::test] - async fn resolve_provider_env_binds_endpointless_profile_from_policy() { + async fn resolve_provider_env_withholds_undeclared_legacy_credentials() { let store = test_store().await; - let mut google_cloud = google_cloud_provider(HashMap::from([( - "project_id".to_string(), + let mut legacy = provider_with_values("legacy-openai", "openai"); + legacy.credentials = HashMap::from([ + ("OPENAI_API_KEY".to_string(), "openai-key".to_string()), + ( + "AWS_SECRET_ACCESS_KEY".to_string(), + "unrelated-secret".to_string(), + ), + ]); + create_provider_record(&store, "default", legacy) + .await + .unwrap(); + + let result = + resolve_provider_environment(&store, "default", &["legacy-openai".to_string()]) + .await + .unwrap(); + + assert_eq!( + result.get("OPENAI_API_KEY"), + Some(&"openai-key".to_string()) + ); + assert!( + result + .static_credential_bindings + .contains_key("OPENAI_API_KEY") + ); + assert!(!result.contains_key("AWS_SECRET_ACCESS_KEY")); + assert!( + !result + .static_credential_bindings + .contains_key("AWS_SECRET_ACCESS_KEY") + ); + } + + #[tokio::test] + async fn resolve_provider_env_withholds_undeclared_legacy_credential_handles() { + let store = test_store().await; + let config = openshell_core::Config::new(None).with_credential_drivers(["test-static"]); + let credentials = crate::credentials::CredentialRuntime::from_config(&config).unwrap(); + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(&store, "default") + .await + .unwrap(); + create_provider_record_validating( + &store, + "default", + &catalog, + provider_with_credential_value( + "legacy-openai-handle", + "openai", + "AWS_SECRET_ACCESS_KEY", + "unrelated-secret", + ), + Some(&credentials), + ) + .await + .unwrap(); + + let records = load_provider_environment_records( + &store, + "default", + &["legacy-openai-handle".to_string()], + ) + .await + .unwrap(); + assert!(records[0].provider.credentials.is_empty()); + assert!( + records[0] + .provider + .credential_handles + .contains_key("AWS_SECRET_ACCESS_KEY") + ); + + let result = + resolve_provider_environment_from_records_with_policy_bindings_and_credentials( + &store, + &catalog, + &records, + &HashMap::new(), + &credentials, + None, + ) + .await + .unwrap(); + assert!(!result.contains_key("AWS_SECRET_ACCESS_KEY")); + assert!( + !result + .static_credential_bindings + .contains_key("AWS_SECRET_ACCESS_KEY") + ); + } + + #[tokio::test] + async fn resolve_provider_env_does_not_bind_alternate_upstream_keys_to_public_vendors() { + let store = test_store().await; + let cases = [ + ( + "alternate-openai", + "openai", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + ), + ( + "alternate-anthropic", + "anthropic", + "ANTHROPIC_API_KEY", + "ANTHROPIC_BASE_URL", + ), + ]; + for (name, provider_type, credential_key, base_url_key) in cases { + let mut provider = + provider_with_credential_value(name, provider_type, credential_key, "private-key"); + provider.config.insert( + base_url_key.to_string(), + "https://api.example.com/v1".to_string(), + ); + create_provider_record(&store, "default", provider) + .await + .unwrap(); + } + + let result = resolve_provider_environment( + &store, + "default", + &[ + "alternate-openai".to_string(), + "alternate-anthropic".to_string(), + ], + ) + .await + .unwrap(); + + for credential_key in ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] { + assert!(!result.contains_key(credential_key)); + assert!( + !result + .static_credential_bindings + .contains_key(credential_key) + ); + } + } + + #[tokio::test] + async fn resolve_provider_env_binds_endpointless_profile_from_policy() { + let store = test_store().await; + let mut google_cloud = google_cloud_provider(HashMap::from([( + "project_id".to_string(), "sandbox-project".to_string(), )])); google_cloud.credentials = HashMap::from([( @@ -10350,10 +11031,11 @@ mod tests { .await .unwrap(); - // Credential value wins over the injected static value. + // Profile-authoritative runtime projection withholds the undeclared + // credential, so the provider plugin's declared config wins. assert_eq!( result.get("GOOSE_PROVIDER"), - Some(&"custom-value".to_string()) + Some(&"gcp_vertex_ai".to_string()) ); } @@ -10889,12 +11571,104 @@ mod tests { // ---- CAS (Client-driven optimistic concurrency) tests for UpdateProvider ---- + #[tokio::test] + async fn update_provider_rejects_undeclared_profile_credential() { + let state = test_server_state().await; + handle_create_provider( + &state, + authed_request(CreateProviderRequest { + provider: Some(provider_with_credential_value( + "profile-backed-openai", + "openai", + "OPENAI_API_KEY", + "sk-test", + )), + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + let current = state + .store + .get_message_by_name::("default", "profile-backed-openai") + .await + .unwrap() + .unwrap(); + let mut update = current; + update.credential_handles.clear(); + update.credentials.insert( + "AWS_SECRET_ACCESS_KEY".to_string(), + "unrelated-secret".to_string(), + ); + + let error = handle_update_provider( + &state, + authed_request(UpdateProviderRequest { + provider: Some(update), + credential_expires_at_ms: HashMap::new(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), Code::InvalidArgument); + assert!(error.message().contains("AWS_SECRET_ACCESS_KEY")); + } + + #[tokio::test] + async fn update_provider_rejects_deleting_required_profile_credential() { + let state = test_server_state().await; + handle_create_provider( + &state, + authed_request(CreateProviderRequest { + provider: Some(provider_with_credential_value( + "required-openai", + "openai", + "OPENAI_API_KEY", + "sk-test", + )), + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + let current = state + .store + .get_message_by_name::("default", "required-openai") + .await + .unwrap() + .unwrap(); + let mut update = current; + update.credential_handles.clear(); + update + .credentials + .insert("OPENAI_API_KEY".to_string(), String::new()); + + let error = handle_update_provider( + &state, + authed_request(UpdateProviderRequest { + provider: Some(update), + credential_expires_at_ms: HashMap::new(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), Code::InvalidArgument); + assert!(error.message().contains("requires static credentials")); + } + #[tokio::test] async fn update_provider_client_driven_cas_succeeds_with_correct_version() { let state = test_server_state().await; // Create a provider - let mut provider = provider_with_values("test-provider", "generic"); + let mut provider = + provider_with_credential_value("test-provider", "openai", "OPENAI_API_KEY", "sk-test"); provider.metadata.as_mut().unwrap().id = String::new(); handle_create_provider( &state, @@ -10919,8 +11693,8 @@ mod tests { let mut updated_provider = current.clone(); updated_provider.credential_handles.clear(); updated_provider - .credentials - .insert("NEW_KEY".to_string(), "new-value".to_string()); + .config + .insert("NEW_CONFIG".to_string(), "new-value".to_string()); updated_provider.metadata.as_mut().unwrap().resource_version = current_version; // Update should succeed @@ -10951,13 +11725,7 @@ mod tests { .resource_version, current_version + 1 ); - assert!( - response - .provider - .unwrap() - .credentials - .contains_key("NEW_KEY") - ); + assert!(response.provider.unwrap().config.contains_key("NEW_CONFIG")); } #[tokio::test] @@ -10965,7 +11733,8 @@ mod tests { let state = test_server_state().await; // Create a provider - let mut provider = provider_with_values("test-provider", "generic"); + let mut provider = + provider_with_credential_value("test-provider", "openai", "OPENAI_API_KEY", "sk-test"); provider.metadata.as_mut().unwrap().id = String::new(); handle_create_provider( &state, @@ -10990,8 +11759,8 @@ mod tests { let mut stale_provider = current.clone(); stale_provider.credential_handles.clear(); stale_provider - .credentials - .insert("NEW_KEY".to_string(), "new-value".to_string()); + .config + .insert("NEW_CONFIG".to_string(), "new-value".to_string()); stale_provider.metadata.as_mut().unwrap().resource_version = 99; // stale version // Update should fail with ABORTED @@ -11025,8 +11794,7 @@ mod tests { unchanged.metadata.as_ref().unwrap().resource_version, current_version ); - assert!(!unchanged.credentials.contains_key("NEW_KEY")); - assert!(!unchanged.credential_handles.contains_key("NEW_KEY")); + assert!(!unchanged.config.contains_key("NEW_CONFIG")); } #[tokio::test] @@ -11107,7 +11875,8 @@ mod tests { let state = Arc::new(test_server_state().await); // Create a provider - let mut provider = provider_with_values("test-provider", "generic"); + let mut provider = + provider_with_credential_value("test-provider", "openai", "OPENAI_API_KEY", "sk-test"); provider.metadata.as_mut().unwrap().id = String::new(); handle_create_provider( &state, @@ -11135,7 +11904,7 @@ mod tests { let mut updated = initial.clone(); updated.credential_handles.clear(); updated - .credentials + .config .insert(format!("KEY_{i}"), format!("value-{i}")); updated.metadata.as_mut().unwrap().resource_version = initial_version; @@ -11187,92 +11956,17 @@ mod tests { initial_version + 1 ); - // Exactly one of KEY_0, KEY_1, or KEY_2 should be present + // Exactly one of KEY_0, KEY_1, or KEY_2 should be present. let new_keys_count = (0..3) - .filter(|i| { - final_provider - .credential_handles - .contains_key(&format!("KEY_{i}")) - }) + .filter(|i| final_provider.config.contains_key(&format!("KEY_{i}"))) .count(); assert_eq!(new_keys_count, 1); } #[tokio::test] - async fn configure_aws_sts_requires_v2_enabled() { - let state = test_server_state().await; - create_provider_record( - state.store.as_ref(), - "default", - Provider { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: String::new(), - name: "my-aws".to_string(), - created_at_ms: 0, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: "default".to_string(), - deletion_timestamp_ms: 0, - }), - r#type: "aws".to_string(), - credentials: std::iter::once(( - "AWS_ACCESS_KEY_ID".to_string(), - "placeholder".to_string(), - )) - .collect(), - config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), - profile_workspace: "default".to_string(), - credential_handles: HashMap::new(), - }, - ) - .await - .unwrap(); - - let err = handle_configure_provider_refresh( - &state, - authed_request(ConfigureProviderRefreshRequest { - provider: "my-aws".to_string(), - credential_key: "AWS_ACCESS_KEY_ID".to_string(), - strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, - material: HashMap::from([( - "role_arn".to_string(), - "arn:aws:iam::123456789012:role/Test".to_string(), - )]), - secret_material_keys: Vec::new(), - expires_at_ms: None, - workspace: "default".to_string(), - }), - ) - .await - .unwrap_err(); - - assert_eq!(err.code(), Code::FailedPrecondition); - assert!(err.message().contains("providers_v2_enabled")); - } - - #[tokio::test] - async fn configure_aws_sts_succeeds_with_v2_enabled() { - use crate::grpc::StoredSettingValue; - use crate::grpc::StoredSettings; - use crate::grpc::policy::save_global_settings; - + async fn configure_aws_sts_succeeds_without_feature_gate() { let state = test_server_state().await; - let global_settings = StoredSettings { - revision: 1, - settings: std::iter::once(( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }; - save_global_settings(state.store.as_ref(), &global_settings) - .await - .unwrap(); - create_provider_record( state.store.as_ref(), "default", @@ -11332,24 +12026,7 @@ mod tests { #[tokio::test] async fn configure_aws_sts_rejects_endpoint_override_material() { - use crate::grpc::StoredSettingValue; - use crate::grpc::StoredSettings; - use crate::grpc::policy::save_global_settings; - let state = test_server_state().await; - let global_settings = StoredSettings { - revision: 1, - settings: std::iter::once(( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }; - save_global_settings(state.store.as_ref(), &global_settings) - .await - .unwrap(); - create_provider_record( state.store.as_ref(), "default", @@ -11424,24 +12101,7 @@ mod tests { #[tokio::test] async fn configure_aws_sts_rejects_partial_source_credentials() { - use crate::grpc::StoredSettingValue; - use crate::grpc::StoredSettings; - use crate::grpc::policy::save_global_settings; - let state = test_server_state().await; - let global_settings = StoredSettings { - revision: 1, - settings: std::iter::once(( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }; - save_global_settings(state.store.as_ref(), &global_settings) - .await - .unwrap(); - create_provider_record( state.store.as_ref(), "default", @@ -11496,17 +12156,7 @@ mod tests { #[tokio::test] async fn configure_aws_sts_rejects_session_token_without_pair() { - use crate::grpc::policy::set_global_bool_setting_for_test; - let state = test_server_state().await; - set_global_bool_setting_for_test( - state.store.as_ref(), - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); - create_provider_record( state.store.as_ref(), "default", @@ -11562,24 +12212,7 @@ mod tests { #[tokio::test] async fn configure_aws_sts_persists_resolved_additional_output_keys() { - use crate::grpc::StoredSettingValue; - use crate::grpc::StoredSettings; - use crate::grpc::policy::save_global_settings; - let state = test_server_state().await; - let global_settings = StoredSettings { - revision: 1, - settings: std::iter::once(( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }; - save_global_settings(state.store.as_ref(), &global_settings) - .await - .unwrap(); - create_provider_record( state.store.as_ref(), "default", @@ -11651,17 +12284,7 @@ mod tests { #[tokio::test] async fn update_provider_rejects_gateway_refresh_primary_and_additional_output_keys() { - use crate::grpc::policy::set_global_bool_setting_for_test; - let state = test_server_state().await; - set_global_bool_setting_for_test( - state.store.as_ref(), - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); - let original_credentials = HashMap::from([ ( "AWS_ACCESS_KEY_ID".to_string(), @@ -11751,24 +12374,7 @@ mod tests { #[tokio::test] async fn configure_aws_sts_requires_profile_declaring_the_refresh() { - use crate::grpc::StoredSettingValue; - use crate::grpc::StoredSettings; - use crate::grpc::policy::save_global_settings; - let state = test_server_state().await; - let global_settings = StoredSettings { - revision: 1, - settings: std::iter::once(( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }; - save_global_settings(state.store.as_ref(), &global_settings) - .await - .unwrap(); - // A generic provider has no profile, so it declares no STS refresh // binding. STS must not be configurable against it. create_provider_record( @@ -11824,24 +12430,7 @@ mod tests { #[tokio::test] async fn configure_aws_sts_rejects_non_canonical_credential_key() { - use crate::grpc::StoredSettingValue; - use crate::grpc::StoredSettings; - use crate::grpc::policy::save_global_settings; - let state = test_server_state().await; - let global_settings = StoredSettings { - revision: 1, - settings: std::iter::once(( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }; - save_global_settings(state.store.as_ref(), &global_settings) - .await - .unwrap(); - create_provider_record( state.store.as_ref(), "default", @@ -11891,116 +12480,6 @@ mod tests { assert!(err.message().contains("AWS_ACCESS_KEY_ID")); } - #[tokio::test] - async fn rotate_aws_sts_blocked_after_providers_v2_disabled() { - use crate::grpc::policy::set_global_bool_setting_for_test; - - let state = test_server_state().await; - - set_global_bool_setting_for_test( - state.store.as_ref(), - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); - - create_provider_record( - state.store.as_ref(), - "default", - Provider { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: String::new(), - name: "aws-gate".to_string(), - created_at_ms: 0, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: "default".to_string(), - deletion_timestamp_ms: 0, - }), - r#type: "aws".to_string(), - credentials: HashMap::new(), - config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), - profile_workspace: "default".to_string(), - credential_handles: HashMap::new(), - }, - ) - .await - .unwrap(); - - handle_configure_provider_refresh( - &state, - authed_request(ConfigureProviderRefreshRequest { - provider: "aws-gate".to_string(), - credential_key: "AWS_ACCESS_KEY_ID".to_string(), - strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, - material: HashMap::from([( - "role_arn".to_string(), - "arn:aws:iam::123456789012:role/Test".to_string(), - )]), - secret_material_keys: Vec::new(), - expires_at_ms: None, - workspace: "default".to_string(), - }), - ) - .await - .unwrap(); - - // Disable the gate after the refresh is already configured. - set_global_bool_setting_for_test( - state.store.as_ref(), - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - false, - ) - .await - .unwrap(); - - let err = handle_rotate_provider_credential( - &state, - authed_request(RotateProviderCredentialRequest { - provider: "aws-gate".to_string(), - credential_key: "AWS_ACCESS_KEY_ID".to_string(), - workspace: "default".to_string(), - }), - ) - .await - .unwrap_err(); - - assert_eq!(err.code(), Code::FailedPrecondition); - assert!(err.message().contains("providers_v2_enabled")); - - // The gate rejection is recorded on the refresh state, and no credential - // was minted into the provider. - let provider = state - .store - .get_message_by_name::("default", "aws-gate") - .await - .unwrap() - .unwrap(); - assert!(!provider.credentials.contains_key("AWS_ACCESS_KEY_ID")); - let refresh_state = crate::provider_refresh::get_refresh_state( - state.store.as_ref(), - "default", - provider.object_id(), - "AWS_ACCESS_KEY_ID", - ) - .await - .unwrap() - .expect("refresh state should exist"); - assert_eq!(refresh_state.status, "configuration_required"); - assert_eq!( - refresh_state.recovery_action, - ProviderCredentialRefreshRecoveryAction::FixConfiguration as i32 - ); - assert_eq!(refresh_state.failure_code, "refresh_configuration_invalid"); - assert_eq!( - refresh_state.next_refresh_at_ms - refresh_state.last_error_at_ms, - 60 * 60 * 1000 - ); - } - #[tokio::test] async fn active_provider_environment_keys_include_additional_output_keys() { let state = test_server_state().await; @@ -12078,25 +12557,8 @@ mod tests { #[tokio::test] async fn configure_aws_sts_validates_additional_credential_key_collision() { - use crate::grpc::StoredSettingValue; - use crate::grpc::StoredSettings; - use crate::grpc::policy::save_global_settings; - let state = test_server_state().await; - let global_settings = StoredSettings { - revision: 1, - settings: std::iter::once(( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }; - save_global_settings(state.store.as_ref(), &global_settings) - .await - .unwrap(); - let mut existing_provider = Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -12198,17 +12660,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn concurrent_configure_aws_sts_reserves_key_exactly_once() { - use crate::grpc::policy::set_global_bool_setting_for_test; - let state = test_server_state().await; - set_global_bool_setting_for_test( - state.store.as_ref(), - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); - for name in ["aws-a", "aws-b"] { create_provider_record( state.store.as_ref(), @@ -12454,8 +12906,8 @@ mod tests { // Create same-named provider in each workspace via handlers. let make_provider = || Provider { metadata: None, - r#type: "custom".to_string(), - credentials: HashMap::from([("TOKEN".to_string(), "secret".to_string())]), + r#type: "pypi".to_string(), + credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: String::new(), diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index a8d198bc80..80f8ba8ac5 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -3143,6 +3143,15 @@ mod tests { #[tokio::test] async fn detach_sandbox_provider_is_idempotent_and_removes_all_matches() { let state = test_server_state().await; + state + .store + .put_message(&test_provider_with_credential_key( + "other", + "github", + "GITHUB_TOKEN", + )) + .await + .unwrap(); state .store .put_message(&test_sandbox( diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index aafc0cd369..591eeab214 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -678,6 +678,14 @@ pub(crate) async fn run_server( // first snapshots observe the post-start backend state. Explicitly stopped // sandboxes remain stopped. ensure_default_workspace(&store).await?; + grpc::policy::validate_provider_composition_startup_preflight(&state) + .await + .map_err(|error| { + Error::config(format!( + "provider policy composition startup preflight failed: {}", + error.message() + )) + })?; let gateway_listeners = bind_gateway_listeners( config.bind_address, diff --git a/crates/openshell-server/src/provider_profile_sources.rs b/crates/openshell-server/src/provider_profile_sources.rs index cbe5ff892c..f080c296d1 100644 --- a/crates/openshell-server/src/provider_profile_sources.rs +++ b/crates/openshell-server/src/provider_profile_sources.rs @@ -15,7 +15,7 @@ use openshell_gateway_interceptors::{ }; use openshell_providers::{ ProfileValidationDiagnostic, ProviderTypeProfile, builtin_profiles, normalize_profile_id, - validate_profile_set, + normalize_provider_type, validate_profile_set, }; use prost::Message as _; use sha2::{Digest, Sha256}; @@ -238,6 +238,7 @@ struct ScopedProfileEntry { struct EffectiveProfileEntry { effective: ScopedProfileEntry, platform_fallback: Option, + static_fallback: Option, } #[derive(Debug, Clone)] @@ -415,6 +416,9 @@ impl EffectiveProviderProfileCatalog { if let Some(fallback) = &entry.platform_fallback { result.push((fallback.scope, fallback.response.clone())); } + if let Some(fallback) = &entry.static_fallback { + result.push((fallback.scope, fallback.response.clone())); + } } result } @@ -427,6 +431,7 @@ impl EffectiveProviderProfileCatalog { .map(|entry| entry.effective.response.clone()) } + #[cfg(test)] pub(crate) fn get_type_profile(&self, id: &str) -> Option { let id = normalize_profile_id(id)?; self.profiles @@ -447,6 +452,22 @@ impl EffectiveProviderProfileCatalog { &self, id: &str, profile_workspace: &str, + ) -> Option<&ScopedProfileEntry> { + if let Some(entry) = self.exact_scoped_type_profile_for_scope(id, profile_workspace) { + return Some(entry); + } + + let alias = normalize_provider_type(id)?; + if normalize_profile_id(id).as_deref() == Some(alias) { + return None; + } + self.exact_scoped_type_profile_for_scope(alias, profile_workspace) + } + + fn exact_scoped_type_profile_for_scope( + &self, + id: &str, + profile_workspace: &str, ) -> Option<&ScopedProfileEntry> { let id = normalize_profile_id(id)?; let entry = self.profiles.get(&id)?; @@ -459,7 +480,7 @@ impl EffectiveProviderProfileCatalog { match &entry.platform_fallback { Some(fallback) => Some(fallback), None if entry.effective.scope == ProfileScope::Platform => Some(&entry.effective), - None => None, + None => entry.static_fallback.as_ref(), } } else { Some(&entry.effective) @@ -470,8 +491,14 @@ impl EffectiveProviderProfileCatalog { let id = normalize_profile_id(id)?; self.profiles .get(&id) - .filter(|entry| !entry.effective.user_managed) - .map(|entry| entry.effective.source_id.clone()) + .and_then(|entry| { + if entry.effective.user_managed { + entry.static_fallback.as_ref() + } else { + Some(&entry.effective) + } + }) + .map(|entry| entry.source_id.clone()) } pub(crate) fn hash_type_profile_revision_for_scope( @@ -623,6 +650,19 @@ fn build_effective_profiles( let new_scope = new_entry.scope; match (existing_scope, new_scope) { + (ProfileScope::Static, _) + if existing.effective.source_id == BUILTIN_SOURCE_ID + && new_entry.user_managed => + { + let fallback = std::mem::replace(&mut existing.effective, new_entry); + existing.static_fallback = Some(fallback); + } + (_, ProfileScope::Static) + if new_entry.source_id == BUILTIN_SOURCE_ID + && existing.effective.user_managed => + { + existing.static_fallback = Some(new_entry); + } (ProfileScope::Static, _) | (_, ProfileScope::Static) => { let location = if existing.effective.source_id == source_id { format!("within source '{source_id}'") @@ -656,6 +696,7 @@ fn build_effective_profiles( EffectiveProfileEntry { effective: new_entry, platform_fallback: None, + static_fallback: None, }, ); } @@ -1505,28 +1546,46 @@ mod tests { } #[test] - fn same_id_static_vs_user_still_errors() { - let err = build_effective_profiles(vec![ + fn user_managed_profiles_shadow_new_builtins_in_scope() { + let catalog = build_effective_profiles(vec![ CollectedProviderProfileSnapshot { source_id: "builtin".to_string(), revision: "v1".to_string(), - profiles: vec![scoped(ProfileScope::Static, "openai")], + profiles: vec![ + scoped(ProfileScope::Static, "openai"), + scoped(ProfileScope::Static, "anthropic"), + ], user_managed: false, allow_empty: false, }, CollectedProviderProfileSnapshot { source_id: "user".to_string(), revision: "v1".to_string(), - profiles: vec![scoped(ProfileScope::Platform, "openai")], + profiles: vec![ + scoped(ProfileScope::Platform, "openai"), + scoped(ProfileScope::Workspace, "anthropic"), + ], user_managed: true, allow_empty: true, }, ]) - .unwrap_err(); + .unwrap(); - assert!( - err.message() - .contains("duplicate provider profile id 'openai'") + let openai = catalog + .get_type_profile_for_scope("openai", "default") + .expect("platform override"); + assert_eq!(openai.source, "user"); + let anthropic = catalog + .get_type_profile_for_scope("anthropic", "default") + .expect("workspace override"); + assert_eq!(anthropic.source, "user"); + let platform_anthropic = catalog + .get_type_profile_for_scope("anthropic", "") + .expect("built-in fallback outside workspace scope"); + assert_eq!(platform_anthropic.source, "builtin"); + assert_eq!( + catalog.static_source_for_profile("openai").as_deref(), + Some("builtin") ); } @@ -1792,4 +1851,70 @@ mod tests { assert!(result.is_some()); assert_eq!(result.unwrap().display_name, "Workspace Anthropic"); } + + #[test] + fn exact_alias_shaped_profile_wins_before_builtin_alias_fallback() { + let mut builtin = profile("github"); + builtin.display_name = "Built-in GitHub".to_string(); + let mut custom = profile("gh"); + custom.display_name = "Enterprise GitHub".to_string(); + + let catalog = build_effective_profiles(vec![ + CollectedProviderProfileSnapshot { + source_id: "builtin".to_string(), + revision: "builtin-v1".to_string(), + profiles: vec![ScopedSnapshotProfile { + scope: ProfileScope::Static, + profile: builtin, + }], + user_managed: false, + allow_empty: false, + }, + CollectedProviderProfileSnapshot { + source_id: "user".to_string(), + revision: "user-v1".to_string(), + profiles: vec![ScopedSnapshotProfile { + scope: ProfileScope::Workspace, + profile: custom, + }], + user_managed: true, + allow_empty: true, + }, + ]) + .unwrap(); + + let exact = catalog + .get_type_profile_for_scope("gh", "default") + .expect("exact custom profile"); + assert_eq!(exact.id, "gh"); + assert_eq!(exact.display_name, "Enterprise GitHub"); + + let builtin = catalog + .get_type_profile_for_scope("github", "default") + .expect("built-in profile"); + assert_eq!(builtin.id, "github"); + } + + #[test] + fn custom_profile_can_reuse_default_id_when_builtin_source_is_not_loaded() { + let mut custom = profile("github"); + custom.display_name = "Private GitHub".to_string(); + let catalog = build_effective_profiles(vec![CollectedProviderProfileSnapshot { + source_id: "user".to_string(), + revision: "user-v1".to_string(), + profiles: vec![ScopedSnapshotProfile { + scope: ProfileScope::Workspace, + profile: custom, + }], + user_managed: true, + allow_empty: true, + }]) + .unwrap(); + + let resolved = catalog + .get_type_profile_for_scope("github", "default") + .expect("custom profile reusing unloaded default ID"); + assert_eq!(resolved.id, "github"); + assert_eq!(resolved.display_name, "Private GitHub"); + } } diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index 176f86ae22..bba28c3843 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -835,23 +835,6 @@ pub async fn refresh_provider_credential( "provider credential refresh started" ); - // Enforce the providers_v2 gate on every mint, not just at configure time. - // Otherwise disabling providers_v2_enabled leaves already-configured refresh - // states that the worker and manual rotation keep minting from. - if let Err(err) = ensure_refresh_providers_v2_gate(store, &state).await { - let failure = RefreshFailure::from_status(&err); - persist_refresh_failure_state(store, &mut state, expected_version, &failure).await?; - warn!( - provider = %state.provider_name, - credential_key = %state.credential_key, - strategy = %refresh_strategy_name(state.strategy), - status = %state.status, - error = %err, - "provider credential refresh gate rejected" - ); - return Err(err); - } - let mint_result = match resolve_refresh_material(Some(credentials), &state).await { Ok(transient_state) => mint_credential(&transient_state).await, Err(err) => Err(err.into()), @@ -1276,31 +1259,6 @@ async fn cleanup_staged_refresh_handles( } } -/// Reject minting for strategies that require `providers_v2_enabled` when the -/// setting is off. Runs on every refresh (worker sweep and manual rotation), so -/// disabling the setting halts further mints from already-configured states. -async fn ensure_refresh_providers_v2_gate( - store: &Store, - state: &StoredProviderCredentialRefreshState, -) -> Result<(), Status> { - let strategy = ProviderCredentialRefreshStrategy::try_from(state.strategy) - .unwrap_or(ProviderCredentialRefreshStrategy::Unspecified); - if strategy != ProviderCredentialRefreshStrategy::AwsStsAssumeRole { - return Ok(()); - } - if !crate::grpc::policy::global_bool_setting_enabled( - store, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - ) - .await? - { - return Err(Status::failed_precondition( - "aws_sts_assume_role requires providers_v2_enabled=true", - )); - } - Ok(()) -} - async fn mint_credential( state: &StoredProviderCredentialRefreshState, ) -> Result { @@ -2518,62 +2476,6 @@ mod tests { ); } - #[tokio::test] - async fn disabled_provider_gate_requires_configuration_and_retries_hourly() { - let store = test_store().await; - let provider = provider("disabled-provider-gate", "aws-s3"); - store.put_message(&provider).await.unwrap(); - let state = new_refresh_state( - &provider, - "default", - "AWS_ACCESS_KEY_ID", - NewRefreshStateConfig { - strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole, - material: HashMap::from([( - "role_arn".to_string(), - "arn:aws:iam::123456789012:role/test".to_string(), - )]), - secret_material_keys: Vec::new(), - expires_at_ms: 0, - token_url: String::new(), - scopes: Vec::new(), - refresh_before_seconds: 30, - max_lifetime_seconds: 60, - additional_output_keys: HashMap::new(), - }, - ) - .unwrap(); - put_refresh_state(&store, &state).await.unwrap(); - - let err = refresh_provider_credential( - &store, - "default", - &test_credentials(), - None, - "disabled-provider-gate", - "AWS_ACCESS_KEY_ID", - ) - .await - .unwrap_err(); - - assert_eq!(err.code(), tonic::Code::FailedPrecondition); - let stored = - get_refresh_state(&store, "default", provider.object_id(), "AWS_ACCESS_KEY_ID") - .await - .unwrap() - .unwrap(); - assert_eq!(stored.status, "configuration_required"); - assert_eq!( - stored.recovery_action, - ProviderCredentialRefreshRecoveryAction::FixConfiguration as i32 - ); - assert_eq!(stored.failure_code, "refresh_configuration_invalid"); - assert_eq!( - stored.next_refresh_at_ms - stored.last_error_at_ms, - 60 * 60 * 1000 - ); - } - #[tokio::test] async fn oauth_invalid_grant_persists_terminal_reauthorization_status() { let mock_server = MockServer::start().await; @@ -3590,13 +3492,6 @@ mod tests { .await; let store = test_store().await; - crate::grpc::policy::set_global_bool_setting_for_test( - &store, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); let prov = provider("aws-sts-test", "aws"); store.put_message(&prov).await.unwrap(); @@ -3695,13 +3590,6 @@ mod tests { .await; let store = test_store().await; - crate::grpc::policy::set_global_bool_setting_for_test( - &store, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); let prov = provider("aws-sts-custom", "aws"); store.put_message(&prov).await.unwrap(); @@ -3779,13 +3667,6 @@ mod tests { #[tokio::test] async fn aws_sts_mint_rejects_partial_source_credentials() { let store = test_store().await; - crate::grpc::policy::set_global_bool_setting_for_test( - &store, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); let prov = provider("aws-sts-partial", "aws"); store.put_message(&prov).await.unwrap(); @@ -4110,13 +3991,6 @@ mod tests { .await; let store = test_store().await; - crate::grpc::policy::set_global_bool_setting_for_test( - &store, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); let prov = provider("aws-sts-session", "aws"); store.put_message(&prov).await.unwrap(); @@ -4194,13 +4068,6 @@ mod tests { #[tokio::test] async fn aws_sts_mint_rejects_session_token_without_source_pair() { let store = test_store().await; - crate::grpc::policy::set_global_bool_setting_for_test( - &store, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); let prov = provider("aws-sts-lonesession", "aws"); store.put_message(&prov).await.unwrap(); @@ -4279,13 +4146,6 @@ mod tests { .await; let store = test_store().await; - crate::grpc::policy::set_global_bool_setting_for_test( - &store, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); let prov = provider("aws-race", "aws"); store.put_message(&prov).await.unwrap(); let provider_id = prov.object_id().to_string(); @@ -4407,13 +4267,6 @@ mod tests { .await; let store = test_store().await; - crate::grpc::policy::set_global_bool_setting_for_test( - &store, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); let prov = provider("aws-superseded", "aws"); store.put_message(&prov).await.unwrap(); let provider_id = prov.object_id().to_string(); diff --git a/crates/openshell-supervisor-network/src/sigv4.rs b/crates/openshell-supervisor-network/src/sigv4.rs index 40cc189dc2..79e4c32e18 100644 --- a/crates/openshell-supervisor-network/src/sigv4.rs +++ b/crates/openshell-supervisor-network/src/sigv4.rs @@ -28,6 +28,13 @@ fn looks_like_region(s: &str) -> bool { /// hostnames. The region is the label immediately before `amazonaws.com` /// (or `amazonaws.com.cn`). pub fn extract_aws_region(host: &str) -> Option { + // The global S3 endpoint and its virtual-hosted bucket form always sign + // in us-east-1. This keeps policy metadata consistent across overlapping + // S3 hostname patterns while preserving correct global endpoint signing. + if host == "s3.amazonaws.com" || host.ends_with(".s3.amazonaws.com") { + return Some("us-east-1".to_string()); + } + let parts: Vec<&str> = host.split('.').collect(); // China partition: *.amazonaws.com.cn if parts.len() >= 5 @@ -386,13 +393,19 @@ mod tests { } #[test] - fn global_endpoint_returns_none() { - assert!(extract_aws_region("s3.amazonaws.com").is_none()); + fn global_s3_endpoint_uses_us_east_1() { + assert_eq!( + extract_aws_region("s3.amazonaws.com").as_deref(), + Some("us-east-1") + ); } #[test] - fn virtual_hosted_global_endpoint_returns_none() { - assert!(extract_aws_region("my-bucket.s3.amazonaws.com").is_none()); + fn virtual_hosted_global_s3_endpoint_uses_us_east_1() { + assert_eq!( + extract_aws_region("my-bucket.s3.amazonaws.com").as_deref(), + Some("us-east-1") + ); } #[test] diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index a5e14bcbab..13a00aaca3 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -11,6 +11,9 @@ use openshell_core::auth::EdgeAuthInterceptor; use openshell_core::proto::open_shell_client::OpenShellClient; use openshell_core::proto::setting_value; use openshell_core::settings::{self, SettingValueKind}; +use openshell_providers::{ + DiscoveredProvider, ProviderTypeProfile, RealDiscoveryContext, discover_from_profile, +}; use tonic::service::interceptor::InterceptedService; use tonic::transport::Channel; @@ -350,9 +353,9 @@ pub enum ProviderKeyField { Name, /// Focused credential row for known types (index via `cred_cursor`). Credential, - /// Custom env var name (generic / no-known-env-vars types only). + /// Custom env var name (legacy no-known-env-vars types only). EnvVarName, - /// Custom env var value (generic / no-known-env-vars types only). + /// Custom env var value (legacy no-known-env-vars types only). GenericValue, /// Browsing/deleting existing config entries (Up/Down/Ctrl+D). ConfigList, @@ -385,14 +388,16 @@ pub struct CreateProviderForm { pub config_key_input: String, /// Config value being entered. pub config_value_input: String, - /// For generic / types with no known env vars: custom env var name. + /// For legacy types with no known env vars: custom env var name. pub generic_env_name: String, - /// For generic / types with no known env vars: custom value. + /// For legacy types with no known env vars: custom value. pub generic_value: String, /// Which field is focused in the key entry form. pub key_field: ProviderKeyField, - /// True when the provider type has no known env vars (generic, outlook). + /// True when the selected profile has no accepted stored credential keys. pub is_generic: bool, + /// True when the selected profile permits creation without stored credentials. + pub allows_empty_credentials: bool, /// Status message (errors, validation). pub status: Option, /// Warning shown at top of `EnterKey` modal (e.g. autodetect failure). @@ -405,6 +410,12 @@ pub struct CreateProviderForm { pub discovered_credentials: Option>, } +fn apply_discovered_provider(form: &mut CreateProviderForm, discovered: DiscoveredProvider) { + form.discovered_credentials = Some(discovered.credentials); + form.config = discovered.config.into_iter().collect(); + form.config_cursor = 0; +} + // --------------------------------------------------------------------------- // Provider detail view (Get) // --------------------------------------------------------------------------- @@ -590,7 +601,7 @@ pub struct App { pub pending_workspace_refresh: bool, // Provider list - pub providers_v2_enabled: bool, + pub provider_profiles: Vec, pub provider_entries: Vec, pub provider_names: Vec, pub provider_types: Vec, @@ -970,7 +981,7 @@ impl App { all_workspaces: false, workspace_names: Vec::new(), pending_workspace_refresh: false, - providers_v2_enabled: false, + provider_profiles: Vec::new(), provider_entries: Vec::new(), provider_names: Vec::new(), provider_types: Vec::new(), @@ -1410,7 +1421,7 @@ impl App { self.overflow_focus_up(); } } - KeyCode::Char('c') if !self.providers_v2_enabled => { + KeyCode::Char('c') => { if self.all_workspaces { self.status_text = "Switch to a specific workspace to create providers.".to_string(); @@ -1423,10 +1434,10 @@ impl App { self.pending_provider_get = true; } // Open update form for the selected provider. - KeyCode::Char('u') if self.provider_count > 0 && !self.providers_v2_enabled => { + KeyCode::Char('u') if self.provider_count > 0 => { self.open_update_provider_form(); } - KeyCode::Char('d') if self.provider_count > 0 && !self.providers_v2_enabled => { + KeyCode::Char('d') if self.provider_count > 0 => { self.confirm_provider_delete = true; } KeyCode::Char('h' | 'l') | KeyCode::Left | KeyCode::Right => { @@ -2383,15 +2394,45 @@ impl App { // ------------------------------------------------------------------ fn open_create_provider_form(&mut self) { - let known = openshell_providers::ProviderRegistry::new().known_types(); - let types: Vec = known.into_iter().map(String::from).collect(); + let types = self.available_provider_profile_types(); self.create_provider_form = Some(CreateProviderForm { types, + status: self.provider_profiles.is_empty().then(|| { + "Provider profiles unavailable. Wait for refresh, then retry.".to_string() + }), ..CreateProviderForm::default() }); } + fn available_provider_profile_types(&self) -> Vec { + let mut types = self + .provider_profiles + .iter() + .map(|profile| profile.id.clone()) + .collect::>(); + types.sort(); + types.dedup(); + types + } + + pub(crate) fn sync_create_provider_types(&mut self) { + let types = self.available_provider_profile_types(); + let Some(form) = self.create_provider_form.as_mut() else { + return; + }; + if form.phase != CreateProviderPhase::SelectType { + return; + } + + form.types = types; + form.type_cursor = form.type_cursor.min(form.types.len().saturating_sub(1)); + form.status = form + .types + .is_empty() + .then(|| "Provider profiles unavailable. Wait for refresh, then retry.".to_string()); + } + fn handle_create_provider_key(&mut self, key: KeyEvent) { let Some(form) = self.create_provider_form.as_mut() else { return; @@ -2409,27 +2450,53 @@ impl App { form.type_cursor = form.type_cursor.saturating_sub(1); } KeyCode::Enter => { - let selected = form.types[form.type_cursor].clone(); - let registry = openshell_providers::ProviderRegistry::new(); - let env_vars = registry.credential_env_vars(&selected); - form.is_generic = env_vars.is_empty(); - - // Populate credential rows from all known env vars. - form.credentials = env_vars + let Some(selected) = form.types.get(form.type_cursor).cloned() else { + form.status = Some( + "Provider profiles unavailable. Wait for refresh, then retry." + .to_string(), + ); + return; + }; + let Some(profile) = self + .provider_profiles .iter() - .map(|s| (s.to_string(), String::new())) + .find(|profile| profile.id == selected) + .cloned() + else { + form.status = Some(format!("Provider profile '{selected}' is unavailable")); + return; + }; + let profile = ProviderTypeProfile::from_proto(&profile); + let mut credential_keys = Vec::new(); + for key in profile + .credentials + .iter() + .flat_map(|credential| credential.accepted_stored_keys()) + { + if !credential_keys.iter().any(|existing| existing == key) { + credential_keys.push(key.to_string()); + } + } + form.is_generic = credential_keys.is_empty(); + form.allows_empty_credentials = profile.allows_empty_provider_credentials(); + + // Populate credential rows from all accepted storage keys, + // including logical names for broker-only credentials. + form.credentials = credential_keys + .into_iter() + .map(|key| (key, String::new())) .collect(); form.cred_cursor = 0; // Auto-generate a unique name. form.name = unique_provider_name(&selected, &self.provider_names); - if form.is_generic { - // No known env vars — skip straight to manual entry. - form.phase = CreateProviderPhase::EnterKey; - form.key_field = ProviderKeyField::Name; - form.status = None; - form.warning = None; + if form.credentials.is_empty() { + // Credential-less profiles can be created directly. + form.discovered_credentials = Some(HashMap::new()); + form.phase = CreateProviderPhase::Creating; + form.anim_start = Some(Instant::now()); + self.pending_provider_create = true; } else { form.phase = CreateProviderPhase::ChooseMethod; form.method_cursor = 0; @@ -2450,10 +2517,18 @@ impl App { KeyCode::Enter => { let ptype = form.types[form.type_cursor].clone(); if form.method_cursor == 0 { - // Autodetect — synchronous since we only check env vars now. - let registry = openshell_providers::ProviderRegistry::new(); - if let Ok(Some(discovered)) = registry.discover_existing(&ptype) { - form.discovered_credentials = Some(discovered.credentials); + let discovered = self + .provider_profiles + .iter() + .find(|profile| profile.id == ptype) + .map(ProviderTypeProfile::from_proto) + .and_then(|profile| { + discover_from_profile(&profile, &RealDiscoveryContext) + .ok() + .flatten() + }); + if let Some(discovered) = discovered { + apply_discovered_provider(form, discovered); if form.name.is_empty() { form.name = unique_provider_name(&ptype, &self.provider_names); } @@ -2465,7 +2540,12 @@ impl App { form.phase = CreateProviderPhase::EnterKey; form.key_field = ProviderKeyField::Name; form.warning = Some( - "No credentials found in environment. Enter manually.".to_string(), + if form.allows_empty_credentials { + "No credentials found in environment. Enter credentials or submit without them." + } else { + "No credentials found in environment. Enter manually." + } + .to_string(), ); form.status = None; } @@ -2789,7 +2869,7 @@ impl App { creds.insert(name.clone(), value.clone()); } } - if creds.is_empty() { + if creds.is_empty() && !form.allows_empty_credentials { form.status = Some("At least one credential is required.".to_string()); return; @@ -2893,13 +2973,19 @@ impl App { }) .unwrap_or_default(); - // If we don't know the credential key, derive from registry. + // If we don't know the credential key, derive it from the profile. let key = if cred_key.is_empty() { - let registry = openshell_providers::ProviderRegistry::new(); - registry - .credential_env_vars(&ptype) - .first() - .map_or(String::new(), ToString::to_string) + self.provider_entries + .get(self.provider_selected) + .and_then(|entry| entry.profile.as_ref()) + .map(ProviderTypeProfile::from_proto) + .and_then(|profile| { + profile + .credential_env_vars() + .first() + .map(ToString::to_string) + }) + .unwrap_or_default() } else { cred_key }; @@ -3336,7 +3422,7 @@ impl App { ); let raw_profile_yaml = profile.and_then(|profile| { - let dto = openshell_providers::ProviderTypeProfile::from_proto(profile); + let dto = ProviderTypeProfile::from_proto(profile); openshell_providers::profile_to_yaml(&dto).ok() }); @@ -3435,7 +3521,7 @@ impl App { self.global_policy_active = false; self.global_policy_version = 0; // Reset provider state too. - self.providers_v2_enabled = false; + self.provider_profiles.clear(); self.provider_entries.clear(); self.provider_names.clear(); self.provider_types.clear(); @@ -3501,22 +3587,115 @@ mod tests { ) } + fn provider_profile( + id: &str, + credentials: Vec, + ) -> openshell_core::proto::ProviderProfile { + openshell_core::proto::ProviderProfile { + id: id.to_string(), + credentials, + ..Default::default() + } + } + + fn credential( + name: &str, + env_vars: &[&str], + required: bool, + runtime_resolvable: bool, + ) -> openshell_core::proto::ProviderProfileCredential { + openshell_core::proto::ProviderProfileCredential { + name: name.to_string(), + env_vars: env_vars.iter().map(ToString::to_string).collect(), + required, + token_grant: runtime_resolvable.then(Default::default), + ..Default::default() + } + } + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + #[tokio::test] - async fn global_settings_do_not_override_provider_api_capability() { + async fn create_provider_enter_with_empty_profile_catalog_is_recoverable() { let mut app = test_app(); - app.providers_v2_enabled = true; - let mut values = HashMap::new(); - values.insert( - settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - openshell_core::proto::SettingValue { - value: Some(setting_value::Value::BoolValue(false)), - }, + app.open_create_provider_form(); + + app.handle_create_provider_key(key(KeyCode::Enter)); + + let form = app + .create_provider_form + .as_ref() + .expect("form remains open"); + assert_eq!(form.phase, CreateProviderPhase::SelectType); + assert!( + form.status + .as_deref() + .is_some_and(|status| status.contains("profiles unavailable")) ); + assert!(!app.pending_provider_create); + + app.provider_profiles = vec![provider_profile("recovered", Vec::new())]; + app.sync_create_provider_types(); + let form = app + .create_provider_form + .as_ref() + .expect("form remains open"); + assert_eq!(form.types, vec!["recovered"]); + assert!(form.status.is_none()); + } + + #[tokio::test] + async fn create_provider_accepts_empty_credentials_when_profile_allows_them() { + for profile in [ + provider_profile("policy-only", Vec::new()), + provider_profile( + "optional-static", + vec![credential("api_key", &["API_KEY"], false, false)], + ), + provider_profile( + "runtime-token", + vec![credential("access_token", &["ACCESS_TOKEN"], true, true)], + ), + ] { + let mut app = test_app(); + app.provider_profiles = vec![profile]; + app.open_create_provider_form(); + app.handle_create_provider_key(key(KeyCode::Enter)); + + let form = app.create_provider_form.as_mut().expect("form"); + if form.phase != CreateProviderPhase::Creating { + form.phase = CreateProviderPhase::EnterKey; + form.key_field = ProviderKeyField::Submit; + app.handle_create_provider_key(key(KeyCode::Enter)); + } + + let form = app.create_provider_form.as_ref().expect("form"); + assert_eq!(form.phase, CreateProviderPhase::Creating); + assert_eq!(form.discovered_credentials, Some(HashMap::new())); + assert!(app.pending_provider_create); + } + } - app.apply_global_settings(values, 7); + #[tokio::test] + async fn create_provider_uses_logical_key_for_broker_only_credential() { + let mut app = test_app(); + app.provider_profiles = vec![provider_profile( + "token-exchange", + vec![credential("subject_token", &[], true, false)], + )]; + app.open_create_provider_form(); + + app.handle_create_provider_key(key(KeyCode::Enter)); - assert!(app.providers_v2_enabled); - assert_eq!(app.global_settings_revision, 7); + let form = app.create_provider_form.as_ref().expect("form"); + assert_eq!(form.phase, CreateProviderPhase::ChooseMethod); + assert_eq!( + form.credentials, + vec![("subject_token".to_string(), String::new())] + ); + assert!(!form.allows_empty_credentials); } #[tokio::test] @@ -3931,4 +4110,34 @@ mod tests { ); assert_eq!(config.get("FOO"), Some(&"bar".to_string())); } + + #[test] + fn autodetected_provider_configuration_is_preserved_in_create_form() { + let mut form = CreateProviderForm::default(); + apply_discovered_provider( + &mut form, + DiscoveredProvider { + credentials: HashMap::from([("VERTEX_AI_TOKEN".to_string(), "token".to_string())]), + config: HashMap::from([ + ("VERTEX_AI_PROJECT_ID".to_string(), "project-a".to_string()), + ("VERTEX_AI_REGION".to_string(), "us-central1".to_string()), + ]), + }, + ); + + assert_eq!( + form.discovered_credentials + .as_ref() + .and_then(|credentials| credentials.get("VERTEX_AI_TOKEN")), + Some(&"token".to_string()) + ); + assert_eq!( + form.config.get("VERTEX_AI_PROJECT_ID"), + Some(&"project-a".to_string()) + ); + assert_eq!( + form.config.get("VERTEX_AI_REGION"), + Some(&"us-central1".to_string()) + ); + } } diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 625bc8a41a..f08f44a3ac 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -8,6 +8,7 @@ pub mod theme; mod ui; use std::collections::HashMap; +use std::future::Future; use std::io; use std::path::PathBuf; use std::time::Duration; @@ -35,6 +36,7 @@ use event::{Event, EventHandler}; /// Duration to show the splash screen before auto-dismissing. const SPLASH_DURATION: Duration = Duration::from_secs(3); const PROVIDER_PROFILE_SCOPE_WORKSPACE: &str = "workspace"; +const PROVIDER_PROFILE_PAGE_SIZE: u32 = 100; type ProviderProfileCache = HashMap<(String, String), openshell_core::proto::ProviderProfile>; @@ -78,7 +80,6 @@ pub async fn run( let mut events = EventHandler::new(Duration::from_secs(2)); - fetch_providers_v2_setting(&mut app).await; refresh_gateway_list(&mut app); refresh_data(&mut app).await; @@ -500,10 +501,6 @@ async fn handle_gateway_switch(app: &mut App) { app.gateway_name = name; app.endpoint = endpoint; app.reset_sandbox_state(); - // Re-fetch the providers_v2 capability for the new gateway - // before refreshing data, so provider CRUD controls reflect - // the correct mode. - fetch_providers_v2_setting(app).await; refresh_data(app).await; } Err(e) => { @@ -2018,30 +2015,6 @@ fn format_draft_approve_all_result( // Data refresh // --------------------------------------------------------------------------- -async fn fetch_providers_v2_setting(app: &mut App) { - let req = openshell_core::proto::GetGatewayConfigRequest {}; - match tokio::time::timeout(Duration::from_secs(5), app.client.get_gateway_config(req)).await { - Ok(Ok(resp)) => { - let response = resp.into_inner(); - let enabled = response - .settings - .get(openshell_core::settings::PROVIDERS_V2_ENABLED_KEY) - .and_then(|s| match &s.value { - Some(openshell_core::proto::setting_value::Value::BoolValue(v)) => Some(*v), - _ => None, - }) - .unwrap_or(false); - app.providers_v2_enabled = enabled; - } - Ok(Err(e)) => { - app.status_text = format!("failed to fetch gateway config: {}", e.message()); - } - Err(_) => { - app.status_text = "gateway config fetch timed out".to_string(); - } - } -} - async fn refresh_data(app: &mut App) { refresh_health(app).await; refresh_global_settings(app).await; @@ -2141,36 +2114,52 @@ async fn refresh_providers(app: &mut App) { }; let providers = response.providers; - let profiles: ProviderProfileCache = if app.providers_v2_enabled { - let workspaces: std::collections::HashSet = providers - .iter() - .map(|provider| provider_profile_query_workspace(provider).to_string()) - // Legacy provider records can decode without an object workspace. Do not - // turn that missing context into a platform-scoped profile request. - .filter(|workspace| !workspace.is_empty()) - .collect(); - let mut all_profiles = HashMap::new(); - for ws in &workspaces { - let req = openshell_core::proto::ListProviderProfilesRequest { - limit: 100, - offset: 0, - workspace: ws.clone(), - }; - if let Ok(Ok(resp)) = tokio::time::timeout( - Duration::from_secs(5), - app.client.list_provider_profiles(req), - ) - .await - { - for profile in resp.into_inner().profiles { - cache_provider_profile(&mut all_profiles, ws, profile); + let mut workspaces: std::collections::HashSet = providers + .iter() + .map(|provider| provider_profile_query_workspace(provider).to_string()) + // Legacy provider records can decode without an object workspace. Do not + // turn that missing context into a platform-scoped profile request. + .filter(|workspace| !workspace.is_empty()) + .collect(); + if !app.all_workspaces { + workspaces.insert(app.current_workspace.clone()); + } + let mut profiles = HashMap::new(); + app.provider_profiles.clear(); + for ws in &workspaces { + let client = app.client.clone(); + let workspace = ws.clone(); + if let Some(listed) = collect_provider_profile_pages(move |offset| { + let mut client = client.clone(); + let workspace = workspace.clone(); + async move { + let req = openshell_core::proto::ListProviderProfilesRequest { + limit: PROVIDER_PROFILE_PAGE_SIZE, + offset, + workspace, + }; + match tokio::time::timeout( + Duration::from_secs(5), + client.list_provider_profiles(req), + ) + .await + { + Ok(Ok(response)) => Some(response.into_inner().profiles), + _ => None, } } + }) + .await + { + if !app.all_workspaces && ws == &app.current_workspace { + app.provider_profiles.clone_from(&listed); + } + for profile in listed { + cache_provider_profile(&mut profiles, ws, profile); + } } - all_profiles - } else { - HashMap::new() - }; + } + app.sync_create_provider_types(); app.provider_count = providers.len(); app.provider_entries = providers @@ -2205,6 +2194,26 @@ async fn refresh_providers(app: &mut App) { } } +async fn collect_provider_profile_pages( + mut fetch_page: F, +) -> Option> +where + F: FnMut(u32) -> Fut, + Fut: Future>>, +{ + let mut profiles = Vec::new(); + let mut offset = 0; + loop { + let page = fetch_page(offset).await?; + let page_len = page.len(); + profiles.extend(page); + if page_len < PROVIDER_PROFILE_PAGE_SIZE as usize { + return Some(profiles); + } + offset = offset.saturating_add(PROVIDER_PROFILE_PAGE_SIZE); + } +} + async fn refresh_global_settings(app: &mut App) { if !app.global_settings_access_denied { let req = openshell_core::proto::GetGatewayConfigRequest {}; @@ -2863,3 +2872,48 @@ mod provider_profile_workspace_tests { } } } + +#[cfg(test)] +mod provider_profile_pagination_tests { + use super::*; + use std::sync::{Arc, Mutex}; + + #[tokio::test] + async fn profile_fetch_continues_until_page_two_is_collected() { + let requested_offsets = Arc::new(Mutex::new(Vec::new())); + let offsets = Arc::clone(&requested_offsets); + + let profiles = collect_provider_profile_pages(move |offset| { + let offsets = Arc::clone(&offsets); + async move { + offsets.lock().unwrap().push(offset); + match offset { + 0 => Some( + (0..PROVIDER_PROFILE_PAGE_SIZE) + .map(|index| openshell_core::proto::ProviderProfile { + id: format!("profile-{index}"), + ..Default::default() + }) + .collect(), + ), + PROVIDER_PROFILE_PAGE_SIZE => { + Some(vec![openshell_core::proto::ProviderProfile { + id: "page-two-profile".to_string(), + ..Default::default() + }]) + } + _ => panic!("unexpected profile page offset {offset}"), + } + } + }) + .await + .expect("all pages should load"); + + assert_eq!( + *requested_offsets.lock().unwrap(), + vec![0, PROVIDER_PROFILE_PAGE_SIZE] + ); + assert_eq!(profiles.len(), PROVIDER_PROFILE_PAGE_SIZE as usize + 1); + assert_eq!(profiles.last().unwrap().id, "page-two-profile"); + } +} diff --git a/crates/openshell-tui/src/ui/create_provider.rs b/crates/openshell-tui/src/ui/create_provider.rs index cb39bff341..b68ddf5ea9 100644 --- a/crates/openshell-tui/src/ui/create_provider.rs +++ b/crates/openshell-tui/src/ui/create_provider.rs @@ -9,6 +9,7 @@ use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph}; use crate::app::{App, CreateProviderPhase, ProviderKeyField, UpdateProviderField}; use indexmap::IndexMap; +use std::ops::Range; use super::centered_rect; @@ -76,25 +77,48 @@ fn draw_select_type( chunks[0], ); - let lines: Vec> = form - .types - .iter() - .enumerate() - .map(|(i, ty)| { - let is_cursor = i == form.type_cursor; - let marker = if is_cursor { ">" } else { " " }; - let style = if is_cursor { t.accent } else { t.text }; - Line::from(vec![ - Span::styled(format!(" {marker} "), style), - Span::styled(ty.as_str(), style), - ]) - }) - .collect(); + let visible_range = visible_type_range( + form.types.len(), + form.type_cursor, + usize::from(chunks[2].height), + ); + let lines: Vec> = if form.types.is_empty() { + vec![Line::from(Span::styled( + form.status + .as_deref() + .unwrap_or("No provider profiles available."), + t.status_warn, + ))] + } else { + form.types + .iter() + .enumerate() + .skip(visible_range.start) + .take(visible_range.len()) + .map(|(i, ty)| { + let is_cursor = i == form.type_cursor; + let marker = if is_cursor { ">" } else { " " }; + let style = if is_cursor { t.accent } else { t.text }; + Line::from(vec![ + Span::styled(format!(" {marker} "), style), + Span::styled(ty.as_str(), style), + ]) + }) + .collect() + }; frame.render_widget(Paragraph::new(lines), chunks[2]); let hint = Line::from(vec![ Span::styled("[j/k]", t.key_hint), Span::styled(" Navigate ", t.muted), + Span::styled( + format!( + "[{}/{}] ", + form.type_cursor.saturating_add(1).min(form.types.len()), + form.types.len() + ), + t.muted, + ), Span::styled("[Enter]", t.key_hint), Span::styled(" Select ", t.muted), Span::styled("[Esc]", t.key_hint), @@ -103,6 +127,19 @@ fn draw_select_type( frame.render_widget(Paragraph::new(hint), chunks[4]); } +fn visible_type_range(total: usize, cursor: usize, visible_rows: usize) -> Range { + if total == 0 || visible_rows == 0 { + return 0..0; + } + + let visible_rows = visible_rows.min(total); + let cursor = cursor.min(total - 1); + let start = cursor + .saturating_sub(visible_rows - 1) + .min(total - visible_rows); + start..start + visible_rows +} + // --------------------------------------------------------------------------- // Phase 2: Choose method (autodetect vs manual) // --------------------------------------------------------------------------- @@ -1094,3 +1131,23 @@ fn render_status( ); } } + +#[cfg(test)] +mod tests { + use super::visible_type_range; + + #[test] + fn type_list_window_follows_cursor_past_visible_rows() { + assert_eq!(visible_type_range(25, 0, 10), 0..10); + assert_eq!(visible_type_range(25, 9, 10), 0..10); + assert_eq!(visible_type_range(25, 10, 10), 1..11); + assert_eq!(visible_type_range(25, 24, 10), 15..25); + } + + #[test] + fn type_list_window_handles_short_and_empty_lists() { + assert_eq!(visible_type_range(3, 2, 10), 0..3); + assert_eq!(visible_type_range(0, 0, 10), 0..0); + assert_eq!(visible_type_range(3, 2, 0), 0..0); + } +} diff --git a/crates/openshell-tui/src/ui/mod.rs b/crates/openshell-tui/src/ui/mod.rs index 3078ff793f..7ad1589186 100644 --- a/crates/openshell-tui/src/ui/mod.rs +++ b/crates/openshell-tui/src/ui/mod.rs @@ -207,27 +207,6 @@ fn draw_nav_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { Span::styled("[q]", t.muted), Span::styled(" Quit", t.muted), ], - Focus::Providers if app.providers_v2_enabled => vec![ - Span::styled(" ", t.text), - Span::styled("[Tab]", t.key_hint), - Span::styled(" Switch Panel", t.text), - Span::styled(" ", t.text), - Span::styled("[h/l]", t.key_hint), - Span::styled(" Switch Tab", t.text), - Span::styled(" ", t.text), - Span::styled("[j/k]", t.key_hint), - Span::styled(" Navigate", t.text), - Span::styled(" ", t.text), - Span::styled("[Enter]", t.key_hint), - Span::styled(" Detail", t.text), - Span::styled(" ", t.text), - Span::styled("read-only", t.muted), - Span::styled(" | ", t.border), - Span::styled("[:]", t.muted), - Span::styled(" Command ", t.muted), - Span::styled("[q]", t.muted), - Span::styled(" Quit", t.muted), - ], Focus::Providers => vec![ Span::styled(" ", t.text), Span::styled("[Tab]", t.key_hint), @@ -242,14 +221,8 @@ fn draw_nav_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { Span::styled("[Enter]", t.key_hint), Span::styled(" Detail", t.text), Span::styled(" ", t.text), - Span::styled("[c]", t.key_hint), - Span::styled(" Create", t.text), - Span::styled(" ", t.text), - Span::styled("[u]", t.key_hint), - Span::styled(" Update", t.text), - Span::styled(" ", t.text), - Span::styled("[d]", t.key_hint), - Span::styled(" Delete", t.text), + Span::styled("[c/u/d]", t.key_hint), + Span::styled(" Create/Update/Delete", t.text), Span::styled(" | ", t.border), Span::styled("[:]", t.muted), Span::styled(" Command ", t.muted), diff --git a/crates/openshell-tui/src/ui/providers.rs b/crates/openshell-tui/src/ui/providers.rs index e4f4d2d0ba..3bf5c7f94c 100644 --- a/crates/openshell-tui/src/ui/providers.rs +++ b/crates/openshell-tui/src/ui/providers.rs @@ -9,12 +9,12 @@ use ratatui::widgets::{Block, Borders, Cell, Padding, Row, Table}; use crate::app::App; pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { - let t = &app.theme; - if app.providers_v2_enabled { - draw_v2(frame, app, area, focused); - return; - } + draw_v2(frame, app, area, focused); +} +#[allow(dead_code)] +fn draw_legacy(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { + let t = &app.theme; let show_ws = app.all_workspaces; let mut header_cells = Vec::new(); diff --git a/docs/get-started/tutorials/index.mdx b/docs/get-started/tutorials/index.mdx index bc30bcfc44..bba5996277 100644 --- a/docs/get-started/tutorials/index.mdx +++ b/docs/get-started/tutorials/index.mdx @@ -24,7 +24,7 @@ Launch Claude Code in a sandbox, diagnose a policy denial, and iterate on a cust -Configure a Providers v2 Microsoft Graph provider with gateway-managed OAuth2 refresh-token rotation. +Configure a Microsoft Graph provider profile with gateway-managed OAuth2 refresh-token rotation. diff --git a/docs/get-started/tutorials/microsoft-graph-provider-refresh.mdx b/docs/get-started/tutorials/microsoft-graph-provider-refresh.mdx index d3c4a75847..76adf55fde 100644 --- a/docs/get-started/tutorials/microsoft-graph-provider-refresh.mdx +++ b/docs/get-started/tutorials/microsoft-graph-provider-refresh.mdx @@ -1,14 +1,14 @@ --- # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -title: "Refresh Microsoft Graph Credentials with Providers v2" +title: "Refresh Microsoft Graph Credentials with a Provider Profile" sidebar-title: "Microsoft Graph Provider Refresh" slug: "get-started/tutorials/microsoft-graph-provider-refresh" -description: "Configure a Providers v2 Microsoft Graph profile with gateway-managed OAuth2 refresh-token rotation." +description: "Configure a Microsoft Graph provider profile with gateway-managed OAuth2 refresh-token rotation." keywords: "Generative AI, Cybersecurity, Tutorial, Providers, Microsoft Graph, OAuth2, Credential Refresh, Sandbox" --- -Use Providers v2 to keep Microsoft Graph access tokens short lived while sandboxes receive a stable `MS_GRAPH_ACCESS_TOKEN` placeholder. OpenShell stores the non-injectable refresh material at the gateway, refreshes the Microsoft Graph access token before it expires, updates the provider record, and injects the current credential into newly launched sandbox processes. +Use a provider profile to keep Microsoft Graph access tokens short lived while sandboxes receive a stable `MS_GRAPH_ACCESS_TOKEN` placeholder. OpenShell stores the non-injectable refresh material at the gateway, refreshes the Microsoft Graph access token before it expires, updates the provider record, and injects the current credential into newly launched sandbox processes. After completing this tutorial, you have: @@ -43,14 +43,6 @@ Do not commit access tokens, refresh tokens, or local `.env` files. The commands -## Enable Providers v2 - -Enable provider profile policy composition on the active gateway: - -```shell -openshell settings set --global --key providers_v2_enabled --value true --yes -``` - ## Create a Microsoft Graph Provider Profile Create `microsoft-graph-mail.yaml` with this profile: diff --git a/docs/providers/google-vertex-ai.mdx b/docs/providers/google-vertex-ai.mdx index 21b9d4d371..5c577efd5a 100644 --- a/docs/providers/google-vertex-ai.mdx +++ b/docs/providers/google-vertex-ai.mdx @@ -105,13 +105,8 @@ OpenShell exposes Anthropic Vertex routes for inference only. It does not advert ## Configure Inference Routing -Before configuring inference routing, enable provider endpoint injection so the Vertex AI network endpoints are automatically included in sandbox policies: - -```shell -openshell settings set --global --key providers_v2_enabled --value true --yes -``` - -Then point `inference.local` at the provider: +Point `inference.local` at the provider. The Vertex AI profile contributes its +network endpoints to sandbox policies automatically: ```shell openshell inference set \ @@ -137,10 +132,7 @@ Agents inside sandboxes should reach Vertex AI through `inference.local`, not by The complete setup from scratch: ```shell -# 1. Enable provider endpoint injection -openshell settings set --global --key providers_v2_enabled --value true --yes - -# 2. Create the provider +# 1. Create the provider openshell provider create \ --name vertex-local \ --type google-vertex-ai \ @@ -148,10 +140,10 @@ openshell provider create \ --config VERTEX_AI_PROJECT_ID=my-gcp-project \ --config VERTEX_AI_REGION=us-central1 -# 3. Configure inference routing +# 2. Configure inference routing openshell inference set --provider vertex-local --model claude-sonnet-4-6 --no-verify -# 4. Create a sandbox with the provider attached +# 3. Create a sandbox with the provider attached openshell sandbox create --name my-sandbox --provider vertex-local ``` diff --git a/docs/sandboxes/providers-v2.mdx b/docs/providers/profiles.mdx similarity index 93% rename from docs/sandboxes/providers-v2.mdx rename to docs/providers/profiles.mdx index 49525d9143..c310c02b15 100644 --- a/docs/sandboxes/providers-v2.mdx +++ b/docs/providers/profiles.mdx @@ -1,24 +1,24 @@ --- # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -title: "Providers v2" -sidebar-title: "Providers v2" +title: "Profiles" +sidebar-title: "Profiles" description: "Use provider profiles to attach credentials, network policy, and refresh metadata to OpenShell sandboxes." keywords: "Generative AI, Cybersecurity, Providers, Provider Profiles, Credentials, Policy, Sandbox" -position: 5 +position: 1 --- -Providers v2 turns providers from credential records into profile-backed access bundles. A provider profile describes the credentials, endpoints, binaries, policy rules, and refresh behavior for a provider type. A provider instance stores the concrete credential and config values for one gateway. +Provider profiles turn providers from credential records into profile-backed access bundles. A provider profile describes the credentials, endpoints, binaries, policy rules, and refresh behavior for a provider type. A provider instance stores the concrete credential and config values for one gateway. -Use Providers v2 when you want provider-owned policy rules to travel with provider credentials. For example, a GitHub provider can describe both `GITHUB_TOKEN` and the GitHub API endpoints that a sandbox needs, so users do not have to copy the same network policy into every sandbox. +Use provider profiles when you want provider-owned policy rules to travel with provider credentials. For example, a GitHub provider can describe both `GITHUB_TOKEN` and the GitHub API endpoints that a sandbox needs, so users do not have to copy the same network policy into every sandbox. -## Why Providers v2 Exists +## Why Provider Profiles Exist Provider credentials and network policy were previously configured through separate workflows. A user could create a GitHub provider that stored `GITHUB_TOKEN`, but the sandbox still needed a separate policy that allowed `api.github.com`, selected the right binaries, and configured REST enforcement. -Providers v2 keeps those pieces together: +Provider profiles keep those pieces together: -| Need | Providers v2 behavior | +| Need | Profile-backed behavior | |---|---| | Repeatable provider setup | Built-in and custom provider profiles define reusable provider types. | | Provider-aware policy | Attached providers contribute `_provider_*` network policy entries to the effective sandbox policy. | @@ -27,32 +27,13 @@ Providers v2 keeps those pieces together: | Credential rotation | Provider refresh metadata lets the gateway refresh short-lived access tokens and update provider records. | | Credential transport | Credential delivery uses environment placeholders and proxy rewrite. Static placeholders resolve only at profile endpoints or explicitly bound sandbox policy endpoints for endpointless profiles. | -## Enable Providers v2 - -Provider profile policy composition is controlled by the gateway-level `providers_v2_enabled` setting. Enable it on the active gateway: - -```shell -openshell settings set --global --key providers_v2_enabled --value true -``` - -When the setting is disabled or unset, attached provider profiles do not add network policy entries to the effective policy. Static provider credential placeholders still use the profile endpoints as a resolution boundary. - -To disable provider profile policy composition, delete the setting: - -```shell -openshell settings delete --global --key providers_v2_enabled -``` - - -The feature flag controls provider-derived policy layers. It does not disable endpoint binding for static credential placeholders. Provider profiles can also declare dynamic token grants that the sandbox proxy resolves on demand for matching HTTP endpoints. - - ## Available Features -Providers v2 currently includes these user-facing features: +Provider profiles include these user-facing features: - Built-in provider profiles loaded by the gateway by default. - Gateway configuration can compose built-in, user-managed, and interceptor-vended profile sources. Selecting only an interceptor makes its catalog authoritative by omission. +- A user-managed profile shadows a built-in profile with the same ID in its applicable workspace or platform scope. The built-in remains the fallback outside that scope. Source-managed interceptor collisions still fail closed. - `openshell provider list-profiles` with table, YAML, and JSON output. - `openshell provider profile export`, `import`, `update`, `lint`, and `delete` for custom profiles. - Provider instances created from built-in or imported profile IDs with `openshell provider create --type `. @@ -74,6 +55,12 @@ supply that boundary by default. For an endpointless profile, a sandbox policy endpoint can name the attached provider instance explicitly. The proxy checks the resulting association before it substitutes the real value. +The built-in `openai` and `anthropic` profiles use their public vendor +endpoints. Configuring an alternate base URL makes those providers route-only: +OpenShell withholds their static credentials and fixed-vendor policy from +direct sandbox traffic while gateway inference routing continues to use the +configured upstream. + A request can use a static credential only when all of these checks pass: | Check | Configuration source | @@ -209,7 +196,7 @@ Later access-token rotations do not require workload restarts. ## Roadmap -The following Providers v2 design items are not part of the current behavior: +The following provider profile design items are not part of the current behavior: | Roadmap item | Current behavior | |---|---| @@ -241,10 +228,11 @@ of truth: list, export, provider creation, policy composition, and sandbox provider environment resolution use the interceptor-vended profiles instead of built-in or user-imported profiles. -Built-in Providers v2 profiles currently include: +Built-in provider profiles currently include: | Profile ID | Category | Credential environment variables | |---|---|---| +| `anthropic` | `inference` | `ANTHROPIC_API_KEY` | | `claude-code` | `agent` | `ANTHROPIC_API_KEY`, `CLAUDE_API_KEY` | | `codex` | `agent` | `CODEX_AUTH_ACCESS_TOKEN`, `CODEX_AUTH_REFRESH_TOKEN`, `CODEX_AUTH_ACCOUNT_ID`, `CODEX_AUTH_ID_TOKEN` | | `copilot` | `agent` | `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN` | @@ -253,6 +241,7 @@ Built-in Providers v2 profiles currently include: | `github` | `source_control` | `GITHUB_TOKEN`, `GH_TOKEN` | | `google-vertex-ai` | `inference` | `GOOGLE_SERVICE_ACCOUNT_KEY`, `GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN`, `VERTEX_AI_SERVICE_ACCOUNT_TOKEN`, `GOOGLE_VERTEX_AI_TOKEN`, `VERTEX_AI_TOKEN` | | `nvidia` | `inference` | `NVIDIA_API_KEY` | +| `openai` | `inference` | `OPENAI_API_KEY` | | `pypi` | `data` | None | Export a built-in profile as YAML: @@ -440,8 +429,8 @@ binaries: `credentials` declares the credential names, environment variables, auth metadata, optional refresh metadata, and optional dynamic token grant metadata for the provider type. The `auth_style` field accepts `basic`, `bearer`, `header`, `query`, or `path`. When `auth_style` is `path`, set `path_template` to a URL path containing the `{credential}` placeholder exactly once (for example, `/v1/{credential}/resources`). Static credentials are exposed as placeholder environment variables and resolved in outbound HTTP requests only at their binding endpoints. Every static credential environment key receives the full profile endpoint set when the profile defines endpoints. An endpointless profile requires explicit sandbox policy bindings for each attached provider instance. Dynamic token grants are resolved by the sandbox proxy on demand for matching profile endpoints and support `bearer` or `header` placement. Credential environment variable names must not use the reserved `v_` prefix, such as `v10_GITHUB_TOKEN`, because OpenShell uses that namespace for revision-scoped placeholders. -`discovery` controls what `--from-existing` scans when -`providers_v2_enabled=true`. Each entry in `discovery.credentials` must name a +`discovery` controls what `--from-existing` scans. Each entry in +`discovery.credentials` must name a credential declared under `credentials`. OpenShell scans the referenced credential's `env_vars` in order and stores the first non-empty local environment value under the actual environment variable key. @@ -465,7 +454,7 @@ Profile YAML can declare these refresh strategies: | `oauth2_refresh_token` | The gateway exchanges a refresh token for a short-lived access token. | | `oauth2_client_credentials` | The gateway mints a short-lived access token with OAuth2 client credentials. | | `google_service_account_jwt` | The gateway signs a Google service account JWT and exchanges it for an access token. | -| `aws_sts_assume_role` | The gateway calls `sts:AssumeRole` and mints `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SESSION_TOKEN` in one operation. Requires `providers_v2_enabled=true`. | +| `aws_sts_assume_role` | The gateway calls `sts:AssumeRole` and mints `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SESSION_TOKEN` in one operation. | `openshell provider refresh configure` accepts only gateway-mintable strategies: `oauth2-refresh-token`, `oauth2-client-credentials`, `google-service-account-jwt`, and `aws-sts-assume-role`. Use `openshell provider update` for `static` and `external` refresh patterns. @@ -582,15 +571,11 @@ openshell provider create \ --from-existing ``` -When `providers_v2_enabled=true`, `--from-existing` uses the provider profile's -`discovery` section. If no profile exists for the requested type, the command -fails instead of falling back to the legacy provider registry. When -`providers_v2_enabled=false`, `--from-existing` uses the legacy provider -registry and ignores profile `discovery` metadata. - -For example, with Providers v2 enabled, `--type openai --from-existing` -requires an imported `openai` profile with a `discovery` section. Setting -`OPENAI_API_KEY` alone is not enough for v2 profile discovery. +`--from-existing` uses the provider profile's `discovery` section. If no +profile exists for the requested type, the command fails instead of falling +back to the retired provider registry. The built-in `openai` profile discovers +`OPENAI_API_KEY`; the built-in `anthropic` profile discovers +`ANTHROPIC_API_KEY`. Create a provider from an imported custom profile: @@ -699,7 +684,7 @@ cannot replace or delete its primary credential or any co-minted output. Use management. You can still update unrelated credentials, configuration, and credential expiry metadata. -For a complete Microsoft Graph OAuth2 refresh-token walkthrough, see [Refresh Microsoft Graph Credentials with Providers v2](/get-started/tutorials/microsoft-graph-provider-refresh). +For a complete Microsoft Graph OAuth2 refresh-token walkthrough, see [Refresh Microsoft Graph Credentials with a Provider Profile](/get-started/tutorials/microsoft-graph-provider-refresh). The profile YAML strategy values use underscores, while the CLI `--strategy` values use kebab-case: @@ -846,13 +831,14 @@ openshell sandbox create \ -- claude ``` -When `providers_v2_enabled=true`, each attached provider with a matching profile contributes a provider policy layer to the sandbox effective policy. The base policy is the user-authored sandbox policy that you can edit and apply. The effective policy is the composed policy that the sandbox enforces: base policy plus provider policy layers. When the setting is disabled, the sandbox still receives endpoint-bound provider credentials but not provider-derived policy entries. +Each attached provider with a matching profile contributes a provider policy layer to the sandbox effective policy. The base policy is the user-authored sandbox policy that you can edit and apply. The effective policy is the composed policy that the sandbox enforces: base policy plus provider policy layers. A gateway-global policy suppresses provider-derived policy layers. Updating a custom provider profile affects every provider instance whose `type` matches that profile ID. Provider instances are not rewritten, and sandbox-authored policies are not modified. Running sandboxes observe the updated provider-derived policy on their next config sync. If a gateway-global policy is active, provider-derived policy layers remain suppressed. -Providers v2 does not infer or auto-attach providers from the sandbox command. -Attach providers explicitly with `--provider` during sandbox creation, or use -`openshell sandbox provider attach` after creation. +The CLI can infer a provider profile from a recognized sandbox command and +auto-create the provider from profile discovery. Attach a different existing +provider explicitly with `--provider`, or use `openshell sandbox provider +attach` after creation. List providers attached to a sandbox: diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index 64942a384b..a0c5d29737 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -216,7 +216,7 @@ global policies that use this field. Network policy admission does not expand the credential boundary unless the endpoint explicitly supplies this binding. OpenShell rejects a request mismatch with HTTP 403 and `credential_endpoint_mismatch`. Refer to [Static Credential Endpoint -Binding](/sandboxes/providers-v2#understand-static-credential-endpoint-binding). +Binding](/providers/profiles#understand-static-credential-endpoint-binding). This example allows the sandbox to reach Google Cloud Storage and binds the static credentials from the attached `work-gcp` provider to that endpoint: diff --git a/docs/sandboxes/inference-routing.mdx b/docs/sandboxes/inference-routing.mdx index 2c065c6cb4..1b47bfdf95 100644 --- a/docs/sandboxes/inference-routing.mdx +++ b/docs/sandboxes/inference-routing.mdx @@ -118,6 +118,12 @@ openshell provider create \ Replace the base URL and API key with the values from your provider. For supported providers out of the box, refer to [Supported Inference Providers](/sandboxes/manage-providers#supported-inference-providers). For other providers, refer to your provider's documentation for the correct base URL, available models, and API key setup. +This override is used by gateway inference routing. It disables the built-in +public OpenAI endpoint policy and credential binding for direct sandbox +traffic, so the alternate-upstream key cannot be substituted at +`api.openai.com`. Import an endpoint-bearing custom profile if a sandbox must +contact the alternate upstream directly. + @@ -147,6 +153,9 @@ openshell provider create \ Use `--config OPENAI_BASE_URL` to point to any OpenAI-compatible server running where the gateway runs. For host-backed local inference, use `host.openshell.internal` or the host's LAN IP. Avoid `127.0.0.1` and `localhost`. Set `OPENAI_API_KEY` to a dummy value if the server does not require authentication. +The override is route-only and does not add the local endpoint to sandbox +network policy. + For a self-contained setup, the Ollama sandbox bundles Ollama inside the sandbox itself, so no host-level provider is needed. Refer to [Inference Ollama](/get-started/tutorials/inference-ollama) for details. diff --git a/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index 66fc418041..ad162eca12 100644 --- a/docs/sandboxes/manage-providers.mdx +++ b/docs/sandboxes/manage-providers.mdx @@ -13,7 +13,9 @@ AI agents typically need credentials to access external services: an API key for Create and manage providers that supply credentials to sandboxes. -Providers v2 is available for profile-backed provider policy, provider-owned network rules, and gateway-managed credential refresh. This page remains the credential-focused provider command reference. For the new workflow, see [Providers v2](/sandboxes/providers-v2). +Provider profiles define provider credentials, policy, and refresh behavior. See +[Profiles](/providers/profiles) for the profile format and custom +profile workflow. Provider profiles include metadata for known endpoints and binaries. View @@ -89,22 +91,22 @@ the stored gateway access token is expired and a refresh token is available, the CLI refreshes it before storing the provider credential. It does not store the OIDC refresh token in the provider. -Provider profile metadata is available for known provider types. Provider profile -network policy is gateway opt-in: +`--from-existing` uses profile-backed discovery. The requested `--type` must +have a built-in or imported provider profile with a `discovery` section. If no +matching profile exists, the CLI returns an error instead of falling back to +legacy discovery. + +Create a provider whose profile requires no static credentials without a +credential source: ```shell -openshell settings set --global --key providers_v2_enabled --value true +openshell provider create --name public-pypi --type pypi ``` -Without `providers_v2_enabled=true`, attached provider profiles do not contribute -network policy to the sandbox. Static credential endpoint binding remains active -in either mode. - -When `providers_v2_enabled=true`, `--from-existing` uses profile-backed -discovery instead of the legacy provider registry. The requested `--type` must -have a built-in or imported provider profile with a `discovery` section. If no -matching profile exists, the CLI returns an error instead of falling back to -legacy discovery. +Provider creation rejects profileless types. For a custom GitLab deployment, +import a profile with the deployment's endpoints and then create an instance +using that profile ID. Existing profileless provider records remain usable, but +OpenShell does not create new ones. Update a custom provider profile after exporting it, editing its endpoints, binaries, or credential metadata, and preserving the exported `resource_version`: @@ -117,9 +119,8 @@ openshell provider profile update my-api -f my-api-profile.yaml Import remains create-only and fails if the profile ID already exists. Use `provider profile update ` for existing custom profiles. Built-in profiles are read-only. The target ID must match the profile ID in the file. Update accepts -one file at a time and rejects stale resource versions. When -`providers_v2_enabled=true`, updated profile policy applies to all provider -instances of that type on the next sandbox config sync. +one file at a time and rejects stale resource versions. Updated profile policy +applies to all provider instances of that type on the next sandbox config sync. Static credentials require a built-in or imported provider profile. Profiles @@ -218,17 +219,15 @@ openshell provider refresh rotate my-graph --credential-key MS_GRAPH_ACCESS_TOKE ### AWS STS -AWS STS refresh requires `providers_v2_enabled=true`. The gateway calls -`sts:AssumeRole` and writes three short-lived credentials (`AWS_ACCESS_KEY_ID`, -`AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`) to the provider record atomically. +The gateway calls `sts:AssumeRole` and writes three short-lived credentials +(`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`) to the +provider record atomically. The `aws` and `aws-s3` profiles declare `AWS_SECRET_ACCESS_KEY` and `AWS_SESSION_TOKEN` as `additional_outputs` of the `AWS_ACCESS_KEY_ID` refresh, so all three credentials are gateway-minted. Create the provider with `--runtime-credentials` — no placeholder credential is needed. ```shell -openshell settings set --global --key providers_v2_enabled --value true --yes - openshell provider create --name my-aws --type aws-s3 --runtime-credentials openshell provider refresh configure my-aws \ @@ -291,25 +290,23 @@ credentials from every attached provider as placeholders at runtime. Each static credential resolves only for endpoints in that provider's profile, or for explicitly bound sandbox policy endpoints when the profile is endpointless. Profile-managed providers also contribute provider-generated network policy -entries when `providers_v2_enabled` is enabled at the gateway. When the setting -is disabled, endpoint binding still applies, but provider-generated policy does -not. +entries. Endpoint binding independently limits where each static credential can +be resolved. - -Legacy provider attachment is fixed at sandbox creation time. Providers v2 adds -`openshell sandbox provider attach` and `openshell sandbox provider detach` for -running sandboxes. See [Providers v2](/sandboxes/providers-v2#attach-and-detach-providers) -for runtime attach and detach behavior. + +Use `--provider` to attach providers when creating a sandbox. To change the +providers attached to a running sandbox, use `openshell sandbox provider attach` +and `openshell sandbox provider detach`. See +[Profiles](/providers/profiles#attach-and-detach-providers) for details. - + ### Auto-Discovery Shortcut -When `providers_v2_enabled=false` and the trailing command in -`openshell sandbox create` is a recognized tool name (`claude`, `codex`, or -`opencode`), the CLI auto-creates the required provider from your local -credentials if one does not already exist. You do not need to create the -provider separately: +When the trailing command in `openshell sandbox create` maps to an available +profile, the CLI auto-creates the required provider from local profile +discovery if one does not already exist. You do not need to create the provider +separately: ```shell openshell sandbox create -- claude @@ -318,9 +315,8 @@ openshell sandbox create -- claude This detects `claude` as a known tool, finds your `ANTHROPIC_API_KEY`, creates a provider, attaches it to the sandbox, and launches Claude Code. -Providers v2 disables command-derived provider inference. When -`providers_v2_enabled=true`, create or import the provider profile, create the -provider instance, and pass `--provider ` explicitly. +If the inferred type has no built-in or imported profile, creation fails. Import +a custom profile first or pass an existing provider with `--provider `. ## How Credential Injection Works @@ -343,15 +339,13 @@ request. A plain sandbox policy endpoint does not grant credential use unless the profile already covers it or the endpoint explicitly binds an endpointless provider. -Endpoint binding applies whether `providers_v2_enabled` is enabled or disabled. -The setting controls provider policy composition only. Every static credential -declared by a provider receives the complete endpoint set from that provider's +Every static credential declared by a provider receives the complete endpoint set from that provider's profile, or the explicitly bound sandbox policy endpoints for an endpointless profile. Credential resolution requires the proxy to handle the request as HTTP. Raw `tls: skip` and non-HTTP tunnels remain opaque and do not support credential -rewrite. Refer to [Providers v2](/sandboxes/providers-v2#understand-static-credential-endpoint-binding) +rewrite. Refer to [Profiles](/providers/profiles#understand-static-credential-endpoint-binding) for endpoint matching and migration guidance. ### Supported injection locations @@ -397,8 +391,8 @@ there returns `credential_endpoint_mismatch`. For a custom service, define the credential in a custom provider profile. Put stable endpoints in the profile, or leave the profile endpointless and bind -each concrete provider instance from sandbox policy. Refer to [Provider -Profiles](/sandboxes/providers-v2#provider-profiles) for the profile workflow +each concrete provider instance from sandbox policy. Refer to +[Profiles](/providers/profiles#provider-profiles) for the profile workflow and schema. ## Supported Provider Types @@ -413,12 +407,9 @@ The following provider types are supported. | `codex` | `OPENAI_API_KEY` | OpenAI Codex | | `copilot` | `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN` | GitHub Copilot CLI | | `deepinfra` | `DEEPINFRA_API_KEY` | DeepInfra inference API | -| `generic` | User-defined | Legacy credential storage. Import an endpoint-bearing custom profile for credentials attached to a sandbox. | | `github` | `GITHUB_TOKEN`, `GH_TOKEN` | GitHub API and `gh` CLI. Refer to [GitHub Sandbox](/get-started/tutorials/github-sandbox). | -| `gitlab` | `GITLAB_TOKEN`, `GLAB_TOKEN`, `CI_JOB_TOKEN` | GitLab API, `glab` CLI | | `nvidia` | `NVIDIA_API_KEY` | NVIDIA API Catalog | -| `openai` | `OPENAI_API_KEY` | Any OpenAI-compatible endpoint. Set `--config OPENAI_BASE_URL` to point to the provider. Refer to [Inference Routing](/sandboxes/inference-routing). | -| `opencode` | `OPENCODE_API_KEY`, `OPENROUTER_API_KEY`, `OPENAI_API_KEY` | OpenCode | +| `openai` | `OPENAI_API_KEY` | OpenAI API. An `OPENAI_BASE_URL` override remains available for gateway inference routing but does not authorize the fixed public OpenAI endpoint for sandbox traffic. Refer to [Inference Routing](/sandboxes/inference-routing). | `ANTHROPIC_API_KEY` is an API key from [console.anthropic.com](https://console.anthropic.com), not a subscription token. Subscription users must generate a separate API key from the Anthropic Console. @@ -433,7 +424,7 @@ ID as the provider `--type`. ## Supported Inference Providers -The following providers have been tested with `inference.local`. Any provider that exposes an OpenAI-compatible API works with the `openai` type. Set `--config OPENAI_BASE_URL` to the provider's base URL and `--credential OPENAI_API_KEY` to your API key. +The following providers have been tested with `inference.local`. Any provider that exposes an OpenAI-compatible API works with the `openai` routing type. Set `--config OPENAI_BASE_URL` to the provider's base URL and `--credential OPENAI_API_KEY` to your API key. A base URL override makes the provider route-only: it does not contribute the built-in public OpenAI endpoint to sandbox policy or make the key substitutable there. Import an endpoint-bearing custom profile when a sandbox needs direct access to the alternate upstream. | Provider | Name | Type | Base URL | API Key Variable | |---|---|---|---|---| diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index c2337d49b9..f1948e3aff 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -253,7 +253,7 @@ openshell sandbox create --env API_KEY=sk-test --env DEBUG=1 -- my-agent Variables set with `--env` are available to all processes in the sandbox, including interactive shells and exec commands. -When an `--env` key looks like a credential — a known provider variable, or a name whose underscore-separated segments include a credential word such as `TOKEN`, `SECRET`, `PASSWORD`, `CREDENTIAL`, `API_KEY`, `ACCESS_KEY`, or `SECRET_KEY` (for example `DB_TOKEN` or `MY_ACCESS_KEY`) — `sandbox create` prints a non-blocking warning. Matching is on whole segments, so unrelated names like `TOKENIZERS_PARALLELISM` or `PASSWORDLESS_LOGIN` do not warn. The agent inside the sandbox can read plain environment values directly, so to hide a secret from the agent, attach it through a [provider](/sandboxes/providers-v2) with `--provider` instead. Suppress the warning with `--no-credential-warnings`. Detection uses the key name only; values are never inspected or printed. +When an `--env` key looks like a credential — a known provider variable, or a name whose underscore-separated segments include a credential word such as `TOKEN`, `SECRET`, `PASSWORD`, `CREDENTIAL`, `API_KEY`, `ACCESS_KEY`, or `SECRET_KEY` (for example `DB_TOKEN` or `MY_ACCESS_KEY`) — `sandbox create` prints a non-blocking warning. Matching is on whole segments, so unrelated names like `TOKENIZERS_PARALLELISM` or `PASSWORDLESS_LOGIN` do not warn. The agent inside the sandbox can read plain environment values directly, so to hide a secret from the agent, attach it through a [profile-backed provider](/providers/profiles) with `--provider` instead. Suppress the warning with `--no-credential-warnings`. Detection uses the key name only; values are never inspected or printed. You can also set per-command environment variables with `sandbox exec`: diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index b8a149a7bd..c5a34db14f 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -374,7 +374,7 @@ Static provider placeholders resolve only when the request host, port, and path also match an endpoint in the provider profile. A sandbox policy allow does not expand that binding. A mismatch returns HTTP 403 with `credential_endpoint_mismatch`. Refer to [Static Credential Endpoint -Binding](/sandboxes/providers-v2#understand-static-credential-endpoint-binding). +Binding](/providers/profiles#understand-static-credential-endpoint-binding). For example: @@ -614,7 +614,7 @@ Do not fix `credential_endpoint_mismatch` by widening sandbox policy. Export the provider profile with `openshell provider profile export -o yaml`. Update the custom provider profile only when the destination is an intended credential recipient. Refer to [Static Credential Endpoint -Binding](/sandboxes/providers-v2#understand-static-credential-endpoint-binding) +Binding](/providers/profiles#understand-static-credential-endpoint-binding) for the complete authorization model. For small changes, prefer `openshell policy update` over rewriting the full YAML: diff --git a/docs/sandboxes/policy-advisor.mdx b/docs/sandboxes/policy-advisor.mdx index 35e0073ec3..eeb13058e1 100644 --- a/docs/sandboxes/policy-advisor.mdx +++ b/docs/sandboxes/policy-advisor.mdx @@ -214,7 +214,7 @@ Findings are categorical. There is no severity tier. The reviewer reads the cate Before approval, the gateway rebuilds the candidate token from the live base policy, immutable provider rules, and non-secret credential metadata. When that token is unchanged, it reuses the persisted prover result instead of rerunning the prover. When it changes, the gateway evaluates and persists the refreshed candidate, leaves the chunk pending, and requires the reviewer to inspect and approve the new token. Edits and deduplicated resubmissions follow the same path. Merge, policy-shape, provider-composition, credential, or prover failures are shown as application errors and cannot be approved. Security notes flag concerns such as internal or private destinations and `allowed_ips`, wildcard hosts, hostless `allowed_ips`, ephemeral ports, and well-known database or service ports. Any prover finding or security note keeps the chunk pending in auto mode. -The full reasoning model lives in [`crates/openshell-prover/README.md`](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-prover/README.md). Provider profiles composed in via [Providers v2](/sandboxes/providers-v2) are part of the effective policy the prover reasons over. +The full reasoning model lives in [`crates/openshell-prover/README.md`](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-prover/README.md). Provider profiles composed in via [Profiles](/providers/profiles) are part of the effective policy the prover reasons over. ## Review Proposals diff --git a/e2e/python/oidc/oidc_auth_test.py b/e2e/python/oidc/oidc_auth_test.py index 1ed8d56fc2..39cd076af0 100644 --- a/e2e/python/oidc/oidc_auth_test.py +++ b/e2e/python/oidc/oidc_auth_test.py @@ -54,7 +54,7 @@ def test_admin_can_create_provider(self) -> None: provider=datamodel_pb2.Provider( metadata=datamodel_pb2.ObjectMeta(name="e2e-oidc-admin-test"), type="claude", - credentials={"API_KEY": "test-value"}, + credentials={"ANTHROPIC_API_KEY": "test-value"}, ) ) try: @@ -78,7 +78,7 @@ def test_user_cannot_create_provider(self) -> None: provider=datamodel_pb2.Provider( metadata=datamodel_pb2.ObjectMeta(name="e2e-oidc-user-blocked"), type="claude", - credentials={"API_KEY": "test-value"}, + credentials={"ANTHROPIC_API_KEY": "test-value"}, ) ) with pytest.raises(grpc.RpcError) as exc_info: diff --git a/e2e/python/oidc/workspace_authz_test.py b/e2e/python/oidc/workspace_authz_test.py index 8336b79078..29bc03bbb5 100644 --- a/e2e/python/oidc/workspace_authz_test.py +++ b/e2e/python/oidc/workspace_authz_test.py @@ -248,7 +248,7 @@ def _workspace_rpcs() -> list[tuple[str, Callable]]: name="authz-test", workspace=WS ), type="claude", - credentials={"K": "v"}, + credentials={"ANTHROPIC_API_KEY": "v"}, ), ), metadata=m, @@ -277,7 +277,7 @@ def _workspace_rpcs() -> list[tuple[str, Callable]]: name="nonexistent", workspace=WS ), type="claude", - credentials={"K": "v"}, + credentials={"ANTHROPIC_API_KEY": "v"}, ), ), metadata=m, @@ -666,7 +666,7 @@ def seed_provider(self, admin_ctx: Any, workspace: str) -> Any: name=prov_name, workspace=workspace ), type="claude", - credentials={"API_KEY": "test"}, + credentials={"ANTHROPIC_API_KEY": "test"}, ), ), metadata=metadata, @@ -977,7 +977,7 @@ def test_user_member_admin_operations_denied( name="user-blocked", workspace=WS ), type="claude", - credentials={"K": "v"}, + credentials={"ANTHROPIC_API_KEY": "v"}, ), ), metadata=user_md, @@ -1046,7 +1046,7 @@ def test_workspace_admin_can_create_provider( provider=datamodel_pb2.Provider( metadata=datamodel_pb2.ObjectMeta(name=prov_name, workspace=WS), type="claude", - credentials={"K": "v"}, + credentials={"ANTHROPIC_API_KEY": "v"}, ), ), metadata=user_md, diff --git a/e2e/python/test_sandbox_providers.py b/e2e/python/test_sandbox_providers.py index 40fd05a122..a924168a1b 100644 --- a/e2e/python/test_sandbox_providers.py +++ b/e2e/python/test_sandbox_providers.py @@ -99,68 +99,6 @@ def _delete_provider(stub: object, name: str) -> None: raise -@pytest.fixture -def providers_v2_enabled( - sandbox_client: SandboxClient, - _gateway_config_guard: None, -) -> Iterator[None]: - """Enable the gateway-global ``providers_v2_enabled`` opt-in for one test. - - Composing a provider's network policy onto a sandbox is gated behind this - setting, which defaults off; the built-in github profile's git-transport - rules only reach the sandbox with it enabled. - - The setting is gateway-global. Exclusivity against other xdist workers is - provided by the ``exclusive_gateway_config`` marker plus the autouse - ``_gateway_config_guard`` guard (see conftest): no concurrent worker is - mid-test while this fixture mutates and restores the setting, so none can - observe the transient value. Depending on the guard here also orders the - exclusive lock acquisition before the mutation. - - ``GetGatewayConfig`` returns known keys even when unset, with an empty - ``SettingValue`` (no populated oneof), so the setting is treated as present - only when its value oneof is set; otherwise restore is a delete. ``global`` - is a Python keyword, so it is passed through a dict expansion. - """ - stub = sandbox_client._stub - key = "providers_v2_enabled" - config = stub.GetGatewayConfig(sandbox_pb2.GetGatewayConfigRequest()) - prior_value = sandbox_pb2.SettingValue() - had_prior = ( - key in config.settings - and config.settings[key].WhichOneof("value") is not None - ) - if had_prior: - prior_value.CopyFrom(config.settings[key]) - - stub.UpdateConfig( - openshell_pb2.UpdateConfigRequest( - setting_key=key, - setting_value=sandbox_pb2.SettingValue(bool_value=True), - **{"global": True}, - ) - ) - try: - yield - finally: - if had_prior: - stub.UpdateConfig( - openshell_pb2.UpdateConfigRequest( - setting_key=key, - setting_value=prior_value, - **{"global": True}, - ) - ) - else: - stub.UpdateConfig( - openshell_pb2.UpdateConfigRequest( - setting_key=key, - delete_setting=True, - **{"global": True}, - ) - ) - - # =========================================================================== # Tests: placeholder visibility # =========================================================================== @@ -195,38 +133,24 @@ def read_env_var() -> str: assert value != "sk-e2e-test-key-12345" -def test_profileless_provider_credentials_fail_closed( - sandbox: Callable[..., Sandbox], +def test_profileless_provider_creation_is_rejected( sandbox_client: SandboxClient, ) -> None: - """Profileless credentials are withheld because they have no endpoint binding.""" - with provider( - sandbox_client._stub, - name="e2e-test-generic-provider-env", - provider_type="generic", - credentials={ - "CUSTOM_SERVICE_TOKEN": "token-generic-123", - "CUSTOM_SERVICE_URL": "https://internal.example.test/api", - }, - ) as provider_name: - spec = datamodel_pb2.SandboxSpec( - policy=_default_policy(), - providers=[provider_name], + """New providers must reference a built-in or imported profile.""" + with pytest.raises(grpc.RpcError) as exc_info: + sandbox_client._stub.CreateProvider( + openshell_pb2.CreateProviderRequest( + provider=datamodel_pb2.Provider( + metadata=datamodel_pb2.ObjectMeta( + name="e2e-test-profileless-provider" + ), + type="generic", + credentials={"CUSTOM_SERVICE_TOKEN": "token-generic-123"}, + ) + ) ) - - def read_generic_env_vars() -> str: - import os - - token = os.environ.get("CUSTOM_SERVICE_TOKEN", "NOT_SET") - url = os.environ.get("CUSTOM_SERVICE_URL", "NOT_SET") - return f"{token}|{url}" - - with sandbox(spec=spec, delete_on_exit=True) as sb: - result = sb.exec_python(read_generic_env_vars) - assert result.exit_code == 0, result.stderr - token, url = result.stdout.strip().split("|") - assert token == "NOT_SET" - assert url == "NOT_SET" + assert exc_info.value.code() == grpc.StatusCode.INVALID_ARGUMENT + assert "provider profile 'generic' was not found" in exc_info.value.details() def test_endpointless_profile_credentials_fail_closed_without_policy_binding( @@ -326,9 +250,7 @@ def read_nvidia_key() -> str: with sandbox(spec=spec, delete_on_exit=True) as sb: result = sb.exec_python(read_nvidia_key) assert result.exit_code == 0, result.stderr - assert _is_placeholder_for_env_key( - result.stdout.strip(), "NVIDIA_API_KEY" - ) + assert _is_placeholder_for_env_key(result.stdout.strip(), "NVIDIA_API_KEY") def test_attach_detach_updates_credentials_for_later_exec_launches( @@ -470,8 +392,12 @@ def test_update_provider_preserves_unset_credentials_and_config( openshell_pb2.CreateProviderRequest( provider=datamodel_pb2.Provider( metadata=datamodel_pb2.ObjectMeta(name=name), - type="generic", - credentials={"KEY_A": "val-a", "KEY_B": "val-b"}, + type="codex", + credentials={ + "CODEX_AUTH_ACCESS_TOKEN": "val-a", + "CODEX_AUTH_REFRESH_TOKEN": "val-b", + "CODEX_AUTH_ACCOUNT_ID": "account-id", + }, config={"BASE_URL": "https://example.com"}, ) ) @@ -482,7 +408,7 @@ def test_update_provider_preserves_unset_credentials_and_config( provider=datamodel_pb2.Provider( metadata=datamodel_pb2.ObjectMeta(name=name), type="", - credentials={"KEY_A": "rotated-a"}, + credentials={"CODEX_AUTH_ACCESS_TOKEN": "rotated-a"}, ) ) ) @@ -515,8 +441,8 @@ def test_update_provider_empty_maps_preserves_all( openshell_pb2.CreateProviderRequest( provider=datamodel_pb2.Provider( metadata=datamodel_pb2.ObjectMeta(name=name), - type="generic", - credentials={"TOKEN": "secret"}, + type="openai", + credentials={"OPENAI_API_KEY": "secret"}, config={"URL": "https://api.example.com"}, ) ) @@ -557,8 +483,8 @@ def test_update_provider_merges_config_preserves_credentials( openshell_pb2.CreateProviderRequest( provider=datamodel_pb2.Provider( metadata=datamodel_pb2.ObjectMeta(name=name), - type="generic", - credentials={"API_KEY": "original-key"}, + type="openai", + credentials={"OPENAI_API_KEY": "original-key"}, config={"ENDPOINT": "https://old.example.com"}, ) ) @@ -600,8 +526,8 @@ def test_update_provider_rejects_type_change( openshell_pb2.CreateProviderRequest( provider=datamodel_pb2.Provider( metadata=datamodel_pb2.ObjectMeta(name=name), - type="generic", - credentials={"KEY": "val"}, + type="openai", + credentials={"OPENAI_API_KEY": "val"}, ) ) ) @@ -626,8 +552,6 @@ def test_update_provider_rejects_type_change( # =========================================================================== -@pytest.mark.exclusive_gateway_config -@pytest.mark.usefixtures("providers_v2_enabled") def test_github_provider_allows_https_git_clone( sandbox: Callable[..., Sandbox], sandbox_client: SandboxClient, @@ -640,9 +564,7 @@ def test_github_provider_allows_https_git_clone( the sandbox, exercising provider attachment, effective-policy composition, TLS interception, and real git behavior end to end. git delegates HTTPS to a ``git-remote-https`` helper whose ancestor is ``/usr/bin/git``, so the - profile's git binary covers it via ancestor matching. The - ``providers_v2_enabled`` fixture turns on the gateway-global gate that - composes the provider's network policy. + profile's git binary covers it via ancestor matching. """ with provider( sandbox_client._stub, diff --git a/e2e/rust/tests/credential_gating.rs b/e2e/rust/tests/credential_gating.rs index f4e1c5cee7..25d5516543 100644 --- a/e2e/rust/tests/credential_gating.rs +++ b/e2e/rust/tests/credential_gating.rs @@ -221,7 +221,6 @@ enum EndpointMode { TlsSkip, L4OptIn, RestBody { rewrite: bool }, - WebSocket, } #[derive(Clone, Copy)] @@ -245,9 +244,6 @@ fn write_policy( EndpointMode::RestBody { rewrite } => format!( " protocol: rest\n access: full\n request_body_credential_rewrite: {rewrite}\n" ), - EndpointMode::WebSocket => { - " protocol: websocket\n access: read-write\n".to_string() - } }; let credential_binding = match credential_source { CredentialSource::ProviderProfile => String::new(), @@ -290,6 +286,27 @@ network_policies: Ok(file) } +fn write_base_policy() -> Result { + let mut file = NamedTempFile::new().map_err(|error| format!("create policy: {error}"))?; + file.write_all( + br#"version: 1 +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] +landlock: + compatibility: best_effort +process: + run_as_user: sandbox + run_as_group: sandbox +"#, + ) + .map_err(|error| format!("write policy: {error}"))?; + file.flush() + .map_err(|error| format!("flush policy: {error}"))?; + Ok(file) +} + #[derive(Debug, Default, Clone, Copy)] struct BodyObservation { saw_placeholder: bool, @@ -840,40 +857,41 @@ async fn run_body_sandbox( Ok(output) } -async fn assert_rest_body_backstop(server: &HttpProbeServer) -> Result<(), String> { - let denied = run_body_sandbox( - server.port, - EndpointMode::RestBody { rewrite: false }, - CredentialSource::ProviderProfile, - ) - .await?; - assert!(denied.contains("BODY_DENIED")); - - let rewritten = run_body_sandbox( - server.port, - EndpointMode::RestBody { rewrite: true }, - CredentialSource::ProviderProfile, - ) +async fn run_profile_body_sandbox(port: u16) -> Result { + let policy = write_base_policy()?; + let policy_path = policy + .path() + .to_str() + .ok_or_else(|| "body policy path is not UTF-8".to_string())?; + let script = body_client_script(port); + let mut sandbox = SandboxGuard::create(&[ + "--policy", + policy_path, + "--provider", + PROVIDER_NAME, + "--", + "python3", + "-c", + &script, + ]) .await?; - assert!(rewritten.contains("BODY_REWRITTEN")); - assert!(!rewritten.contains(TEST_SECRET)); - assert!(!rewritten.contains(PLACEHOLDER_PREFIX)); + let output = sandbox.create_output.clone(); + sandbox.cleanup().await; + Ok(output) +} - let observations = server.wait_for_observations(2).await; - assert_eq!(observations.len(), 2, "observations: {observations:?}"); +async fn assert_rest_body_backstop(server: &HttpProbeServer) -> Result<(), String> { + let denied = run_profile_body_sandbox(server.port).await?; + assert!(denied.contains("BODY_DENIED")); + let observations = server.wait_for_observations(1).await; + assert_eq!(observations.len(), 1, "observations: {observations:?}"); assert!(!observations[0].saw_placeholder); assert!(!observations[0].saw_secret); - assert!(!observations[1].saw_placeholder); - assert!(observations[1].saw_secret); Ok(()) } async fn assert_websocket_binary_denied(server: &BinaryWebSocketProbeServer) -> Result<(), String> { - let policy = write_policy( - server.port, - EndpointMode::WebSocket, - CredentialSource::ProviderProfile, - )?; + let policy = write_base_policy()?; let policy_path = policy .path() .to_str() @@ -914,7 +932,6 @@ async fn credentialed_endpoint_gates_work_end_to_end() { .expect("install credentialed provider"); let result = async { - assert_gateway_admission(server.port, CredentialSource::ProviderProfile).await?; assert_rest_body_backstop(&server).await?; assert_websocket_binary_denied(&websocket_server).await } @@ -935,10 +952,21 @@ async fn credentialed_endpoint_gates_work_end_to_end() { ) .await?; assert!(denied.contains("BODY_DENIED")); + let rewritten = run_body_sandbox( + server.port, + EndpointMode::RestBody { rewrite: true }, + CredentialSource::PolicyBinding, + ) + .await?; + assert!(rewritten.contains("BODY_REWRITTEN")); + assert!(!rewritten.contains(TEST_SECRET)); + assert!(!rewritten.contains(PLACEHOLDER_PREFIX)); let observations = server.wait_for_observations(3).await; assert_eq!(observations.len(), 3, "observations: {observations:?}"); + assert!(!observations[1].saw_placeholder); + assert!(!observations[1].saw_secret); assert!(!observations[2].saw_placeholder); - assert!(!observations[2].saw_secret); + assert!(observations[2].saw_secret); assert_endpointless_provider_env_live_update(server.port).await?; Ok::<(), String>(()) } diff --git a/e2e/rust/tests/oidc_pkce.rs b/e2e/rust/tests/oidc_pkce.rs index 93d71b9586..5e9ab2e7ae 100644 --- a/e2e/rust/tests/oidc_pkce.rs +++ b/e2e/rust/tests/oidc_pkce.rs @@ -136,9 +136,7 @@ async fn user_can_create_sandbox() { } /// Workspace users must be able to create sandboxes with inferred-provider -/// commands (e.g. `claude`). The CLI calls `GetGatewayConfig` to check -/// `providers_v2_enabled` before sandbox creation; that RPC must not be -/// gated to Platform Admin or the workspace-user flow breaks. +/// commands (e.g. `claude`) without requiring Platform Admin access. #[tokio::test] async fn user_can_create_sandbox_with_inferred_provider_command() { const WORKSPACE: &str = "oidc-inferred-cmd"; @@ -148,10 +146,9 @@ async fn user_can_create_sandbox_with_inferred_provider_command() { let _lifecycle = SANDBOX_LIFECYCLE_LOCK.lock().await; // Use `claude` as the command so the CLI infers provider type - // `claude-code` and calls `GetGatewayConfig` to check - // `providers_v2_enabled`. The sandbox won't actually start (no - // provider credentials), but we only care that the - // `GetGatewayConfig` call itself succeeds for a workspace user. + // `claude-code`. The sandbox won't actually start (no provider + // credentials), but provider inference must remain available to a + // workspace user. let output = run_workspace_cli( &user, WORKSPACE, @@ -277,9 +274,9 @@ async fn admin_can_manage_provider() { "--name", PROVIDER, "--type", - "generic", + "openai", "--credential", - "TOKEN=e2e-test-value", + "OPENAI_API_KEY=e2e-test-value", ], "create a provider", ) @@ -315,9 +312,9 @@ async fn user_cannot_create_provider() { "--name", "oidc-pkce-user-provider", "--type", - "generic", + "openai", "--credential", - "TOKEN=e2e-test-value", + "OPENAI_API_KEY=e2e-test-value", ], ) .await; @@ -341,9 +338,9 @@ async fn user_cannot_delete_provider() { "--name", PROVIDER, "--type", - "generic", + "openai", "--credential", - "TOKEN=e2e-test-value", + "OPENAI_API_KEY=e2e-test-value", ], "create the provider deletion target", ) @@ -536,9 +533,9 @@ async fn workspace_admin_can_create_provider() { "--name", PROVIDER, "--type", - "generic", + "openai", "--credential", - "TOKEN=e2e-test-value", + "OPENAI_API_KEY=e2e-test-value", ], "create a provider as workspace admin", ) @@ -570,9 +567,9 @@ async fn workspace_admin_can_delete_provider() { "--name", PROVIDER, "--type", - "generic", + "openai", "--credential", - "TOKEN=e2e-test-value", + "OPENAI_API_KEY=e2e-test-value", ], "create the provider deletion target", ) @@ -787,9 +784,9 @@ async fn workspace_admin_cannot_manage_another_workspace_providers() { "--name", "oidc-wsa-xprovider", "--type", - "generic", + "openai", "--credential", - "TOKEN=e2e-test-value", + "OPENAI_API_KEY=e2e-test-value", ], ) .await; @@ -1557,11 +1554,15 @@ fn assert_platform_admin_denial(output: &Output, action: &str) { fn assert_non_member_denial(output: &Output, action: &str) { let denied = combined_output(output); + let compact_denial: String = denied + .chars() + .filter(|character| !character.is_whitespace() && *character != '│') + .collect(); assert!( !output.status.success() - && denied + && compact_denial .to_ascii_lowercase() - .contains("not a member of workspace"), + .contains("notamemberofworkspace"), "non-member unexpectedly authorized to {action}, or denial omitted membership context:\n{denied}" ); } diff --git a/e2e/rust/tests/provider_refresh_keycloak.rs b/e2e/rust/tests/provider_refresh_keycloak.rs index 57bcb45fb7..b0ba3236a8 100644 --- a/e2e/rust/tests/provider_refresh_keycloak.rs +++ b/e2e/rust/tests/provider_refresh_keycloak.rs @@ -200,59 +200,6 @@ async fn delete_provider_resources() { let _ = run_cli(&["provider", "profile", "delete", PROFILE_ID], &[]).await; } -async fn read_providers_v2_setting() -> Result, String> { - let output = run_cli(&["settings", "get", "--global", "--json"], &[]).await?; - if !output.status.success() { - return Err(format!( - "read global settings failed (exit {:?}):\n{}", - output.status.code(), - combined_output(&output) - )); - } - let response: Value = serde_json::from_slice(&output.stdout) - .map_err(|error| format!("decode global settings: {error}"))?; - let value = response - .get("settings") - .and_then(|settings| settings.get("providers_v2_enabled")) - .and_then(Value::as_str) - .filter(|value| *value != "") - .map(ToString::to_string); - Ok(value) -} - -async fn restore_providers_v2_setting(previous: Option<&str>) -> Result<(), String> { - if let Some(value) = previous { - run_cli_success( - &[ - "settings", - "set", - "--global", - "--key", - "providers_v2_enabled", - "--value", - value, - "--yes", - ], - &[], - ) - .await?; - } else { - run_cli_success( - &[ - "settings", - "delete", - "--global", - "--key", - "providers_v2_enabled", - "--yes", - ], - &[], - ) - .await?; - } - Ok(()) -} - #[tokio::test] async fn revoked_refresh_grant_requires_user_reauthorization() -> Result<(), String> { let issuer = std::env::var("OPENSHELL_E2E_OIDC_ISSUER") @@ -261,7 +208,6 @@ async fn revoked_refresh_grant_requires_user_reauthorization() -> Result<(), Str .map_err(|_| "OPENSHELL_E2E_OIDC_USERNAME is required".to_string())?; let password = std::env::var("OPENSHELL_E2E_OIDC_PASSWORD") .map_err(|_| "OPENSHELL_E2E_OIDC_PASSWORD is required".to_string())?; - let previous_providers_v2_setting = read_providers_v2_setting().await?; let (access_token, refresh_token) = acquire_keycloak_grant(&issuer, &username, &password).await?; let profile = write_profile(&issuer)?; @@ -269,20 +215,6 @@ async fn revoked_refresh_grant_requires_user_reauthorization() -> Result<(), Str delete_provider_resources().await; let result = async { - run_cli_success( - &[ - "settings", - "set", - "--global", - "--key", - "providers_v2_enabled", - "--value", - "true", - "--yes", - ], - &[], - ) - .await?; run_cli_success( &["provider", "profile", "import", "--file", &profile_path], &[], @@ -404,14 +336,5 @@ async fn revoked_refresh_grant_requires_user_reauthorization() -> Result<(), Str .await; delete_provider_resources().await; - let cleanup_result = - restore_providers_v2_setting(previous_providers_v2_setting.as_deref()).await; - match (result, cleanup_result) { - (Ok(()), Ok(())) => Ok(()), - (Err(test_error), Ok(())) => Err(test_error), - (Ok(()), Err(cleanup_error)) => Err(cleanup_error), - (Err(test_error), Err(cleanup_error)) => Err(format!( - "{test_error}\ncleanup also failed: {cleanup_error}" - )), - } + result } diff --git a/e2e/rust/tests/provider_token_exchange.rs b/e2e/rust/tests/provider_token_exchange.rs index 8747a4b279..c4e06f029f 100644 --- a/e2e/rust/tests/provider_token_exchange.rs +++ b/e2e/rust/tests/provider_token_exchange.rs @@ -835,19 +835,6 @@ async fn podman_provider_token_exchange_injects_bearer_header() { let _gateway_token = start_gateway_token_endpoint(token_port).await; let _target = start_protected_target(target_port).await; - run_cli(&[ - "settings", - "set", - "--global", - "--key", - "providers_v2_enabled", - "--value", - "true", - "--yes", - ]) - .await - .expect("enable providers v2"); - run_cli_ignore_error(&["provider", "delete", &provider_name, "--yes"]).await; run_cli_ignore_error(&["provider", "profile", "delete", &profile_type, "--yes"]).await; diff --git a/e2e/rust/tests/workspace_lifecycle.rs b/e2e/rust/tests/workspace_lifecycle.rs index fa1d2cb2c9..2fe946389e 100644 --- a/e2e/rust/tests/workspace_lifecycle.rs +++ b/e2e/rust/tests/workspace_lifecycle.rs @@ -85,9 +85,9 @@ async fn workspace_full_crud_lifecycle() { "--name", PROVIDER, "--type", - "generic", + "openai", "--credential", - "TOKEN=test-value", + "OPENAI_API_KEY=test-value", "--workspace", WORKSPACE, ]) @@ -206,9 +206,9 @@ async fn workspace_terminating_rejects_creates() { "--name", PROVIDER_TERM, "--type", - "generic", + "openai", "--credential", - "TOKEN=test-value", + "OPENAI_API_KEY=test-value", "--workspace", WORKSPACE_TERM, ]) @@ -239,9 +239,9 @@ async fn workspace_terminating_rejects_creates() { "--name", "should-fail", "--type", - "generic", + "openai", "--credential", - "TOKEN=test-value", + "OPENAI_API_KEY=test-value", "--workspace", WORKSPACE_TERM, ]) diff --git a/examples/agent-driven-policy-management/demo.sh b/examples/agent-driven-policy-management/demo.sh index 36bef4ded6..b123c66ad2 100755 --- a/examples/agent-driven-policy-management/demo.sh +++ b/examples/agent-driven-policy-management/demo.sh @@ -173,20 +173,6 @@ cleanup() { fi fi - # Restore the providers_v2_enabled setting to what it was before this - # run. The demo opts in to v2 composition so provider profiles - # contribute to the effective policy; restore so the host's broader - # workflow isn't affected. - if [[ -n "${PRIOR_PROVIDERS_V2_FLAG:-}" ]]; then - if [[ "$PRIOR_PROVIDERS_V2_FLAG" == "(unset)" ]]; then - "$OPENSHELL_BIN" settings delete --global --key providers_v2_enabled --yes \ - >/dev/null 2>&1 || true - else - "$OPENSHELL_BIN" settings set --global --key providers_v2_enabled \ - --value "$PRIOR_PROVIDERS_V2_FLAG" --yes >/dev/null 2>&1 || true - fi - fi - if [[ $status -eq 0 ]]; then rm -rf "$TMP_DIR" else @@ -347,7 +333,7 @@ create_providers() { "$OPENSHELL_BIN" provider create \ --name "$DEMO_CODEX_PROVIDER_NAME" \ - --type generic \ + --type codex \ --credential CODEX_AUTH_ACCESS_TOKEN \ --credential CODEX_AUTH_REFRESH_TOKEN \ --credential CODEX_AUTH_ACCOUNT_ID >/dev/null @@ -355,7 +341,7 @@ create_providers() { "$OPENSHELL_BIN" provider create \ --name "$DEMO_GITHUB_PROVIDER_NAME" \ --type github \ - --credential DEMO_GITHUB_TOKEN >/dev/null + --credential "GITHUB_TOKEN=$DEMO_GITHUB_TOKEN" >/dev/null info "providers created (codex, github) — credentials injected as env vars only" } @@ -631,19 +617,6 @@ enable_agent_proposals() { || fail "could not enable agent_policy_proposals_enabled globally" } -enable_providers_v2() { - # Providers-v2 composition is behind a global flag. The demo opts in - # so provider profiles (codex, github) contribute to the effective - # policy via composition. Cleanup restores the prior value. - local prior - prior="$("$OPENSHELL_BIN" settings get --global --json 2>/dev/null \ - | jq -r '.settings.providers_v2_enabled // empty | tostring | select(. == "true" or . == "false")')" - PRIOR_PROVIDERS_V2_FLAG="${prior:-(unset)}" - "$OPENSHELL_BIN" settings set --global \ - --key providers_v2_enabled --value true --yes >/dev/null \ - || fail "could not enable providers_v2_enabled globally" -} - main() { validate_env @@ -653,7 +626,6 @@ main() { render_payload create_providers enable_agent_proposals - enable_providers_v2 show_run_summary diff --git a/examples/aws-s3-sts.md b/examples/aws-s3-sts.md index e622e71117..ed0c204d6b 100644 --- a/examples/aws-s3-sts.md +++ b/examples/aws-s3-sts.md @@ -129,9 +129,6 @@ In a separate terminal: ```shell export OPENSHELL_BASE_URL=http://localhost:18080 -# Enable provider v2 (required for STS) -openshell settings set --global --key providers_v2_enabled --value true --yes - # Create the provider with the aws-s3 profile. All three credentials are # gateway-minted via STS, so no static credential is needed. openshell provider create --name s3-test --type aws-s3 --runtime-credentials diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh index 88610cf1ee..feaa42df05 100755 --- a/examples/governance-interceptor/smoke.sh +++ b/examples/governance-interceptor/smoke.sh @@ -434,7 +434,6 @@ configure_gateway() { --gateway-endpoint "$GATEWAY_ENDPOINT" ) - run_setup_step "enabling provider profile policy composition" "${CLI[@]}" settings set --global --key providers_v2_enabled --value true --yes wait_for_profile "github" wait_for_profile "slack" } diff --git a/examples/governance-interceptor/src/tests.rs b/examples/governance-interceptor/src/tests.rs index 4575fe2ac8..1df9c25727 100644 --- a/examples/governance-interceptor/src/tests.rs +++ b/examples/governance-interceptor/src/tests.rs @@ -827,7 +827,7 @@ fn policy_update_and_merge_are_denied() { .evaluate_inner(&evaluation( "UpdateConfig", GatewayInterceptorPhase::Validate, - json!({"global": true, "settingKey": "providers_v2_enabled"}), + json!({"global": true, "settingKey": "agent_policy_proposals_enabled"}), )) .unwrap(); assert!(settings_update.allowed); diff --git a/examples/multi-agent-notepad/demo.sh b/examples/multi-agent-notepad/demo.sh index e56d303c17..0d68b3cdbf 100755 --- a/examples/multi-agent-notepad/demo.sh +++ b/examples/multi-agent-notepad/demo.sh @@ -143,15 +143,15 @@ create_providers() { "$OPENSHELL_BIN" provider create \ --name "$DEMO_CODEX_PROVIDER_NAME" \ - --type generic \ + --type codex \ --credential CODEX_AUTH_ACCESS_TOKEN \ --credential CODEX_AUTH_REFRESH_TOKEN \ --credential CODEX_AUTH_ACCOUNT_ID >/dev/null "$OPENSHELL_BIN" provider create \ --name "$DEMO_GITHUB_PROVIDER_NAME" \ - --type generic \ - --credential DEMO_GITHUB_TOKEN >/dev/null + --type github \ + --credential "GITHUB_TOKEN=$DEMO_GITHUB_TOKEN" >/dev/null } run_sandbox() { diff --git a/examples/spiffe-token-exchange-demo/README.md b/examples/spiffe-token-exchange-demo/README.md index bd0ca44a89..89ddc09e69 100644 --- a/examples/spiffe-token-exchange-demo/README.md +++ b/examples/spiffe-token-exchange-demo/README.md @@ -39,7 +39,6 @@ When a sandbox curls `alpha-exchange` or `beta-exchange`: - Gateway and supervisor access to SPIRE OIDC/JWKS discovery. - OpenShell configured with the Kubernetes ServiceAccount supervisor bootstrap path. -- `providers_v2_enabled=true` on the target gateway. - Local `curl`, `python3`, `openssl`, `nc`, `kubectl`, and `openshell`. - A registered and logged-in CLI gateway. The script uses `GATEWAY_NAME`, then `OPENSHELL_GATEWAY`, then the active OpenShell gateway selection. @@ -129,9 +128,6 @@ Then run: ```bash export GATEWAY=https://127.0.0.1:8097 -openshell --gateway "$OPENSHELL_GATEWAY" --gateway-endpoint "$GATEWAY" settings set \ - --global --key providers_v2_enabled --value true --yes - openshell --gateway "$OPENSHELL_GATEWAY" --gateway-endpoint "$GATEWAY" provider profile import \ -f "$OPENSHELL_REPO/examples/spiffe-token-exchange-demo/provider-profile.yaml" diff --git a/examples/spiffe-token-exchange-demo/demo.sh b/examples/spiffe-token-exchange-demo/demo.sh index e50f989454..a123f554ab 100755 --- a/examples/spiffe-token-exchange-demo/demo.sh +++ b/examples/spiffe-token-exchange-demo/demo.sh @@ -204,7 +204,6 @@ SUBJECT_TOKEN="$(curl -fsS "http://127.0.0.1:${TOKEN_ISSUER_PORT}/demo-subject-t "${OS[@]}" provider delete "$PROVIDER_NAME" >/dev/null 2>&1 || true "${OS[@]}" provider profile delete "$PROFILE_ID" >/dev/null 2>&1 || true -run "${OS[@]}" settings set --global --key providers_v2_enabled --value true --yes run "${OS[@]}" provider profile lint -f "$PROFILE_FILE" run "${OS[@]}" provider profile import -f "$PROFILE_FILE" run "${OS[@]}" provider create --name "$PROVIDER_NAME" --type "$PROFILE_ID" --credential "subject_token=${SUBJECT_TOKEN}" diff --git a/examples/spiffe-token-exchange-demo/podman/demo.sh b/examples/spiffe-token-exchange-demo/podman/demo.sh index 0c943c15f6..6b5804a523 100755 --- a/examples/spiffe-token-exchange-demo/podman/demo.sh +++ b/examples/spiffe-token-exchange-demo/podman/demo.sh @@ -641,7 +641,6 @@ SUBJECT_TOKEN="$(curl -fsS "http://127.0.0.1:${TOKEN_ISSUER_PORT}/demo-subject-t "${OS[@]}" provider delete "$PROVIDER_NAME" >/dev/null 2>&1 || true "${OS[@]}" provider profile delete "$PROFILE_ID" >/dev/null 2>&1 || true -run "${OS[@]}" settings set --global --key providers_v2_enabled --value true --yes run "${OS[@]}" provider profile lint -f "$RENDERED_PROFILE" run "${OS[@]}" provider profile import -f "$RENDERED_PROFILE" run "${OS[@]}" provider create --name "$PROVIDER_NAME" --type "$PROFILE_ID" --credential "subject_token=${SUBJECT_TOKEN}" diff --git a/examples/spiffe-token-grant-demo/README.md b/examples/spiffe-token-grant-demo/README.md index cb622ccbae..df4597c666 100644 --- a/examples/spiffe-token-grant-demo/README.md +++ b/examples/spiffe-token-grant-demo/README.md @@ -24,7 +24,6 @@ request. - SPIRE enabled for provider token grants. - OpenShell configured with the Kubernetes ServiceAccount supervisor bootstrap path. Gateway end-user OIDC is not required for this demo. -- `providers_v2_enabled=true` on the target gateway. For the Helm dev environment, deploy with the SPIRE releases and `ci/values-spire.yaml` enabled in `deploy/helm/openshell/skaffold.yaml`. @@ -60,9 +59,6 @@ Then run: export XDG_CONFIG_HOME="$(mktemp -d)" export GATEWAY=http://127.0.0.1:8097 -openshell --gateway-endpoint "$GATEWAY" settings set \ - --global --key providers_v2_enabled --value true --yes - openshell --gateway-endpoint "$GATEWAY" provider profile import \ -f examples/spiffe-token-grant-demo/provider-profile.yaml diff --git a/examples/spiffe-token-grant-demo/demo.sh b/examples/spiffe-token-grant-demo/demo.sh index f148d869fd..8013755a7d 100755 --- a/examples/spiffe-token-grant-demo/demo.sh +++ b/examples/spiffe-token-grant-demo/demo.sh @@ -111,7 +111,6 @@ wait_for_port_forward "${OS[@]}" provider delete "$PROVIDER_NAME" >/dev/null 2>&1 || true "${OS[@]}" provider profile delete "$PROFILE_ID" >/dev/null 2>&1 || true -run "${OS[@]}" settings set --global --key providers_v2_enabled --value true --yes run "${OS[@]}" provider profile lint -f "$PROFILE_FILE" run "${OS[@]}" provider profile import -f "$PROFILE_FILE" run "${OS[@]}" provider create --name "$PROVIDER_NAME" --type "$PROFILE_ID" --runtime-credentials diff --git a/fern/docs.yml b/fern/docs.yml index c0aeb62cc1..65639bd5d0 100644 --- a/fern/docs.yml +++ b/fern/docs.yml @@ -61,6 +61,8 @@ versions: slug: latest redirects: + - source: "/openshell/latest/sandboxes/providers-v2" + destination: "/openshell/latest/providers/profiles" # Paths are relative to the site root; subpath prefix matches instances + custom-domain. # Legacy HTML URLs used .../path/to/page/index.html; Fern canonical URLs omit index.html. # List explicit /index.html routes before :path*/index.html so empty path segments do not diff --git a/proto/openshell.proto b/proto/openshell.proto index 42e635927f..a2d7a0ad02 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -364,9 +364,8 @@ service OpenShell { }; } - // Get gateway-global settings (read-only feature flags; any authenticated - // user may read these so the CLI and TUI can discover capabilities like - // providers_v2_enabled without requiring Platform Admin). + // Get gateway-global settings (read-only runtime configuration; any + // authenticated user may read these without requiring Platform Admin). // // Scope-only (no role): scopes are granted by the IdP at token issuance, // orthogonal to workspace membership. Deployments that enable scope diff --git a/providers/anthropic.yaml b/providers/anthropic.yaml new file mode 100644 index 0000000000..752bff6e4f --- /dev/null +++ b/providers/anthropic.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: anthropic +display_name: Anthropic +description: Anthropic inference API +category: inference +inference_capable: true +credentials: + - name: api_key + description: Anthropic API key + env_vars: [ANTHROPIC_API_KEY] + required: true + auth_style: header + header_name: x-api-key +discovery: + credentials: [api_key] +endpoints: + - host: api.anthropic.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce +binaries: [/usr/bin/curl, /usr/local/bin/curl] diff --git a/providers/aws-s3.yaml b/providers/aws-s3.yaml index 699f067ff1..f51ffedb9a 100644 --- a/providers/aws-s3.yaml +++ b/providers/aws-s3.yaml @@ -71,7 +71,6 @@ endpoints: enforcement: enforce credential_signing: sigv4 signing_service: s3 - signing_region: us-east-1 - host: "s3.amazonaws.com" port: 443 protocol: rest @@ -80,7 +79,6 @@ endpoints: enforcement: enforce credential_signing: sigv4 signing_service: s3 - signing_region: us-east-1 - host: "*.s3.*.amazonaws.com" port: 443 protocol: rest diff --git a/providers/openai.yaml b/providers/openai.yaml new file mode 100644 index 0000000000..fe09cb0ed4 --- /dev/null +++ b/providers/openai.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: openai +display_name: OpenAI +description: OpenAI inference API +category: inference +inference_capable: true +credentials: + - name: api_key + description: OpenAI API key + env_vars: [OPENAI_API_KEY] + required: true + auth_style: bearer + header_name: authorization +discovery: + credentials: [api_key] +endpoints: + - host: api.openai.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce +binaries: [/usr/bin/curl, /usr/local/bin/curl] diff --git a/scripts/agents/gator/README.md b/scripts/agents/gator/README.md index 3bdb4f490a..c64bb068f9 100644 --- a/scripts/agents/gator/README.md +++ b/scripts/agents/gator/README.md @@ -34,7 +34,7 @@ The launcher: - Selects the requested harness and bakes the common runtime into the immutable sandbox payload. - For `--harness codex`, imports `providers/codex-gator.yaml`, creates or updates the `codex-gator` provider from `$HOME/.codex/auth.json`, and stores the refresh token as gateway-only refresh material. - For `--harness codex`, configures gateway-managed refresh for `CODEX_AUTH_ACCESS_TOKEN` and rotates it before launching the sandbox. -- Enables `providers_v2_enabled`, `agent_policy_proposals_enabled`, and `proposal_approval_mode=auto` at gateway scope. +- Enables `agent_policy_proposals_enabled` and `proposal_approval_mode=auto` at gateway scope. - Uses the gator image policy copied to `/etc/openshell/policy.yaml`. - Installs the gator-specific `gh` wrapper from `gator/bin/gh` as `/usr/local/bin/gh` to fail closed when same-head-SHA history cannot be checked, prevent duplicate dispositions, and require versioned review payloads. - Installs `gator/bin/review-feedback-ledger` as `/usr/local/bin/review-feedback-ledger` so reviews receive tree- and patch-aware scope, prior summaries and findings, resolution state, convergence telemetry, and the three-round Warning budget. diff --git a/scripts/agents/gator/agent.yaml b/scripts/agents/gator/agent.yaml index f4eb082ac1..363cea207f 100644 --- a/scripts/agents/gator/agent.yaml +++ b/scripts/agents/gator/agent.yaml @@ -28,8 +28,6 @@ profile_paths: - providers settings: - - key: providers_v2_enabled - value: true - key: agent_policy_proposals_enabled value: true - key: proposal_approval_mode diff --git a/sdk/go/proto/openshellv1/openshell_grpc.pb.go b/sdk/go/proto/openshellv1/openshell_grpc.pb.go index 0d98c66ac8..9220f22999 100644 --- a/sdk/go/proto/openshellv1/openshell_grpc.pb.go +++ b/sdk/go/proto/openshellv1/openshell_grpc.pb.go @@ -183,9 +183,8 @@ type OpenShellClient interface { DeleteProviderProfile(ctx context.Context, in *DeleteProviderProfileRequest, opts ...grpc.CallOption) (*DeleteProviderProfileResponse, error) // Get sandbox settings by id (called by sandbox entrypoint and poll loop). GetSandboxConfig(ctx context.Context, in *sandboxv1.GetSandboxConfigRequest, opts ...grpc.CallOption) (*sandboxv1.GetSandboxConfigResponse, error) - // Get gateway-global settings (read-only feature flags; any authenticated - // user may read these so the CLI and TUI can discover capabilities like - // providers_v2_enabled without requiring Platform Admin). + // Get gateway-global settings (read-only runtime configuration; any + // authenticated user may read these without requiring Platform Admin). // // Scope-only (no role): scopes are granted by the IdP at token issuance, // orthogonal to workspace membership. Deployments that enable scope @@ -1107,9 +1106,8 @@ type OpenShellServer interface { DeleteProviderProfile(context.Context, *DeleteProviderProfileRequest) (*DeleteProviderProfileResponse, error) // Get sandbox settings by id (called by sandbox entrypoint and poll loop). GetSandboxConfig(context.Context, *sandboxv1.GetSandboxConfigRequest) (*sandboxv1.GetSandboxConfigResponse, error) - // Get gateway-global settings (read-only feature flags; any authenticated - // user may read these so the CLI and TUI can discover capabilities like - // providers_v2_enabled without requiring Platform Admin). + // Get gateway-global settings (read-only runtime configuration; any + // authenticated user may read these without requiring Platform Admin). // // Scope-only (no role): scopes are granted by the IdP at token issuance, // orthogonal to workspace membership. Deployments that enable scope From 11dd373c6ecda141bd4f489e96cdf1fe1804449b Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Tue, 1 Sep 2026 19:28:16 +0000 Subject: [PATCH 074/505] fix(cli): size auto-detected interactive exec terminals (#3084) * fix(cli): size auto-detected interactive exec terminals Signed-off-by: Evan Lezar * fix(gateway): default unary exec PTY dimensions Signed-off-by: Evan Lezar --------- Signed-off-by: Evan Lezar --- crates/openshell-cli/src/run.rs | 30 +++++++++++++------- crates/openshell-server/src/grpc/sandbox.rs | 31 +++++++++++++++++++-- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 658251b34e..da2a6a6230 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -1466,6 +1466,12 @@ pub async fn sandbox_get( /// data into memory before the server rejects an oversized message. const MAX_STDIN_PAYLOAD: usize = 4 * 1024 * 1024; +fn local_terminal_size() -> Option<(u32, u32)> { + crossterm::terminal::size() + .ok() + .map(|(cols, rows)| (u32::from(cols), u32::from(rows))) +} + /// Execute a command in a running sandbox via gRPC, streaming output to the terminal. /// /// Returns the remote command's exit code, or an error if the event stream @@ -1535,7 +1541,7 @@ pub async fn sandbox_exec_grpc( let tty = tty_override .unwrap_or_else(|| std::io::stdin().is_terminal() && std::io::stdout().is_terminal()); - if tty_override == Some(true) && std::io::stdin().is_terminal() { + if tty && std::io::stdin().is_terminal() { return sandbox_exec_interactive_grpc( client, &sandbox, @@ -1548,6 +1554,12 @@ pub async fn sandbox_exec_grpc( .await; } + let (cols, rows) = if tty { + local_terminal_size().unwrap_or_default() + } else { + (0, 0) + }; + // Make the streaming gRPC call. let mut stream = client .exec_sandbox(ExecSandboxRequest { @@ -1558,8 +1570,9 @@ pub async fn sandbox_exec_grpc( timeout_seconds, stdin: stdin_payload, tty, + cols, + rows, no_login_shell, - ..Default::default() }) .await .into_diagnostic()? @@ -1926,7 +1939,7 @@ async fn sandbox_exec_interactive_grpc( use openshell_core::proto::{ExecSandboxInput, exec_sandbox_input}; use tokio_stream::wrappers::ReceiverStream; - let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + let (cols, rows) = local_terminal_size().unwrap_or((80, 24)); let (input_tx, input_rx) = tokio::sync::mpsc::channel::(4096); @@ -1942,8 +1955,8 @@ async fn sandbox_exec_interactive_grpc( timeout_seconds, stdin: Vec::new(), tty: true, - cols: u32::from(cols), - rows: u32::from(rows), + cols, + rows, })), }) .await @@ -1993,13 +2006,10 @@ async fn sandbox_exec_interactive_grpc( tokio::signal::unix::signal(tokio::signal::unix::SignalKind::window_change()) .expect("failed to register SIGWINCH handler"); while sig.recv().await.is_some() { - if let Ok((c, r)) = crossterm::terminal::size() { + if let Some((cols, rows)) = local_terminal_size() { let msg = ExecSandboxInput { payload: Some(exec_sandbox_input::Payload::Resize( - ExecSandboxWindowResize { - cols: u32::from(c), - rows: u32::from(r), - }, + ExecSandboxWindowResize { cols, rows }, )), }; if resize_tx.send(msg).await.is_err() { diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 80f8ba8ac5..de658909b0 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -1195,6 +1195,16 @@ fn is_watch_terminal(phase: SandboxPhase) -> bool { // Exec handler // --------------------------------------------------------------------------- +const DEFAULT_PTY_COLS: u32 = 80; +const DEFAULT_PTY_ROWS: u32 = 24; + +fn pty_dimensions(cols: u32, rows: u32) -> (u32, u32) { + ( + if cols == 0 { DEFAULT_PTY_COLS } else { cols }, + if rows == 0 { DEFAULT_PTY_ROWS } else { rows }, + ) +} + pub(super) async fn handle_exec_sandbox( state: &Arc, request: Request, @@ -1236,6 +1246,7 @@ pub(super) async fn handle_exec_sandbox( let stdin_payload = req.stdin; let timeout_seconds = req.timeout_seconds; let request_tty = req.tty; + let (cols, rows) = pty_dimensions(req.cols, req.rows); let sandbox_id = sandbox.object_id().to_string(); @@ -1260,6 +1271,8 @@ pub(super) async fn handle_exec_sandbox( timeout_seconds, request_tty, no_login_shell, + cols, + rows, ) .await { @@ -1671,8 +1684,7 @@ pub(super) async fn handle_exec_sandbox_interactive( let request_tty = req.tty; let no_login_shell = req.no_login_shell; let timeout_seconds = req.timeout_seconds; - let cols = if req.cols == 0 { 80 } else { req.cols }; - let rows = if req.rows == 0 { 24 } else { req.rows }; + let (cols, rows) = pty_dimensions(req.cols, req.rows); let sandbox_id = sandbox.object_id().to_string(); @@ -1997,6 +2009,8 @@ async fn stream_exec_over_relay( timeout_seconds: u32, request_tty: bool, no_login_shell: bool, + cols: u32, + rows: u32, ) -> Result<(), Status> { let command_preview: String = command .chars() @@ -2022,6 +2036,7 @@ async fn stream_exec_over_relay( stdin_payload, request_tty, no_login_shell, + (cols, rows), tx.clone(), ); @@ -2373,8 +2388,11 @@ async fn run_exec_with_russh( stdin_payload: Vec, request_tty: bool, no_shell_login: bool, + pty_size: (u32, u32), tx: mpsc::Sender>, ) -> Result { + let (cols, rows) = pty_size; + // Defense-in-depth: validate command at the transport boundary. if command.as_bytes().contains(&0) { return Err(Status::invalid_argument( @@ -2423,7 +2441,7 @@ async fn run_exec_with_russh( if request_tty { channel - .request_pty(false, "xterm-256color", 0, 0, 0, 0, &[]) + .request_pty(false, "xterm-256color", cols, rows, 0, 0, &[]) .await .map_err(|e| Status::internal(format!("failed to allocate PTY: {e}")))?; } @@ -2551,6 +2569,13 @@ mod tests { // ---- shell_escape ---- + #[test] + fn pty_dimensions_default_zero_values_without_overwriting_explicit_values() { + assert_eq!(pty_dimensions(0, 0), (80, 24)); + assert_eq!(pty_dimensions(120, 40), (120, 40)); + assert_eq!(pty_dimensions(0, 40), (80, 40)); + } + #[test] fn watch_terminal_phases_include_command_results_and_errors() { for phase in [ From 07453f29fe8a6a93667ec256dabc5d40761a3e59 Mon Sep 17 00:00:00 2001 From: grs Date: Tue, 1 Sep 2026 19:40:46 +0000 Subject: [PATCH 075/505] fix(cli): allow multiple provider profiles to be deleted (#3032) Signed-off-by: Gordon Sim --- .agents/skills/openshell-cli/cli-reference.md | 2 +- crates/openshell-cli/src/main.rs | 25 ++++++++++------ crates/openshell-cli/src/run.rs | 28 +++++++++--------- .../tests/provider_commands_integration.rs | 29 +++++++++++++++++-- 4 files changed, 58 insertions(+), 26 deletions(-) diff --git a/.agents/skills/openshell-cli/cli-reference.md b/.agents/skills/openshell-cli/cli-reference.md index 27c2ed78d5..f53289b89f 100644 --- a/.agents/skills/openshell-cli/cli-reference.md +++ b/.agents/skills/openshell-cli/cli-reference.md @@ -593,7 +593,7 @@ Delete one or more providers by name. - `openshell provider profile import (--file |--from

)` - `openshell provider profile update --file ` - `openshell provider profile lint (--file |--from )` -- `openshell provider profile delete ` +- `openshell provider profile delete ...` ### Provider credential refresh diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 9ba6b77612..80668272db 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1092,8 +1092,9 @@ enum ProviderProfileCommands { /// Delete a custom provider profile. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Delete { - /// Provider profile id. - id: String, + /// Provider profile id(s). + #[arg(required = true, num_args = 1.., value_name = "ID")] + ids: Vec, /// Target platform-scoped profile (ignores --workspace). #[arg(long)] @@ -3626,10 +3627,10 @@ async fn run_async() -> Result<()> { ) .await?; } - ProviderProfileCommands::Delete { id, global } => { + ProviderProfileCommands::Delete { ids, global } => { run::provider_profile_delete( endpoint, - &id, + &ids, profile_workspace(global), &tls, ) @@ -4577,17 +4578,23 @@ mod tests { }) if id == "custom-api" )); - let delete = - Cli::try_parse_from(["openshell", "provider", "profile", "delete", "custom-api"]) - .expect("provider profile delete should parse"); + let delete = Cli::try_parse_from([ + "openshell", + "provider", + "profile", + "delete", + "custom-api", + "custom-alt", + ]) + .expect("provider profile delete should parse"); assert!(matches!( delete.command, Some(Commands::Provider { command: Some(ProviderCommands::Profile(ProviderProfileCommands::Delete { - id, + ids, .. })) - }) if id == "custom-api" + }) if ids == vec!["custom-api".to_string(), "custom-alt".to_string()] )); } diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index da2a6a6230..ea495d5c04 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -4418,23 +4418,25 @@ pub async fn provider_profile_lint( pub async fn provider_profile_delete( server: &str, - id: &str, + ids: &[String], workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; - let response = client - .delete_provider_profile(DeleteProviderProfileRequest { - id: id.to_string(), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()? - .into_inner(); - if response.deleted { - println!("Deleted provider profile '{id}'."); - } else { - println!("Provider profile '{id}' was not deleted."); + for id in ids { + let response = client + .delete_provider_profile(DeleteProviderProfileRequest { + id: id.clone(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner(); + if response.deleted { + println!("{} Deleted provider profile {id}", "✓".green().bold()); + } else { + println!("{} Provider profile {id} not found", "!".yellow()); + } } Ok(()) } diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 373f79b9a3..d198c1e37a 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -1909,6 +1909,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()], @@ -1917,9 +1932,17 @@ 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] From 5b925dd8afba6ba558e93803656e680b744769d7 Mon Sep 17 00:00:00 2001 From: Russell Bryant Date: Tue, 1 Sep 2026 19:55:42 +0000 Subject: [PATCH 076/505] feat(build): add defaults-without-telemetry feature alias (#2843) * feat(build): add defaults-without-telemetry feature alias Cargo cannot subtract a single default feature, so compiling telemetry out meant `--no-default-features` plus a hand-maintained keep-list of the crate's other defaults. That keep-list was already wrong for operators: telemetry is the only default on openshell-server and openshell-driver-vm, but openshell-sandbox also defaults to `bundled-ca-roots`, so a bare `--no-default-features` silently swapped the supervisor onto the platform trust store. Add a `defaults-without-telemetry` alias to each of the three telemetry- carrying binary crates, enumerating every default except `telemetry`. Telemetry-free builds become `--no-default-features --features defaults-without-telemetry` and stay correct as the default set grows. The alias is a keep-list, not a switch. Enabling it on top of the defaults would otherwise produce a telemetry-on binary that reads as telemetry-free, so each crate root carries a `compile_error!` for the `telemetry` + `defaults-without-telemetry` combination. Add `rust:verify:defaults-without-telemetry` to guard both properties: each alias still equals its crate's defaults minus `telemetry`, and the mutual-exclusion error is wired up. The additive-misuse check matches on the `compile_error!` text rather than a nonzero exit code so it cannot pass vacuously on hosts where openshell-driver-vm fails to build for unrelated reasons. `rust:verify:telemetry-off` now builds through the alias. Signed-off-by: Russell Bryant * fix feature alias for openshell-server Signed-off-by: Russell Bryant * fix(ci): run Rust verification in Nix shell Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --------- Signed-off-by: Russell Bryant Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> Co-authored-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- .github/workflows/branch-checks.yml | 24 +++++ README.md | 10 ++- architecture/build.md | 27 ++++-- crates/openshell-driver-vm/Cargo.toml | 9 ++ crates/openshell-driver-vm/src/lib.rs | 10 +++ crates/openshell-sandbox/Cargo.toml | 11 +++ crates/openshell-sandbox/src/lib.rs | 10 +++ crates/openshell-server/Cargo.toml | 9 ++ crates/openshell-server/src/lib.rs | 10 +++ tasks/rust.toml | 12 ++- .../verify-defaults-without-telemetry.sh | 88 +++++++++++++++++++ 11 files changed, 210 insertions(+), 10 deletions(-) create mode 100755 tasks/scripts/verify-defaults-without-telemetry.sh diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 2d9dc9914f..b779974a0a 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -167,6 +167,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-server --bin openshell-gateway + tasks/scripts/verify-telemetry-compiled-out.sh present target/debug/openshell-gateway + cargo build -p openshell-server --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 diff --git a/README.md b/README.md index ba1ccf9859..28674cbdbf 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,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-server --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. diff --git a/architecture/build.md b/architecture/build.md index 32c50aa397..7d2f9ddebd 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -35,12 +35,27 @@ 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 diff --git a/crates/openshell-driver-vm/Cargo.toml b/crates/openshell-driver-vm/Cargo.toml index ebcb9d2bc2..30a0f2a970 100644 --- a/crates/openshell-driver-vm/Cargo.toml +++ b/crates/openshell-driver-vm/Cargo.toml @@ -59,6 +59,15 @@ default = ["telemetry"] ## default; build with `--no-default-features` for a telemetry-free VM driver ## that reports telemetry disabled to the sandboxes it launches. telemetry = ["openshell-core/telemetry"] +## Convenience alias: every default feature except `telemetry`. Build a +## telemetry-free VM driver with +## `--no-default-features --features defaults-without-telemetry` and stay +## correct as new default features are added. Cargo cannot subtract a single +## default feature, so this alias must be paired with `--no-default-features`; +## enabling it alongside `telemetry` is a compile error rather than a silent +## telemetry-on build. Kept in sync with `default` by +## `rust:verify:defaults-without-telemetry`. +defaults-without-telemetry = [] [dev-dependencies] openshell-otel-test-support = { path = "../openshell-otel-test-support" } diff --git a/crates/openshell-driver-vm/src/lib.rs b/crates/openshell-driver-vm/src/lib.rs index 98ba6b0c9a..f34c7dda8d 100644 --- a/crates/openshell-driver-vm/src/lib.rs +++ b/crates/openshell-driver-vm/src/lib.rs @@ -1,6 +1,16 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +// `defaults-without-telemetry` is an alias for the default feature set minus +// `telemetry`, not a switch that turns telemetry off. Cargo cannot subtract a +// default feature, so adding it on top of the defaults would otherwise produce +// a telemetry-on build that reads as telemetry-free. Fail the build instead. +#[cfg(all(feature = "telemetry", feature = "defaults-without-telemetry"))] +compile_error!( + "features `telemetry` and `defaults-without-telemetry` are mutually exclusive; \ + build a telemetry-free VM driver with `--no-default-features --features defaults-without-telemetry`" +); + pub mod driver; mod embedded_runtime; mod ffi; diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index c653db84dd..3463f03767 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -60,6 +60,17 @@ default = ["telemetry", "bundled-ca-roots"] ## `--no-default-features --features system-ca-roots` to build a supervisor ## that uses the platform trust store with telemetry intact. system-ca-roots = ["telemetry"] +## Convenience alias: every default feature except `telemetry`. Build a +## telemetry-free supervisor with +## `--no-default-features --features defaults-without-telemetry` and stay +## correct as new default features are added. Cargo cannot subtract a single +## default feature, so this alias must be paired with `--no-default-features`; +## enabling it alongside `telemetry` is a compile error rather than a silent +## telemetry-on build. Kept in sync with `default` by +## `rust:verify:defaults-without-telemetry`. Do not pair it with +## `system-ca-roots`, which re-enables `telemetry`; a build with neither +## telemetry nor bundled CA roots is plain `--no-default-features`. +defaults-without-telemetry = ["bundled-ca-roots"] telemetry = ["openshell-core/telemetry"] bundled-ca-roots = ["openshell-supervisor-network/bundled-ca-roots"] diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index e70aac7a9f..41c6ca3c94 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -5,6 +5,16 @@ //! //! This crate provides process sandboxing and monitoring capabilities. +// `defaults-without-telemetry` is an alias for the default feature set minus +// `telemetry`, not a switch that turns telemetry off. Cargo cannot subtract a +// default feature, so adding it on top of the defaults would otherwise produce +// a telemetry-on build that reads as telemetry-free. Fail the build instead. +#[cfg(all(feature = "telemetry", feature = "defaults-without-telemetry"))] +compile_error!( + "features `telemetry` and `defaults-without-telemetry` are mutually exclusive; \ + build a telemetry-free supervisor with `--no-default-features --features defaults-without-telemetry`" +); + mod activity_aggregator; mod denial_aggregator; #[cfg_attr(not(target_os = "linux"), allow(dead_code))] diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index ae35fc0fbf..1cfd214f20 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -138,6 +138,15 @@ in-tree-compute-drivers = [ ## On by default; build with `--no-default-features` for a telemetry-free gateway ## that contains no telemetry endpoint, HTTP client, or emission code. telemetry = ["openshell-core/telemetry"] +## Convenience alias: every default feature except `telemetry`. Build a +## telemetry-free gateway with +## `--no-default-features --features defaults-without-telemetry` and stay +## correct as new default features are added. Cargo cannot subtract a single +## default feature, so this alias must be paired with `--no-default-features`; +## enabling it alongside `telemetry` is a compile error rather than a silent +## telemetry-on build. Kept in sync with `default` by +## `rust:verify:defaults-without-telemetry`. +defaults-without-telemetry = ["in-tree-compute-drivers"] bundled-z3 = ["openshell-prover/bundled-z3"] test-support = [] diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 591eeab214..c2306b6632 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -13,6 +13,16 @@ //! startup. Runtime selection only consults that registry or a configured //! external endpoint; it does not switch on driver names. +// `defaults-without-telemetry` is an alias for the default feature set minus +// `telemetry`, not a switch that turns telemetry off. Cargo cannot subtract a +// default feature, so adding it on top of the defaults would otherwise produce +// a telemetry-on build that reads as telemetry-free. Fail the build instead. +#[cfg(all(feature = "telemetry", feature = "defaults-without-telemetry"))] +compile_error!( + "features `telemetry` and `defaults-without-telemetry` are mutually exclusive; \ + build a telemetry-free gateway with `--no-default-features --features defaults-without-telemetry`" +); + mod auth; pub mod certgen; pub mod cli; diff --git a/tasks/rust.toml b/tasks/rust.toml index 854c2ac939..6ef34f60df 100644 --- a/tasks/rust.toml +++ b/tasks/rust.toml @@ -53,13 +53,19 @@ run = [ # markers, so the absent checks below can never become silently vacuous. "cargo build -p openshell-server --bin openshell-gateway", "tasks/scripts/verify-telemetry-compiled-out.sh present target/debug/openshell-gateway", - # Guard: telemetry-free builds must contain no telemetry markers. - "cargo build -p openshell-server --bin openshell-gateway --no-default-features", + # Guard: telemetry-free builds must contain no telemetry markers. Built + # through the `defaults-without-telemetry` alias, which is how the docs tell + # operators to produce these artifacts. + "cargo build -p openshell-server --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 bundled-ca-roots", + "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", ] +["rust:verify:defaults-without-telemetry"] +description = "Verify the defaults-without-telemetry feature alias matches default minus telemetry and cannot be used additively" +run = "tasks/scripts/verify-defaults-without-telemetry.sh" + ["rust:verify:system-ca-roots"] description = "Verify system CA roots build mode compiles and excludes bundled Mozilla root crates" run = [ diff --git a/tasks/scripts/verify-defaults-without-telemetry.sh b/tasks/scripts/verify-defaults-without-telemetry.sh new file mode 100755 index 0000000000..4d78cbc5b4 --- /dev/null +++ b/tasks/scripts/verify-defaults-without-telemetry.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Verify the `defaults-without-telemetry` alias still means "every default +# feature except telemetry", and that it cannot be used additively. +# +# Cargo cannot subtract a single default feature, so telemetry-free builds use +# `--no-default-features --features defaults-without-telemetry`. Two failure +# modes follow from that, and this guard covers both: +# +# 1. Drift. The alias enumerates the keep-list by hand, so it silently rots +# the moment a crate gains a new default feature. Telemetry-free builds +# would then quietly lose an unrelated default. +# 2. Additive misuse. `--features defaults-without-telemetry` without +# `--no-default-features` would otherwise compile a telemetry-on binary +# that reads as telemetry-free. Each crate root carries a `compile_error!` +# for that combination; this asserts the error is actually wired up. + +set -euo pipefail + +# Crates that define the alias. Each must forward `telemetry` and define +# `defaults-without-telemetry`. +CRATES=( + openshell-server + openshell-sandbox + openshell-driver-vm +) + +if ! command -v jq >/dev/null 2>&1; then + echo "error: 'jq' is required to inspect cargo metadata" >&2 + exit 2 +fi + +metadata=$(cargo metadata --no-deps --format-version 1) + +failed=0 +for crate in "${CRATES[@]}"; do + features=$(jq -c --arg crate "$crate" \ + '.packages[] | select(.name == $crate) | .features' <<<"$metadata") + + if [[ -z $features || $features == "null" ]]; then + echo "FAIL: crate '$crate' not found in workspace metadata" >&2 + failed=1 + continue + fi + + if ! jq -e 'has("defaults-without-telemetry")' <<<"$features" >/dev/null; then + echo "FAIL: $crate defines no 'defaults-without-telemetry' feature" >&2 + failed=1 + continue + fi + + expected=$(jq -r '(.default // []) - ["telemetry"] | sort | join(",")' <<<"$features") + actual=$(jq -r '(."defaults-without-telemetry" // []) | sort | join(",")' <<<"$features") + + if [[ $expected != "$actual" ]]; then + echo "FAIL: $crate 'defaults-without-telemetry' is out of sync with 'default'" >&2 + echo " default minus telemetry: [${expected}]" >&2 + echo " defaults-without-telemetry: [${actual}]" >&2 + echo " Update 'defaults-without-telemetry' in crates/$crate/Cargo.toml to match." >&2 + failed=1 + continue + fi + + echo "OK: $crate 'defaults-without-telemetry' == default minus telemetry [${expected}]" +done + +# Additive misuse must be a hard error. Match on the `compile_error!` text +# rather than a nonzero exit code: openshell-driver-vm does not build on every +# host, and a check that failed for an unrelated reason would make this guard +# silently vacuous. +for crate in "${CRATES[@]}"; do + output=$(cargo check -p "$crate" --features defaults-without-telemetry 2>&1 || true) + + if grep -qF "features \`telemetry\` and \`defaults-without-telemetry\` are mutually exclusive" <<<"$output"; then + echo "OK: $crate rejects 'telemetry' + 'defaults-without-telemetry'" + continue + fi + + echo "FAIL: $crate did not reject 'telemetry' + 'defaults-without-telemetry'" >&2 + echo " Expected the mutual-exclusion compile_error! in crates/$crate/src/lib.rs." >&2 + echo " Got:" >&2 + sed 's/^/ /' <<<"$output" | tail -20 >&2 + failed=1 +done + +exit "$failed" From b4afcd8a4323e9d1b08b1d1bc87ae0ae5e16463a Mon Sep 17 00:00:00 2001 From: Mrunal Patel Date: Tue, 1 Sep 2026 21:11:31 +0000 Subject: [PATCH 077/505] fix(cli): suppress ANSI color when stdout is not a terminal (#3026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): suppress ANSI color when stdout is not a terminal The CLI colorized output unconditionally. owo-colors is built without its `supports-colors` feature, so `.green()` and friends emitted escape sequences regardless of destination, and nothing in the CLI read NO_COLOR. Piping any command through grep or awk matched against bytes the caller could not see; `forward list` was the case that surfaced it, where an escape sits immediately before the STATUS word and defeats a pattern anchored on whitespace. Add a `color` module holding a process-wide switch resolved once in run_async, before any output. Command modules import its `Colorize` trait in place of `OwoColorize`; the method names match, so the ~450 call sites are unchanged, but each consults the switch when it renders and delegates to owo-colors so the escape bytes stay identical. The two traits collide by design: importing both in one module is an ambiguity error, which keeps unconditional coloring from returning. owo-colors is not the only styled path, and the rest each carry their own default, so the switch governs them too: - tracing_subscriber formats with ANSI on, does no terminal detection, and writes to stdout, so `openshell -v ... | ...` leaked escapes the same way the tables did. It now takes the setting via with_ansi. - indicatif and dialoguer both style through console, which has its own detection but cannot learn about --color. Overriding console's global switch covers every progress bar and prompt rather than the specific ones constructed today. Both the stdout and stderr switches are set, since prompts and progress bars draw to stderr. - miette renders errors through its own handler, likewise unaware of --color, so init installs one built from the setting. Resolution order: `--color always|never`, then NO_COLOR, then CLICOLOR_FORCE, then whether stdout is a terminal. The decision is made against stdout even for stderr text, since stdout is what gets parsed; `--color always` restores styling when redirecting. Padding is unaffected — the format spec is forwarded to the inner Display, so widths measure text rather than text plus escapes. Signed-off-by: Mrunal Patel * fix(cli): resolve color per output stream Review feedback on #3026. Resolving one answer from stdout and handing it to every library meant a redirected stream inherited the other stream's terminal check. Running `openshell ... 2> build.log` from a terminal wrote escapes into the log, because console's stderr switch and miette's handler were both given stdout's answer. That is worse than the behavior before this branch, where both libraries did their own per-stream detection. Resolve `auto` separately for stdout and stderr and hand each library the answer for the stream it writes to: tracing and console's stdout switch get stdout, miette and console's stderr switch get stderr. The owo-colors wrapper is the exception, since 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, erring toward plain text rather than risking a redirected stream. Existing tests could not catch this: Command::output gives both streams pipes, so a per-stream decision and a single stdout-derived one look identical. Add a test that puts stdout on a pty and stderr on a pipe, which fails when stderr is handed stdout's answer. Replace CLICOLOR_FORCE with FORCE_COLOR. The clicolors spec does not say how to treat `0`, and implementations that special-case it disagree with force-color.org, which keys on presence and non-emptiness only. Using FORCE_COLOR gives it the same rule as NO_COLOR: set and non-empty means yes, whatever the value. Nothing depended on CLICOLOR_FORCE, which was introduced earlier on this branch and never released. Carry the whole style in an owo_colors::Style rather than dispatching a local enum through a six-arm match, and merge styles when chaining so `x.green().bold()` emits one `\x1b[32;1m...\x1b[0m` instead of nesting two wrappers. No call site styles already-styled text, so merging is safe; the emitted bytes are shorter and there is a single reset. Signed-off-by: Mrunal Patel --------- Signed-off-by: Mrunal Patel --- .agents/skills/openshell-cli/cli-reference.md | 4 + Cargo.lock | 1 + crates/openshell-cli/Cargo.toml | 3 + crates/openshell-cli/src/color.rs | 534 ++++++++++++++++++ crates/openshell-cli/src/commands/common.rs | 2 +- crates/openshell-cli/src/commands/gateway.rs | 2 +- crates/openshell-cli/src/lib.rs | 1 + crates/openshell-cli/src/main.rs | 23 +- crates/openshell-cli/src/run.rs | 2 +- crates/openshell-cli/src/ssh.rs | 2 +- .../tests/cli_color_integration.rs | 315 +++++++++++ docs/sandboxes/manage-sandboxes.mdx | 2 + 12 files changed, 886 insertions(+), 5 deletions(-) create mode 100644 crates/openshell-cli/src/color.rs create mode 100644 crates/openshell-cli/tests/cli_color_integration.rs diff --git a/.agents/skills/openshell-cli/cli-reference.md b/.agents/skills/openshell-cli/cli-reference.md index f53289b89f..1e975342dd 100644 --- a/.agents/skills/openshell-cli/cli-reference.md +++ b/.agents/skills/openshell-cli/cli-reference.md @@ -12,6 +12,7 @@ Quick-reference for the `openshell` command-line interface. For workflow guidanc | `-g`, `--gateway ` | Gateway to operate on. Also settable via `OPENSHELL_GATEWAY` env var. Falls back to active gateway in `~/.config/openshell/active_gateway`. | | `--gateway-endpoint ` | 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. | +| `--color ` | `auto` (default), `always`, or `never`. `auto` decides per stream, so a redirected stream is plain text while a stream still on the terminal stays styled. Covers tables, `-v` log lines, progress spinners, prompts, and error messages. Also settable via `OPENSHELL_COLOR`. | ## Environment Variables @@ -20,6 +21,9 @@ Quick-reference for the `openshell` command-line interface. For workflow guidanc | `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_COLOR` | When to colorize output: `auto`, `always`, `never` (same as `--color`) | +| `NO_COLOR` | Disable colorized output when set to any non-empty value ([no-color.org](https://no-color.org)) | +| `FORCE_COLOR` | Force colorized output when set to any non-empty value ([force-color.org](https://force-color.org)) | | `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 ` | | `OPENSHELL_THEME` | TUI theme: `auto`, `dark`, or `light` | diff --git a/Cargo.lock b/Cargo.lock index 9188a93d0c..510273b6d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3782,6 +3782,7 @@ dependencies = [ "chrono", "clap", "clap_complete", + "console", "crossterm 0.28.1", "dialoguer", "futures", diff --git a/crates/openshell-cli/Cargo.toml b/crates/openshell-cli/Cargo.toml index 4b96253310..ddaf09f889 100644 --- a/crates/openshell-cli/Cargo.toml +++ b/crates/openshell-cli/Cargo.toml @@ -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 } diff --git a/crates/openshell-cli/src/color.rs b/crates/openshell-cli/src/color.rs new file mode 100644 index 0000000000..e6a185bdad --- /dev/null +++ b/crates/openshell-cli/src/color.rs @@ -0,0 +1,534 @@ +// 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 (). +//! 3. `FORCE_COLOR`, set and non-empty, forces color on (). +//! 4. Otherwise the stream is styled only when that stream is a terminal. +//! +//! 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"); + + // 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(), + std::io::stdout().is_terminal(), + ); + let stderr_enabled = resolve( + choice, + no_color.as_deref(), + force_color.as_deref(), + std::io::stderr().is_terminal(), + ); + 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_is_terminal: 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. and + // . + if is_set(no_color) { + return false; + } + if is_set(force_color) { + return true; + } + + stream_is_terminal +} + +/// 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 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 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 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, + }; + 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(on: bool, body: impl FnOnce() -> R) -> R { + with_streams(on, on, body) + } + + fn with_streams(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. + // 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 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)); + } +} diff --git a/crates/openshell-cli/src/commands/common.rs b/crates/openshell-cli/src/commands/common.rs index 03cb3260cf..0f12c3df4e 100644 --- a/crates/openshell-cli/src/commands/common.rs +++ b/crates/openshell-cli/src/commands/common.rs @@ -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; diff --git a/crates/openshell-cli/src/commands/gateway.rs b/crates/openshell-cli/src/commands/gateway.rs index 7a3ebf01f0..0a7950050d 100644 --- a/crates/openshell-cli/src/commands/gateway.rs +++ b/crates/openshell-cli/src/commands/gateway.rs @@ -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}; diff --git a/crates/openshell-cli/src/lib.rs b/crates/openshell-cli/src/lib.rs index e28b84b05d..910920f42a 100644 --- a/crates/openshell-cli/src/lib.rs +++ b/crates/openshell-cli/src/lib.rs @@ -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; diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 80668272db..7c042296f4 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -7,7 +7,7 @@ use clap::{CommandFactory, Parser, Subcommand, ValueEnum, ValueHint}; use clap_complete::engine::ArgValueCompleter; use clap_complete::env::CompleteEnv; use miette::Result; -use owo_colors::OwoColorize; +use openshell_cli::color::{self, ColorChoice, Colorize}; use std::collections::HashMap; use std::io::Write; use std::path::PathBuf; @@ -468,6 +468,18 @@ struct Cli { #[arg(short, long, action = clap::ArgAction::Count, global = true, help_heading = "GLOBAL FLAGS")] verbose: u8, + /// When to colorize output. `auto` colorizes only when stdout is a terminal. + #[arg( + long, + global = true, + value_enum, + default_value_t = ColorChoice::Auto, + value_name = "WHEN", + env = "OPENSHELL_COLOR", + help_heading = "GLOBAL FLAGS" + )] + color: ColorChoice, + /// Print help. #[arg(short = 'h', long, action = clap::ArgAction::Help, global = true, help_heading = "GLOBAL FLAGS")] help: (), @@ -2255,6 +2267,11 @@ async fn run_async() -> Result<()> { CompleteEnv::with_factory(Cli::command).complete(); let cli = Cli::parse(); + + // Resolve colorization before anything is printed, so no command can emit + // escape sequences into a pipe. + color::init(cli.color); + let mut tls = TlsOptions::default(); tls.gateway_insecure = cli.gateway_insecure; @@ -2266,7 +2283,11 @@ async fn run_async() -> Result<()> { _ => "trace", }; + // The fmt formatter defaults to ANSI on with no terminal detection, and it + // writes to stdout, so without this `openshell -v ... | ...` pipes escapes + // to the caller. tracing_subscriber::fmt() + .with_ansi(color::stdout_enabled()) .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(log_level)), diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index ea495d5c04..ebf82713e7 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -22,6 +22,7 @@ pub use crate::commands::gateway::{ gateway_logout, gateway_remove, gateway_select, gateway_status, gateway_use, }; +use crate::color::Colorize; use crate::policy_update::build_policy_update_plan; use crate::tls::{TlsOptions, grpc_client, grpc_inference_client}; use dialoguer::Confirm; @@ -64,7 +65,6 @@ use openshell_providers::{ normalize_profile_id, normalize_provider_type, parse_profile_json, parse_profile_yaml, profile_to_json, profile_to_yaml, profiles_to_json, profiles_to_yaml, }; -use owo_colors::OwoColorize; use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::io::{ErrorKind, IsTerminal, Read, Write}; diff --git a/crates/openshell-cli/src/ssh.rs b/crates/openshell-cli/src/ssh.rs index 1e1e48257a..126cea652d 100644 --- a/crates/openshell-cli/src/ssh.rs +++ b/crates/openshell-cli/src/ssh.rs @@ -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}; diff --git a/crates/openshell-cli/tests/cli_color_integration.rs b/crates/openshell-cli/tests/cli_color_integration.rs new file mode 100644 index 0000000000..23109e9774 --- /dev/null +++ b/crates/openshell-cli/tests/cli_color_integration.rs @@ -0,0 +1,315 @@ +// 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: \t\t + "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 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) { + 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 tmpdir = tempfile::tempdir().expect("create tmpdir"); + let mut child = Command::new(env!("CARGO_BIN_EXE_openshell")) + .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") + .env_remove("NO_COLOR") + .env_remove("FORCE_COLOR") + .env_remove("OPENSHELL_COLOR") + .stdout(follower.try_clone().expect("dup pty follower")) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn openshell"); + + // 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, + ) +} + +/// 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:?}" + ); +} + +#[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:?}" + ); +} diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index f1948e3aff..d8abacce23 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -456,6 +456,8 @@ Structured output includes `sandbox`, `bind_address`, `port`, `pid`, and expected OpenShell SSH forward; it does not probe the forwarded socket. When no forwards are tracked, structured output returns an empty collection. +The default table colorizes the `STATUS` column, but only when the stream it is written to is a terminal, so piping or redirecting gives plain text. Each stream is decided on its own, so redirecting one leaves the other styled. Prefer `--output json` for automation rather than matching on the table. Set `NO_COLOR` to any non-empty value or pass `--color never` to suppress color, and `--color always` to keep it when piping into a pager. `--color` applies to every `openshell` command and covers all styled output: tables, log lines from `-v`, progress spinners, prompts, and error messages. + You can also forward a port at creation time with `--forward`: From 9ca19e6c800f9ca07cc9bfe097c56f3202fe8608 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 1 Sep 2026 21:13:45 +0000 Subject: [PATCH 078/505] refactor(compute): decouple gateway driver composition (#2823) * refactor(compute): decouple gateway driver composition Move first-party composition and VM process ownership into openshell-gateway, leaving openshell-server backend-independent. Update packaging and build references with the new crate, simplify the compiled-driver boundary, and keep the driver-free gateway path buildable with bundled Z3 tooling. Signed-off-by: Drew Newberry * fix(telemetry): bound compute driver categories Signed-off-by: Drew Newberry * refactor(core): keep runtime transport generic Signed-off-by: Drew Newberry * fix(compute): complete server driver decoupling Signed-off-by: Drew Newberry * fix(compute): preserve driver integrations after rebase Signed-off-by: Drew Newberry <385+drew@users.noreply.github.com> * fix(compute): preserve docker tracing after decoupling Signed-off-by: Drew Newberry <385+drew@users.noreply.github.com> * fix(compute): preserve driver behavior after extraction Signed-off-by: Drew Newberry * refactor(compute): remove MXC policy side channel Signed-off-by: Drew Newberry * refactor(compute): separate policy delivery from readiness Signed-off-by: Drew Newberry --------- Signed-off-by: Drew Newberry Signed-off-by: Drew Newberry <385+drew@users.noreply.github.com> --- .../build-openshell-mxc-windows/SKILL.md | 10 +- .../build-openshell-mxc-windows/reference.md | 4 +- .../skills/debug-openshell-cluster/SKILL.md | 5 +- .agents/skills/openshell-cli/SKILL.md | 4 +- .agents/skills/openshell-cli/cli-reference.md | 4 +- .github/workflows/branch-checks.yml | 4 +- .github/workflows/branch-e2e.yml | 2 +- .github/workflows/build-gateway-binaries.yml | 2 +- AGENTS.md | 2 + Cargo.lock | 29 +- README.md | 2 +- architecture/README.md | 10 +- architecture/build.md | 4 +- architecture/compute-runtimes.md | 100 +- architecture/windows-msvc-build.md | 20 +- crates/openshell-core/src/config.rs | 1291 +---------------- crates/openshell-core/src/driver_utils.rs | 13 +- ...lowlist.rs => dynamic_string_allowlist.rs} | 11 +- crates/openshell-core/src/error.rs | 4 +- crates/openshell-core/src/lib.rs | 13 +- crates/openshell-core/src/local_api_socket.rs | 81 ++ crates/openshell-core/src/sandbox_env.rs | 2 +- crates/openshell-core/src/settings.rs | 2 +- crates/openshell-core/src/telemetry.rs | 78 +- crates/openshell-driver-docker/src/lib.rs | 51 +- crates/openshell-driver-docker/src/main.rs | 5 +- crates/openshell-driver-docker/src/tests.rs | 1 + .../openshell-driver-kubernetes/src/config.rs | 2 +- .../openshell-driver-kubernetes/src/driver.rs | 106 +- crates/openshell-driver-kubernetes/src/lib.rs | 2 +- crates/openshell-driver-mxc/README.md | 17 +- crates/openshell-driver-mxc/src/driver.rs | 106 +- crates/openshell-driver-mxc/src/grpc.rs | 4 +- crates/openshell-driver-mxc/src/policy.rs | 4 +- crates/openshell-driver-podman/README.md | 10 +- crates/openshell-driver-podman/src/driver.rs | 20 +- crates/openshell-driver-podman/src/lib.rs | 1 + .../src/socket_discovery.rs | 402 +++++ crates/openshell-driver-podman/src/watcher.rs | 11 +- crates/openshell-driver-vm/Cargo.toml | 1 + crates/openshell-driver-vm/README.md | 6 +- crates/openshell-driver-vm/runtime/README.md | 2 +- crates/openshell-driver-vm/src/driver.rs | 7 +- crates/openshell-gateway/Cargo.toml | 72 + crates/openshell-gateway/src/lib.rs | 452 ++++++ .../src/main.rs | 8 +- .../compute => openshell-gateway/src}/vm.rs | 84 +- crates/openshell-server/Cargo.toml | 35 +- .../src/auth/compute_driver.rs | 2 +- crates/openshell-server/src/cli.rs | 227 ++- .../src/compute/driver_config.rs | 29 +- .../src/compute/driver_config/builtin.rs | 231 --- crates/openshell-server/src/compute/lease.rs | 8 +- crates/openshell-server/src/compute/mod.rs | 370 ++--- crates/openshell-server/src/config_file.rs | 89 +- .../openshell-server/src/gateway_listener.rs | 96 +- crates/openshell-server/src/grpc/policy.rs | 36 - crates/openshell-server/src/grpc/sandbox.rs | 49 +- crates/openshell-server/src/lib.rs | 903 +++++------- crates/openshell-server/src/otel_tracing.rs | 20 +- crates/openshell-server/src/sandbox_index.rs | 2 +- crates/openshell-server/src/test_support.rs | 10 +- crates/openshell-server/src/tracing_setup.rs | 203 +-- deploy/docker/Dockerfile.gateway-macos | 16 +- docs/reference/sandbox-compute-drivers.mdx | 22 +- e2e/no-compute-driver-gateway.sh | 17 +- e2e/run.sh | 4 +- e2e/rust/e2e-vm.sh | 6 +- e2e/support/gateway-common.sh | 6 +- e2e/with-kube-gateway.sh | 7 +- examples/governance-interceptor/smoke.sh | 2 +- .../smoke.sh | 2 +- proto/compute_driver.proto | 13 + tasks/ci.toml | 2 +- tasks/gateway.toml | 2 +- tasks/rust.toml | 4 +- tasks/scripts/gateway-docker.sh | 2 +- tasks/scripts/gateway-vm.sh | 2 +- tasks/scripts/package-deb-install.sh | 2 +- tasks/scripts/stage-prebuilt-binaries.sh | 2 +- .../verify-defaults-without-telemetry.sh | 2 +- tasks/scripts/vm/smoke-orphan-cleanup.sh | 2 +- tasks/scripts/windows-msvc.ps1 | 4 +- 83 files changed, 2173 insertions(+), 3327 deletions(-) rename crates/openshell-core/src/{operator_namespace_allowlist.rs => dynamic_string_allowlist.rs} (84%) create mode 100644 crates/openshell-core/src/local_api_socket.rs create mode 100644 crates/openshell-driver-podman/src/socket_discovery.rs create mode 100644 crates/openshell-gateway/Cargo.toml create mode 100644 crates/openshell-gateway/src/lib.rs rename crates/{openshell-server => openshell-gateway}/src/main.rs (59%) rename crates/{openshell-server/src/compute => openshell-gateway/src}/vm.rs (92%) delete mode 100644 crates/openshell-server/src/compute/driver_config/builtin.rs diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index d61fc555e9..954fcedf2f 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -231,7 +231,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 +248,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: diff --git a/.agents/skills/build-openshell-mxc-windows/reference.md b/.agents/skills/build-openshell-mxc-windows/reference.md index 4a5f2668a1..16b9a48586 100644 --- a/.agents/skills/build-openshell-mxc-windows/reference.md +++ b/.agents/skills/build-openshell-mxc-windows/reference.md @@ -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 diff --git a/.agents/skills/debug-openshell-cluster/SKILL.md b/.agents/skills/debug-openshell-cluster/SKILL.md index 86395af148..b73d152fc7 100644 --- a/.agents/skills/debug-openshell-cluster/SKILL.md +++ b/.agents/skills/debug-openshell-cluster/SKILL.md @@ -19,8 +19,9 @@ The target deployment flow is: 4. The CLI registers a reachable gateway endpoint with `openshell gateway add`. 5. The gateway creates sandboxes through the selected compute driver. -The standard gateway binary explicitly installs its compiled Docker, Podman, -Kubernetes, and VM registrations at startup. With no configured driver, the +The `openshell-gateway` composition crate explicitly installs its compiled +Docker, Podman, Kubernetes, and VM registrations at startup; `openshell-server` +does not link compute-driver crates. With no configured driver, the gateway probes only installed registrations in priority order (Kubernetes, Podman, then Docker); VM has no probe and remains opt-in. A custom gateway binary may install a different set, so confirm the binary's registered drivers diff --git a/.agents/skills/openshell-cli/SKILL.md b/.agents/skills/openshell-cli/SKILL.md index a1ed3bef9b..c0be7fc730 100644 --- a/.agents/skills/openshell-cli/SKILL.md +++ b/.agents/skills/openshell-cli/SKILL.md @@ -384,7 +384,7 @@ the operation that removes retained state. This is the most important multi-step workflow. It enables a tight feedback cycle where sandbox policy is refined based on observed activity. -**Key concept**: Policies have static fields (immutable after creation: `filesystem_policy`, `landlock`, `process`) and two dynamic fields: `network_policies` and `network_middlewares`. Both dynamic fields can be updated without recreating the sandbox when the selected compute driver supports live policy updates. MXC rejects live policy replacement and merge updates; delete and recreate an MXC sandbox instead. +**Key concept**: Policies have static fields (immutable after creation: `filesystem_policy`, `landlock`, `process`) and two dynamic fields: `network_policies` and `network_middlewares`. Both dynamic fields can be updated without recreating the sandbox when the selected compute driver supports live policy updates. Drivers without the standard supervisor fetch revisions through the sandbox configuration API and report whether they loaded them. An endpoint with omitted `protocol` retains explicit-proxy behavior. Explicit `protocol: tcp` requests policy DNS and transparent TCP and currently requires @@ -454,7 +454,7 @@ Edit `current-policy.yaml` to allow the blocked actions. **For policy content au - Binary matching patterns - Ordered `network_middlewares`, host selection, HTTP and WebSocket bindings, and `fail_open` or `fail_closed` behavior -`network_policies` and `network_middlewares` can be modified at runtime when the selected compute driver supports live policy updates. MXC rejects live policy replacement and merge updates; delete and recreate an MXC sandbox instead. If `filesystem_policy`, `landlock`, or `process` need changes, the sandbox must be recreated. Built-in middleware such as `openshell/regex` needs no gateway registration. An operator-run middleware must already be registered under `[[openshell.supervisor.middleware]]`; changing that static registration requires a gateway restart. +`network_policies` and `network_middlewares` can be modified at runtime when the selected compute driver supports live policy updates. Use `--wait` to verify that the active runtime loaded the revision; do not infer enforcement from the gateway accepting the update. If `filesystem_policy`, `landlock`, or `process` need changes, the sandbox must be recreated. Built-in middleware such as `openshell/regex` needs no gateway registration. An operator-run middleware must already be registered under `[[openshell.supervisor.middleware]]`; changing that static registration requires a gateway restart. Middleware can inspect parsed HTTP request bodies and complete client-to-upstream WebSocket text messages over both `ws://` and `wss://` when the implementation advertises the matching binding. The built-in `openshell/regex` advertises both bindings and applies its fixed patterns to UTF-8 text. A host-matched HTTP-only attachment can inspect the upgrade GET but does not join the WebSocket chain; look for `binding_not_selected` coverage. Binary messages pass under both `on_error` modes and active stages emit `unsupported_message_type` coverage; upstream-to-client messages remain uninspected. A broken fail-open WebSocket stage is disabled for the rest of that connection; inspect sandbox OCSF logs for `openshell.middleware.websocket_stage_disabled`. diff --git a/.agents/skills/openshell-cli/cli-reference.md b/.agents/skills/openshell-cli/cli-reference.md index 1e975342dd..a3dc91c958 100644 --- a/.agents/skills/openshell-cli/cli-reference.md +++ b/.agents/skills/openshell-cli/cli-reference.md @@ -417,7 +417,7 @@ The sandbox name defaults to the last-used sandbox. ### `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. +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. Use `--wait` to verify that the active runtime loaded the new revision. | Flag | Default | Description | |------|---------|-------------| @@ -447,7 +447,7 @@ Notes: ### `openshell policy set [name] --policy ` -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. +Replace the full policy on a live sandbox when the selected compute driver supports live updates. Only dynamic policy fields can be changed at runtime. Use `--wait` to verify that the active runtime loaded the new revision. | Flag | Default | Description | |------|---------|-------------| diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index b779974a0a..2d22b4eafc 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -169,9 +169,9 @@ jobs: - name: Verify telemetry can be compiled out run: | - cargo build -p openshell-server --bin openshell-gateway + cargo build -p openshell-gateway --bin openshell-gateway tasks/scripts/verify-telemetry-compiled-out.sh present target/debug/openshell-gateway - cargo build -p openshell-server --bin openshell-gateway --no-default-features --features defaults-without-telemetry + 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 diff --git a/.github/workflows/branch-e2e.yml b/.github/workflows/branch-e2e.yml index 580a05c1f9..2e316f88bd 100644 --- a/.github/workflows/branch-e2e.yml +++ b/.github/workflows/branch-e2e.yml @@ -166,7 +166,7 @@ jobs: 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 }} diff --git a/.github/workflows/build-gateway-binaries.yml b/.github/workflows/build-gateway-binaries.yml index ed5219e7c6..391e421080 100644 --- a/.github/workflows/build-gateway-binaries.yml +++ b/.github/workflows/build-gateway-binaries.yml @@ -43,7 +43,7 @@ jobs: interpreter: "" uses: ./.github/workflows/build-binaries.yml with: - package: openshell-server + package: openshell-gateway binary: openshell-gateway triple: ${{ matrix.triple }} runner: ${{ matrix.runner }} diff --git a/AGENTS.md b/AGENTS.md index 2c11b4c4ce..33dad248d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,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,6 +52,7 @@ These pipelines connect skills into end-to-end workflows. Individual skill files | `crates/openshell-driver-db-credstore/` | Database credential driver | In-process `CredentialDriver` backend for gateway database credential storage | | `crates/openshell-driver-kubernetes/` | Kubernetes compute driver | In-process `ComputeDriver` backend for K8s sandbox pods | | `crates/openshell-driver-docker/` | Docker compute driver | In-process `ComputeDriver` backend for local Docker sandbox containers | +| `crates/openshell-driver-mxc/` | MXC compute driver | Windows in-process `ComputeDriver` backend for MXC sandbox execution | | `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-prover/` | Policy prover | Policy verification and proof generation | diff --git a/Cargo.lock b/Cargo.lock index 510273b6d8..90240d6b3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4070,6 +4070,7 @@ dependencies = [ "nix 0.29.0", "oci-client", "openshell-core", + "openshell-driver-podman", "openshell-otel", "openshell-otel-test-support", "openshell-policy", @@ -4114,6 +4115,30 @@ dependencies = [ "tower 0.5.3", ] +[[package]] +name = "openshell-gateway" +version = "0.0.0" +dependencies = [ + "async-trait", + "hyper-util", + "miette", + "nix 0.29.0", + "openshell-core", + "openshell-driver-docker", + "openshell-driver-kubernetes", + "openshell-driver-mxc", + "openshell-driver-podman", + "openshell-otel", + "openshell-server", + "rustix 1.1.4", + "serde", + "tempfile", + "tokio", + "tonic", + "tower 0.5.3", + "tracing", +] + [[package]] name = "openshell-gateway-interceptors" version = "0.0.0" @@ -4325,11 +4350,7 @@ dependencies = [ "openshell-bootstrap", "openshell-core", "openshell-driver-db-credstore", - "openshell-driver-docker", - "openshell-driver-kubernetes", "openshell-driver-kubernetes-secrets", - "openshell-driver-mxc", - "openshell-driver-podman", "openshell-driver-vault", "openshell-extension-core", "openshell-gateway-interceptors", diff --git a/README.md b/README.md index 28674cbdbf..751eba780d 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,7 @@ Disable telemetry at runtime by setting `OPENSHELL_TELEMETRY_ENABLED=false` on t 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-server --no-default-features --features defaults-without-telemetry +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 ``` diff --git a/architecture/README.md b/architecture/README.md index 0d814a7a22..d48a0d53f9 100644 --- a/architecture/README.md +++ b/architecture/README.md @@ -141,9 +141,13 @@ 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 diff --git a/architecture/build.md b/architecture/build.md index 7d2f9ddebd..393eb9e468 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -10,7 +10,7 @@ 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` | @@ -28,7 +28,7 @@ Sandbox community images are built outside this repository. 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 diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index 200e15fe72..aa97fbcb78 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -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. - -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. +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 `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 @@ -471,13 +483,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>>`) 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 diff --git a/architecture/windows-msvc-build.md b/architecture/windows-msvc-build.md index e940b83273..6b0d675a9c 100644 --- a/architecture/windows-msvc-build.md +++ b/architecture/windows-msvc-build.md @@ -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. | diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index a1f1a6c84f..3507011120 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -6,16 +6,10 @@ use serde::{Deserialize, Serialize}; use std::borrow::Cow; use std::collections::BTreeMap; -use std::fmt; -#[cfg(unix)] -use std::io::{Read, Write}; use std::net::SocketAddr; -#[cfg(unix)] -use std::os::unix::fs::FileTypeExt; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::path::PathBuf; use std::str::FromStr; -use std::time::{Duration, Instant}; +use std::time::Duration; // ── Public default constants ──────────────────────────────────────────── // @@ -35,9 +29,6 @@ pub const DEFAULT_GATEWAY_NAME: &str = "openshell"; /// Default container stop timeout in seconds (SIGTERM → SIGKILL). pub const DEFAULT_STOP_TIMEOUT_SECS: u32 = 10; -/// Default Docker bridge network name for local sandboxes. -pub const DEFAULT_DOCKER_NETWORK_NAME: &str = "openshell-docker"; - /// Default domain used for browser-facing sandbox service URLs. pub const DEFAULT_SERVICE_ROUTING_DOMAIN: &str = "openshell.localhost"; @@ -118,34 +109,9 @@ pub const CDI_GPU_DEVICE_ALL: &str = "nvidia.com/gpu=all"; /// Default maximum number of processes (PIDs) allowed inside a sandbox container. /// -/// Shared by the Docker and Podman drivers; override via driver config. +/// Compute drivers may override this through backend configuration. pub const DEFAULT_SANDBOX_PIDS_LIMIT: i64 = 2048; -/// Compute backends the gateway can orchestrate sandboxes through. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ComputeDriverKind { - Kubernetes, - Vm, - Docker, - Podman, - /// Microsoft MXC isolation session (Windows only). - Mxc, -} - -impl ComputeDriverKind { - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Kubernetes => "kubernetes", - Self::Vm => "vm", - Self::Docker => "docker", - Self::Podman => "podman", - Self::Mxc => "mxc", - } - } -} - /// Normalize a configured compute driver name. /// /// Built-in driver names and custom remote driver names share the same @@ -167,594 +133,6 @@ pub fn normalize_compute_driver_name(value: &str) -> Result { Ok(value.to_ascii_lowercase()) } -impl fmt::Display for ComputeDriverKind { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -impl FromStr for ComputeDriverKind { - type Err = String; - - fn from_str(value: &str) -> Result { - match value.trim().to_ascii_lowercase().as_str() { - "kubernetes" => Ok(Self::Kubernetes), - "vm" => Ok(Self::Vm), - "docker" => Ok(Self::Docker), - "podman" => Ok(Self::Podman), - "mxc" => Ok(Self::Mxc), - other => Err(format!( - "unsupported compute driver '{other}'. expected one of: kubernetes, vm, docker, podman, mxc" - )), - } - } -} - -/// Auto-detect the appropriate compute driver based on the runtime environment. -/// -/// Priority order: Kubernetes → Podman → Docker. -/// VM is never auto-detected (requires explicit `--drivers vm`). -/// -/// Returns the first driver where the environment check passes. -/// Returns `None` if no compatible driver is found. -pub fn detect_driver() -> Option { - // Kubernetes: check for KUBERNETES_SERVICE_HOST env var (set inside pods) - if std::env::var_os("KUBERNETES_SERVICE_HOST").is_some() { - return Some(ComputeDriverKind::Kubernetes); - } - - // Podman: check for a reachable local API socket. - if is_podman_available() { - return Some(ComputeDriverKind::Podman); - } - - // Docker: check for a reachable local API socket. - if is_docker_available() { - return Some(ComputeDriverKind::Docker); - } - - None -} - -/// Return whether a responsive local Podman API socket is available. -#[must_use] -pub fn is_podman_available() -> bool { - detect_podman_socket().is_some() -} - -/// Return the Podman API socket, or `None` if Podman is not available. -/// -/// Probes the well-known socket candidates first, then falls back to asking -/// the Podman CLI where its socket lives. The symlink at a well-known path is -/// not always present — it varies by Podman version, machine provider, and -/// platform — so the CLI fallback is what makes detection work on hosts where -/// Podman is functional but the socket is somewhere else. -pub fn detect_podman_socket() -> Option { - detect_podman_socket_from_candidates(&podman_socket_candidates()) - .or_else(discover_podman_socket) -} - -fn detect_podman_socket_from_candidates(candidates: &[PathBuf]) -> Option { - candidates - .iter() - .find(|path| podman_socket_responds(path)) - .cloned() -} - -/// Maximum time to wait for a Podman discovery subprocess. -/// -/// Driver auto-detection runs `podman info`, `podman machine inspect`, and -/// `podman system connection list` during gateway startup. A stalled Podman -/// machine, SSH connection, provider, or helper must not block startup forever, -/// so each probe is bounded and treated as "not found" on expiry — detection -/// then continues to the next candidate driver or fails with an actionable -/// error. -const PODMAN_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5); - -/// How often the bounded runner polls the child for completion. -const PODMAN_DISCOVERY_POLL_INTERVAL: Duration = Duration::from_millis(25); - -/// Run `podman ` with a bounded deadline, returning captured stdout on a -/// successful exit. Returns `None` on spawn failure, non-zero exit, or timeout. -fn run_podman_capture(args: &[&str]) -> Option> { - run_bounded_command("podman", args, PODMAN_DISCOVERY_TIMEOUT) -} - -/// Run `program ` with a deadline, capturing stdout. -/// -/// Unlike `Command::output()`, which blocks until the child exits, the deadline -/// is absolute: the call returns within `timeout` no matter what the probe or -/// its descendants do. A Podman probe can leave a daemonized descendant (an SSH -/// multiplexer, `gvproxy`, etc.) that inherited the stdout pipe, so -/// `read_to_end` would otherwise wait for EOF forever even after the direct -/// child exits — and such a descendant may even have escaped the probe's -/// process group. To stay bounded, the deadline covers both waiting for the -/// child and draining its stdout; on expiry the call best-effort kills the -/// process group (cleaning up in-group descendants) and gives up immediately, -/// abandoning the reader thread rather than waiting on it again. The abandoned -/// reader exits on its own once the pipe finally closes. Returns the captured -/// stdout only on a successful exit whose output was fully drained within the -/// deadline; otherwise `None`. -fn run_bounded_command(program: &str, args: &[&str], timeout: Duration) -> Option> { - use std::io::Read as _; - use std::sync::mpsc; - - let mut command = Command::new(program); - command - .args(args) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()); - // Run the probe as its own process-group leader so in-group descendants that - // inherited the stdout pipe can be terminated as a group on timeout. - set_new_process_group(&mut command); - let mut child = command.spawn().ok()?; - - // Drain stdout on a separate thread so a child that fills the pipe buffer - // cannot deadlock against the polling loop below. The reader reports through - // a channel so the drain can be bounded by the deadline and abandoned if it - // outlives it. - let mut stdout = child.stdout.take()?; - let (tx, rx) = mpsc::channel(); - std::thread::spawn(move || { - let mut buf = Vec::new(); - let _ = stdout.read_to_end(&mut buf); - let _ = tx.send(buf); - }); - - let deadline = Instant::now() + timeout; - - // Wait for the direct child to exit, bounded by the deadline. - let status = loop { - match child.try_wait() { - Ok(Some(status)) => break Some(status), - Ok(None) => { - if Instant::now() >= deadline { - break None; - } - std::thread::sleep(PODMAN_DISCOVERY_POLL_INTERVAL); - } - Err(_) => break None, - } - }; - - // The child never exited within the deadline: kill the group, reap the - // (now-killed) direct child, and give up. `wait()` is bounded because the - // direct child is dead. - let Some(status) = status else { - terminate_process_group(&mut child); - let _ = child.wait(); - return None; - }; - - // The direct child exited (already reaped by `try_wait`). Collect its stdout - // without ever blocking past the deadline. A surviving descendant — possibly - // one that escaped the process group — can hold the pipe open indefinitely, - // so on expiry best-effort kill the group for cleanup and return None rather - // than waiting on the reader again. - let remaining = deadline.saturating_duration_since(Instant::now()); - match rx.recv_timeout(remaining) { - Ok(stdout) if status.success() => Some(stdout), - Ok(_) => None, - Err(_) => { - terminate_process_group(&mut child); - None - } - } -} - -/// Configure `command` to start its child as a new process-group leader so the -/// group can be signaled as a unit. No-op on non-Unix platforms. -#[cfg(unix)] -fn set_new_process_group(command: &mut Command) { - use std::os::unix::process::CommandExt as _; - command.process_group(0); -} - -#[cfg(not(unix))] -fn set_new_process_group(_command: &mut Command) {} - -/// Kill the child's whole process group so daemonized descendants that inherited -/// the stdout pipe are terminated too. Falls back to killing just the child. -#[cfg(unix)] -fn terminate_process_group(child: &mut std::process::Child) { - // `set_new_process_group` made the child a group leader, so its PID doubles - // as the group ID; signaling the negated PID targets the entire group. The - // group stays valid while a descendant is alive, so this reaches survivors - // even after the leader has been reaped. - let raw_pid = i32::try_from(child.id()).unwrap_or(i32::MAX); - let pgid = nix::unistd::Pid::from_raw(-raw_pid); - let _ = nix::sys::signal::kill(pgid, nix::sys::signal::Signal::SIGKILL); - let _ = child.kill(); -} - -#[cfg(not(unix))] -fn terminate_process_group(child: &mut std::process::Child) { - let _ = child.kill(); -} - -/// Query the Podman CLI to discover the host-side API socket path. -/// -/// Strategy: -/// 1. Run `podman info --format json` to check connectivity and whether -/// the service is remote (macOS/Windows VM) or local (native Linux). -/// 2. If `CONTAINER_HOST` explicitly points at a Unix socket, `podman info` -/// just connected through it — use that path directly (a raw unix:// URL -/// has no machine to inspect and reports the VM-internal socket). -/// 3. If `serviceIsRemote` is true, run `podman machine inspect` to get -/// the host-side forwarded socket (the `remoteSocket` from `podman info` -/// is the VM-internal path, which is not reachable from the host). -/// 4. If `serviceIsRemote` is false, use `remoteSocket.path` directly -/// (on native Linux this IS the real local socket). -fn discover_podman_socket() -> Option { - let stdout = run_podman_capture(&["info", "--format", "json"])?; - - // podman info succeeded, so an explicit unix:// CONTAINER_HOST is the exact - // working host-side socket. This must be checked before the machine path, - // which cannot map a raw unix:// endpoint to a machine. - if let Some(path) = explicit_unix_container_host() { - return Some(path); - } - - let info: serde_json::Value = serde_json::from_slice(&stdout).ok()?; - let is_remote = info["host"]["serviceIsRemote"].as_bool().unwrap_or(false); - - if is_remote { - discover_podman_machine_socket() - } else { - parse_podman_info_socket(&info) - } -} - -/// Return the socket path when `CONTAINER_HOST` is an explicit `unix://` URL. -/// -/// Honors Podman's precedence: `CONTAINER_CONNECTION` outranks `CONTAINER_HOST`, -/// so a set `CONTAINER_CONNECTION` means `podman info` did not use -/// `CONTAINER_HOST` and this returns `None`. -fn explicit_unix_container_host() -> Option { - if env_var_nonempty("CONTAINER_CONNECTION").is_some() { - return None; - } - let host = env_var_nonempty("CONTAINER_HOST")?; - unix_url_socket_path(&host) -} - -/// Parse the socket path from a `unix://` URL, or `None` for other schemes. -fn unix_url_socket_path(url: &str) -> Option { - let path = url.trim().strip_prefix("unix://")?; - (!path.is_empty()).then(|| PathBuf::from(path)) -} - -/// Extract the socket path from `podman info` JSON output. -/// Used on native Linux where `remoteSocket.path` is the real local socket. -fn parse_podman_info_socket(info: &serde_json::Value) -> Option { - let path_str = info["host"]["remoteSocket"]["path"].as_str()?; - let path = path_str.strip_prefix("unix://").unwrap_or(path_str); - if path.is_empty() { - return None; - } - Some(PathBuf::from(path)) -} - -/// Which Podman machine `podman info` connected through. -/// -/// Podman resolves its endpoint (highest precedence first) from -/// `CONTAINER_CONNECTION` (a named connection), then `CONTAINER_HOST` (a URL), -/// then the default connection in `containers.conf`. -#[derive(Debug, PartialEq, Eq)] -enum ActiveMachine { - /// An explicit selector (`CONTAINER_CONNECTION`, or `CONTAINER_HOST` mapped - /// to a connection by URL) named this connection. It must match a machine - /// exactly; guessing another machine would connect to the wrong socket. - Explicit(String), - /// An explicit `CONTAINER_HOST` is set but maps to no known connection - /// (e.g. a plain remote server, not a local machine). The active machine - /// cannot be determined and must not be guessed. - UnresolvedExplicit, - /// No explicit selector; the `containers.conf` default connection name, if - /// any. When absent, Podman's built-in default machine is inspected by name. - Default(Option), -} - -/// Run `podman machine inspect ` to discover the host-side forwarded -/// socket. Used on macOS/Windows where the Podman service runs inside a VM. -/// -/// The active machine is resolved first and inspected *by name*: a no-argument -/// `podman machine inspect` inspects only `podman-machine-default`, so a host -/// whose default connection is a different machine would otherwise be pointed -/// at the wrong machine's socket. When the active machine cannot be mapped to a -/// name, this returns `None` rather than substituting an unrelated machine. -fn discover_podman_machine_socket() -> Option { - let targets = podman_machine_inspect_targets(&active_podman_machine())?; - targets.iter().find_map(|name| { - let stdout = run_podman_capture(&["machine", "inspect", name])?; - let machines: serde_json::Value = serde_json::from_slice(&stdout).ok()?; - parse_podman_machine_inspect_socket(&machines) - }) -} - -/// Machine names to try with `podman machine inspect`, most specific first. -/// -/// `None` means the active machine cannot be determined; inspection must not -/// guess, since picking an unrelated machine would return the wrong socket. A -/// rootful connection is named `-root` while the machine itself is -/// ``, so the `-root`-stripped name is offered as a fallback. -fn podman_machine_inspect_targets(active: &ActiveMachine) -> Option> { - fn names_for(connection: &str) -> Vec { - let mut names = vec![connection.to_string()]; - if let Some(stripped) = connection.strip_suffix("-root") - && !stripped.is_empty() - { - names.push(stripped.to_string()); - } - names - } - - match active { - ActiveMachine::Explicit(name) | ActiveMachine::Default(Some(name)) => Some(names_for(name)), - ActiveMachine::UnresolvedExplicit => None, - // No explicit selector and no default connection: `podman info` used - // Podman's built-in default machine, so inspect it by name rather than - // guessing an arbitrary entry. - ActiveMachine::Default(None) => Some(vec!["podman-machine-default".to_string()]), - } -} - -/// Determine which machine `podman info` connected through. -fn active_podman_machine() -> ActiveMachine { - if let Some(name) = env_var_nonempty("CONTAINER_CONNECTION") { - return ActiveMachine::Explicit(name); - } - let container_host = env_var_nonempty("CONTAINER_HOST"); - resolve_active_podman_machine(container_host.as_deref(), podman_connection_list().as_ref()) -} - -/// Resolve the active machine from `CONTAINER_HOST` and the connection list. -/// -/// `CONTAINER_CONNECTION` is handled by the caller (it needs no connection -/// list). This is split out as a pure function for testing. -fn resolve_active_podman_machine( - container_host: Option<&str>, - connections: Option<&serde_json::Value>, -) -> ActiveMachine { - if let Some(host) = container_host { - return connections - .and_then(|c| podman_connection_name_for_uri(c, host)) - .map_or(ActiveMachine::UnresolvedExplicit, ActiveMachine::Explicit); - } - ActiveMachine::Default(connections.and_then(parse_default_podman_connection)) -} - -fn env_var_nonempty(key: &str) -> Option { - std::env::var(key) - .ok() - .filter(|value| !value.trim().is_empty()) -} - -/// Run `podman system connection list --format json`. -fn podman_connection_list() -> Option { - let stdout = run_podman_capture(&["system", "connection", "list", "--format", "json"])?; - serde_json::from_slice(&stdout).ok() -} - -/// Extract the default machine connection name from -/// `podman system connection list --format json`. -fn parse_default_podman_connection(connections: &serde_json::Value) -> Option { - connections - .as_array()? - .iter() - .find(|c| { - c["Default"].as_bool().unwrap_or(false) && c["IsMachine"].as_bool().unwrap_or(false) - }) - .and_then(|c| c["Name"].as_str()) - .map(str::to_string) -} - -/// Find the machine connection whose URI matches `CONTAINER_HOST`. -/// -/// Only machine connections (`IsMachine: true`) map to a local socket, so a -/// `CONTAINER_HOST` pointing at a plain remote server yields `None`. -fn podman_connection_name_for_uri(connections: &serde_json::Value, uri: &str) -> Option { - connections - .as_array()? - .iter() - .find(|c| c["IsMachine"].as_bool().unwrap_or(false) && c["URI"].as_str() == Some(uri)) - .and_then(|c| c["Name"].as_str()) - .map(str::to_string) -} - -/// Extract the host-side socket path from a `podman machine inspect ` -/// JSON array (which contains only the inspected machine). -fn parse_podman_machine_inspect_socket(machines: &serde_json::Value) -> Option { - let machine = machines.as_array()?.first()?; - let path_str = machine["ConnectionInfo"]["PodmanSocket"]["Path"].as_str()?; - if path_str.is_empty() { - return None; - } - Some(PathBuf::from(path_str)) -} - -fn podman_socket_candidates() -> Vec { - let socket = std::env::var("OPENSHELL_PODMAN_SOCKET") - .ok() - .filter(|path| !path.trim().is_empty()) - .map(PathBuf::from); - podman_socket_candidates_from_env( - socket, - std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from), - std::env::var_os("HOME").map(PathBuf::from), - ) -} - -fn podman_socket_candidates_from_env( - socket: Option, - runtime_dir: Option, - home: Option, -) -> Vec { - let mut candidates = Vec::new(); - - if let Some(path) = socket { - candidates.push(path); - } - - if let Some(runtime_dir) = runtime_dir { - candidates.push(runtime_dir.join("podman/podman.sock")); - } - - #[cfg(target_os = "linux")] - { - candidates.push(PathBuf::from(format!( - "/run/user/{}/podman/podman.sock", - current_uid() - ))); - } - - if let Some(home) = home { - candidates.push(home.join(".local/share/containers/podman/machine/podman.sock")); - } - - candidates -} - -/// Return whether a responsive local Docker API socket is available. -#[must_use] -pub fn is_docker_available() -> bool { - detect_docker_socket().is_some() -} - -pub fn detect_docker_socket() -> Option { - detect_docker_socket_from_candidates(&docker_socket_candidates()) -} - -fn detect_docker_socket_from_candidates(candidates: &[PathBuf]) -> Option { - candidates - .iter() - .find(|path| docker_socket_responds(path)) - .cloned() -} - -fn docker_socket_candidates() -> Vec { - let mut candidates = Vec::new(); - - if let Ok(host) = std::env::var("DOCKER_HOST") - && let Some(path) = docker_host_unix_socket_path(&host) - { - candidates.push(path); - } - - candidates.push(PathBuf::from("/var/run/docker.sock")); - - if let Some(home) = std::env::var_os("HOME") { - candidates.push(PathBuf::from(home).join(".docker/run/docker.sock")); - } - - if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") { - candidates.push(PathBuf::from(runtime_dir).join("docker.sock")); - } - - candidates -} - -fn docker_host_unix_socket_path(host: &str) -> Option { - let path = host.trim().strip_prefix("unix://")?; - (!path.is_empty()).then(|| PathBuf::from(path)) -} - -#[cfg(unix)] -fn is_unix_socket(path: &Path) -> bool { - path.metadata() - .is_ok_and(|metadata| metadata.file_type().is_socket()) -} - -#[cfg(unix)] -fn podman_socket_responds(path: &Path) -> bool { - unix_socket_http_ping(path, |response| { - http_response_is_success(response) && contains_ascii(response, b"Libpod-Api-Version:") - }) -} - -#[cfg(unix)] -fn docker_socket_responds(path: &Path) -> bool { - unix_socket_http_ping(path, |response| { - http_response_is_success(response) - && contains_ascii(response, b"Api-Version:") - && !contains_ascii(response, b"Libpod-Api-Version:") - }) -} - -#[cfg(unix)] -fn unix_socket_http_ping(path: &Path, accepts_response: impl FnOnce(&[u8]) -> bool) -> bool { - const PROBE_TIMEOUT: Duration = Duration::from_secs(1); - const PING_REQUEST: &[u8] = - b"GET /_ping HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; - - if !is_unix_socket(path) { - return false; - } - - let Ok(mut stream) = std::os::unix::net::UnixStream::connect(path) else { - return false; - }; - if stream.set_read_timeout(Some(PROBE_TIMEOUT)).is_err() - || stream.set_write_timeout(Some(PROBE_TIMEOUT)).is_err() - || stream.write_all(PING_REQUEST).is_err() - { - return false; - } - - let mut response = [0_u8; 512]; - let mut total = 0; - while total < response.len() { - let Ok(n) = stream.read(&mut response[total..]) else { - return false; - }; - if n == 0 { - break; - } - total += n; - if contains_ascii(&response[..total], b"\r\n\r\n") { - break; - } - } - total > 0 && accepts_response(&response[..total]) -} - -#[cfg(unix)] -fn http_response_is_success(response: &[u8]) -> bool { - response.starts_with(b"HTTP/1.1 200") || response.starts_with(b"HTTP/1.0 200") -} - -#[cfg(unix)] -fn contains_ascii(haystack: &[u8], needle: &[u8]) -> bool { - haystack - .windows(needle.len()) - .any(|window| window.eq_ignore_ascii_case(needle)) -} - -#[cfg(all(unix, test))] -fn is_reachable_unix_socket(path: &Path) -> bool { - is_unix_socket(path) && std::os::unix::net::UnixStream::connect(path).is_ok() -} - -#[cfg(all(unix, target_os = "linux"))] -fn current_uid() -> u32 { - use std::os::unix::fs::MetadataExt; - - std::fs::metadata("/proc/self").map_or(0, |metadata| metadata.uid()) -} - -#[cfg(not(unix))] -fn podman_socket_responds(path: &Path) -> bool { - let _ = path; - false -} - -#[cfg(not(unix))] -fn docker_socket_responds(path: &Path) -> bool { - let _ = path; - false -} - /// Server configuration. /// /// Built programmatically in [`crate::Config::new`] and the gateway CLI from @@ -1447,53 +825,13 @@ const fn default_ssh_session_ttl_secs() -> u64 { #[cfg(test)] mod tests { use super::{ - ActiveMachine, ComputeDriverKind, Config, DEFAULT_SERVICE_ROUTING_DOMAIN, - GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, - GatewayJwtConfig, GatewayProviderProfileSourceConfig, PolicyValidationFailureMode, - detect_docker_socket_from_candidates, detect_driver, detect_podman_socket_from_candidates, - docker_host_unix_socket_path, docker_socket_responds, explicit_unix_container_host, - normalize_compute_driver_name, parse_default_podman_connection, parse_podman_info_socket, - parse_podman_machine_inspect_socket, podman_connection_name_for_uri, - podman_machine_inspect_targets, podman_socket_candidates_from_env, podman_socket_responds, - resolve_active_podman_machine, run_bounded_command, unix_url_socket_path, + Config, DEFAULT_SERVICE_ROUTING_DOMAIN, GatewayInterceptorBindingPolicy, + GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, GatewayJwtConfig, + GatewayProviderProfileSourceConfig, PolicyValidationFailureMode, + normalize_compute_driver_name, }; - #[cfg(unix)] - use super::{is_reachable_unix_socket, is_unix_socket}; - #[cfg(unix)] - use std::io::{Read as _, Write as _}; use std::net::SocketAddr; - #[cfg(unix)] - use std::os::unix::net::UnixListener; - use std::path::PathBuf; use std::time::Duration; - #[cfg(unix)] - use std::time::Instant; - - #[test] - fn compute_driver_kind_parses_supported_values() { - assert_eq!( - "kubernetes".parse::().unwrap(), - ComputeDriverKind::Kubernetes - ); - assert_eq!( - "vm".parse::().unwrap(), - ComputeDriverKind::Vm - ); - assert_eq!( - "podman".parse::().unwrap(), - ComputeDriverKind::Podman - ); - assert_eq!( - "docker".parse::().unwrap(), - ComputeDriverKind::Docker - ); - } - - #[test] - fn compute_driver_kind_rejects_unknown_values() { - let err = "firecracker".parse::().unwrap_err(); - assert!(err.contains("unsupported compute driver 'firecracker'")); - } #[test] fn policy_validation_failure_mode_is_secure_by_default() { @@ -1714,244 +1052,6 @@ mod tests { assert_eq!(cfg.health_bind_address, Some(addr)); } - #[test] - fn detect_driver_returns_none_without_k8s_env_or_local_runtime() { - // When KUBERNETES_SERVICE_HOST is not set, no Docker binary/socket is - // available, and no Podman API socket is available, detect_driver - // should return None. - // This test may pass or fail depending on the test environment, - // but it documents the expected behavior. - let _ = detect_driver(); // Returns Some or None based on environment - } - - #[test] - fn docker_host_unix_socket_path_parses_unix_hosts() { - assert_eq!( - docker_host_unix_socket_path("unix:///var/run/docker.sock"), - Some(PathBuf::from("/var/run/docker.sock")) - ); - assert_eq!(docker_host_unix_socket_path("tcp://127.0.0.1:2375"), None); - assert_eq!(docker_host_unix_socket_path("unix://"), None); - } - - #[cfg(unix)] - #[test] - fn is_unix_socket_detects_socket_files() { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let socket_path = temp_dir.path().join("docker.sock"); - let _listener = UnixListener::bind(&socket_path).expect("bind unix socket"); - - assert!(is_unix_socket(&socket_path)); - assert!(is_reachable_unix_socket(&socket_path)); - - let regular_file = temp_dir.path().join("not-a-socket"); - std::fs::write(®ular_file, b"not a socket").expect("write regular file"); - assert!(!is_unix_socket(®ular_file)); - assert!(!is_reachable_unix_socket(®ular_file)); - } - - #[cfg(unix)] - #[test] - #[ignore = "flaky under concurrent test execution"] - fn podman_socket_probe_accepts_successful_ping_response() { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let socket_path = temp_dir.path().join("podman.sock"); - let listener = UnixListener::bind(&socket_path).expect("bind podman socket"); - - let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept podman probe"); - let mut request = [0_u8; 128]; - let n = stream.read(&mut request).expect("read podman probe"); - assert!(request[..n].starts_with(b"GET /_ping HTTP/1.1\r\n")); - stream - .write_all( - b"HTTP/1.1 200 OK\r\nLibpod-Api-Version: 5.8.2\r\nContent-Length: 2\r\n\r\nOK", - ) - .expect("write podman ping response"); - }); - - assert!(podman_socket_responds(&socket_path)); - handle.join().expect("probe server exits"); - } - - #[cfg(unix)] - #[test] - #[ignore = "flaky under concurrent test execution"] - fn podman_socket_probe_rejects_docker_ping_response() { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let socket_path = temp_dir.path().join("podman.sock"); - let listener = UnixListener::bind(&socket_path).expect("bind podman socket"); - - let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept podman probe"); - let mut request = [0_u8; 128]; - let n = stream.read(&mut request).expect("read podman probe"); - assert!(request[..n].starts_with(b"GET /_ping HTTP/1.1\r\n")); - stream - .write_all( - b"HTTP/1.1 200 OK\r\nServer: Docker/29.2.1\r\nContent-Length: 2\r\n\r\nOK", - ) - .expect("write docker ping response"); - }); - - assert!(!podman_socket_responds(&socket_path)); - handle.join().expect("probe server exits"); - } - - #[cfg(unix)] - #[test] - #[ignore = "flaky under concurrent test execution"] - fn docker_socket_probe_accepts_successful_ping_response() { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let socket_path = temp_dir.path().join("docker.sock"); - let listener = UnixListener::bind(&socket_path).expect("bind docker socket"); - - let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept docker probe"); - let mut request = [0_u8; 128]; - let n = stream.read(&mut request).expect("read docker probe"); - assert!(request[..n].starts_with(b"GET /_ping HTTP/1.1\r\n")); - stream - .write_all( - b"HTTP/1.1 200 OK\r\nApi-Version: 1.51\r\nDocker-Experimental: false\r\nContent-Length: 2\r\n\r\nOK", - ) - .expect("write docker ping response"); - }); - - assert!(docker_socket_responds(&socket_path)); - handle.join().expect("probe server exits"); - } - - #[cfg(unix)] - #[test] - #[ignore = "flaky under concurrent test execution"] - fn docker_socket_probe_rejects_podman_ping_response() { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let socket_path = temp_dir.path().join("podman.sock"); - let listener = UnixListener::bind(&socket_path).expect("bind podman socket"); - - let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept docker probe"); - let mut request = [0_u8; 128]; - let n = stream.read(&mut request).expect("read docker probe"); - assert!(request[..n].starts_with(b"GET /_ping HTTP/1.1\r\n")); - stream - .write_all( - b"HTTP/1.1 200 OK\r\nLibpod-Api-Version: 5.8.2\r\nContent-Length: 2\r\n\r\nOK", - ) - .expect("write podman ping response"); - }); - - assert!(!docker_socket_responds(&socket_path)); - handle.join().expect("probe server exits"); - } - - #[cfg(unix)] - #[test] - fn docker_socket_probe_rejects_inactive_socket() { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let socket_path = temp_dir.path().join("docker.sock"); - let listener = UnixListener::bind(&socket_path).expect("bind docker socket"); - drop(listener); - - assert!(is_unix_socket(&socket_path)); - assert!(!docker_socket_responds(&socket_path)); - } - - #[cfg(unix)] - #[test] - #[ignore = "flaky under concurrent test execution"] - fn docker_socket_detection_returns_the_responsive_candidate() { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let inactive_path = temp_dir.path().join("inactive.sock"); - let inactive_listener = UnixListener::bind(&inactive_path).expect("bind inactive socket"); - drop(inactive_listener); - - let responsive_path = temp_dir.path().join("responsive.sock"); - let listener = UnixListener::bind(&responsive_path).expect("bind responsive socket"); - let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept docker probe"); - let mut request = [0_u8; 128]; - let _ = stream.read(&mut request).expect("read docker probe"); - stream - .write_all(b"HTTP/1.1 200 OK\r\nApi-Version: 1.51\r\nContent-Length: 2\r\n\r\nOK") - .expect("write docker ping response"); - }); - - assert_eq!( - detect_docker_socket_from_candidates(&[inactive_path, responsive_path.clone(),]), - Some(responsive_path) - ); - handle.join().expect("probe server exits"); - } - - #[test] - fn podman_socket_candidates_include_env_runtime_and_home_paths() { - let candidates = podman_socket_candidates_from_env( - Some(PathBuf::from("/tmp/custom-podman.sock")), - Some(PathBuf::from("/tmp/runtime")), - Some(PathBuf::from("/tmp/home")), - ); - - assert!(candidates.contains(&PathBuf::from("/tmp/custom-podman.sock"))); - assert!(candidates.contains(&PathBuf::from("/tmp/runtime/podman/podman.sock"))); - assert!(candidates.contains(&PathBuf::from( - "/tmp/home/.local/share/containers/podman/machine/podman.sock" - ))); - } - - #[cfg(unix)] - #[test] - #[ignore = "flaky under concurrent test execution"] - fn podman_socket_detection_returns_the_responsive_candidate() { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let inactive_path = temp_dir.path().join("inactive.sock"); - let inactive_listener = UnixListener::bind(&inactive_path).expect("bind inactive socket"); - drop(inactive_listener); - - let responsive_path = temp_dir.path().join("responsive.sock"); - let listener = UnixListener::bind(&responsive_path).expect("bind responsive socket"); - let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept podman probe"); - let mut request = [0_u8; 128]; - let _ = stream.read(&mut request).expect("read podman probe"); - stream - .write_all( - b"HTTP/1.1 200 OK\r\nLibpod-Api-Version: 5.8.2\r\nContent-Length: 2\r\n\r\nOK", - ) - .expect("write podman ping response"); - }); - - assert_eq!( - detect_podman_socket_from_candidates(&[inactive_path, responsive_path.clone(),]), - Some(responsive_path) - ); - handle.join().expect("probe server exits"); - } - - #[test] - #[allow(unsafe_code)] // std::env::set_var/remove_var require unsafe in Rust 2024 - fn detect_driver_prefers_kubernetes_when_k8s_env_is_set() { - // Save the original env var - let original = std::env::var("KUBERNETES_SERVICE_HOST").ok(); - - // Set the env var - unsafe { - std::env::set_var("KUBERNETES_SERVICE_HOST", "127.0.0.1"); - } - - let result = detect_driver(); - assert_eq!(result, Some(ComputeDriverKind::Kubernetes)); - - // Restore the original env var - unsafe { - match original { - Some(val) => std::env::set_var("KUBERNETES_SERVICE_HOST", val), - None => std::env::remove_var("KUBERNETES_SERVICE_HOST"), - } - } - } - #[test] fn supervisor_image_tag_prefers_explicit_build_tags() { use super::resolve_supervisor_image_tag; @@ -1968,23 +1068,6 @@ mod tests { ); } - #[test] - fn parse_podman_info_socket_extracts_linux_local_socket() { - let info: serde_json::Value = serde_json::json!({ - "host": { - "serviceIsRemote": false, - "remoteSocket": { - "path": "unix:///run/user/1000/podman/podman.sock", - "exists": true - } - } - }); - assert_eq!( - parse_podman_info_socket(&info), - Some(PathBuf::from("/run/user/1000/podman/podman.sock")) - ); - } - #[test] fn supervisor_image_tag_sanitizes_build_metadata_for_oci() { use super::resolve_supervisor_image_tag; @@ -1998,22 +1081,6 @@ mod tests { ); } - #[test] - fn parse_podman_info_socket_handles_path_without_unix_prefix() { - let info: serde_json::Value = serde_json::json!({ - "host": { - "remoteSocket": { - "path": "/run/user/1000/podman/podman.sock", - "exists": true - } - } - }); - assert_eq!( - parse_podman_info_socket(&info), - Some(PathBuf::from("/run/user/1000/podman/podman.sock")) - ); - } - #[test] fn default_supervisor_image_is_version_pinned() { use super::default_supervisor_image; @@ -2022,348 +1089,4 @@ mod tests { let tag = image.rsplit_once(':').unwrap().1; assert!(!tag.is_empty()); } - - #[test] - fn parse_podman_info_socket_returns_none_for_missing_path() { - let info: serde_json::Value = serde_json::json!({ - "host": { - "remoteSocket": {} - } - }); - assert_eq!(parse_podman_info_socket(&info), None); - } - - #[test] - fn parse_podman_info_socket_returns_none_for_empty_path() { - let info: serde_json::Value = serde_json::json!({ - "host": { - "remoteSocket": { - "path": "", - "exists": false - } - } - }); - assert_eq!(parse_podman_info_socket(&info), None); - } - - #[test] - fn parse_podman_machine_inspect_socket_extracts_macos_socket() { - // `podman machine inspect ` returns only the inspected machine. - let machines: serde_json::Value = serde_json::json!([ - { - "ConnectionInfo": { - "PodmanSocket": { - "Path": "/var/folders/1q/jx7s14b928n8zvstgfk98lj00000gn/T/podman/podman-machine-default-api.sock" - }, - "PodmanPipe": null - }, - "Name": "podman-machine-default" - } - ]); - assert_eq!( - parse_podman_machine_inspect_socket(&machines), - Some(PathBuf::from( - "/var/folders/1q/jx7s14b928n8zvstgfk98lj00000gn/T/podman/podman-machine-default-api.sock" - )) - ); - } - - #[test] - fn parse_podman_machine_inspect_socket_returns_none_for_empty_array() { - let machines: serde_json::Value = serde_json::json!([]); - assert_eq!(parse_podman_machine_inspect_socket(&machines), None); - } - - #[test] - fn parse_podman_machine_inspect_socket_returns_none_for_missing_socket() { - let machines: serde_json::Value = serde_json::json!([ - { - "ConnectionInfo": {}, - "Name": "podman-machine-default" - } - ]); - assert_eq!(parse_podman_machine_inspect_socket(&machines), None); - } - - #[test] - fn podman_machine_inspect_targets_uses_explicit_machine_by_name() { - // The active connection points at `work`; discovery must inspect `work` - // explicitly rather than the no-argument default machine, which would - // return a different machine's socket. - assert_eq!( - podman_machine_inspect_targets(&ActiveMachine::Explicit("work".to_string())), - Some(vec!["work".to_string()]) - ); - } - - #[test] - fn podman_machine_inspect_targets_strips_rootful_suffix() { - // Rootful connections are named `-root`; the machine itself is - // ``, offered as a fallback after the connection name. - assert_eq!( - podman_machine_inspect_targets(&ActiveMachine::Explicit("work-root".to_string())), - Some(vec!["work-root".to_string(), "work".to_string()]) - ); - } - - #[test] - fn podman_machine_inspect_targets_uses_default_connection_name() { - assert_eq!( - podman_machine_inspect_targets(&ActiveMachine::Default(Some("work".to_string()))), - Some(vec!["work".to_string()]) - ); - } - - #[test] - fn podman_machine_inspect_targets_falls_back_to_builtin_default() { - // No explicit selector and no default connection: inspect Podman's own - // built-in default machine by name. - assert_eq!( - podman_machine_inspect_targets(&ActiveMachine::Default(None)), - Some(vec!["podman-machine-default".to_string()]) - ); - } - - #[test] - fn podman_machine_inspect_targets_does_not_guess_for_unresolved_explicit() { - // CONTAINER_HOST pointing at a non-machine endpoint cannot be mapped to - // a machine; discovery must not guess an unrelated one. - assert_eq!( - podman_machine_inspect_targets(&ActiveMachine::UnresolvedExplicit), - None - ); - } - - #[cfg(unix)] - #[test] - fn run_bounded_command_captures_stdout_on_success() { - assert_eq!( - run_bounded_command("printf", &["hello"], Duration::from_secs(5)), - Some(b"hello".to_vec()) - ); - } - - #[cfg(unix)] - #[test] - fn run_bounded_command_returns_none_on_nonzero_exit() { - assert_eq!( - run_bounded_command("false", &[], Duration::from_secs(5)), - None - ); - } - - #[cfg(unix)] - #[test] - fn run_bounded_command_kills_child_that_exceeds_deadline() { - // A process that would otherwise block startup indefinitely must be - // bounded: `run_bounded_command` returns within the deadline instead of - // hanging until the child exits. - let start = Instant::now(); - let result = run_bounded_command("sleep", &["30"], Duration::from_millis(200)); - let elapsed = start.elapsed(); - assert_eq!(result, None); - assert!( - elapsed < Duration::from_secs(5), - "bounded command did not return promptly: {elapsed:?}" - ); - } - - #[cfg(unix)] - #[test] - fn run_bounded_command_bounds_drain_when_in_group_descendant_holds_stdout() { - // The shell exits immediately after `echo`, but the backgrounded child - // (in the same process group) inherits and holds the stdout pipe open. - // Without a bounded drain, `read_to_end` would wait ~30s for EOF even - // though the direct child already exited. - let start = Instant::now(); - let result = run_bounded_command( - "sh", - &["-c", "sleep 30 & echo done"], - Duration::from_millis(300), - ); - let elapsed = start.elapsed(); - assert!( - elapsed < Duration::from_secs(2), - "drain blocked on a descendant holding stdout: {elapsed:?}" - ); - // Draining hit the deadline, so the probe is treated as "not found". - assert_eq!(result, None); - } - - #[cfg(unix)] - #[test] - fn run_bounded_command_bounds_drain_when_descendant_escapes_process_group() { - // `set -m` runs the background job in its OWN process group, so it - // survives the group kill while still holding the stdout pipe. The - // deadline must remain absolute: the call must not fall back to an - // untimed receive that waits for the escaped descendant's EOF. - let start = Instant::now(); - let result = run_bounded_command( - "bash", - &["-c", "set -m; sleep 5 & echo done"], - Duration::from_millis(300), - ); - let elapsed = start.elapsed(); - // A regression (blocking receive after the timeout) would wait ~5s for - // the escaped `sleep`; the bounded implementation returns promptly. - assert!( - elapsed < Duration::from_secs(2), - "drain blocked on a descendant that escaped the process group: {elapsed:?}" - ); - assert_eq!(result, None); - } - - #[cfg(unix)] - #[test] - fn run_bounded_command_returns_none_for_missing_program() { - assert_eq!( - run_bounded_command( - "openshell-nonexistent-binary-xyz", - &[], - Duration::from_secs(5) - ), - None - ); - } - - #[test] - fn resolve_active_podman_machine_maps_container_host_to_connection() { - let connections: serde_json::Value = serde_json::json!([ - { "Name": "work", "IsMachine": true, "Default": false, - "URI": "ssh://core@127.0.0.1:5555/run/user/1000/podman/podman.sock" }, - { "Name": "podman-machine-default", "IsMachine": true, "Default": true, - "URI": "ssh://core@127.0.0.1:4444/run/user/1000/podman/podman.sock" } - ]); - // CONTAINER_HOST pointing at the non-default machine's URI resolves to - // that machine, not the default. - assert_eq!( - resolve_active_podman_machine( - Some("ssh://core@127.0.0.1:5555/run/user/1000/podman/podman.sock"), - Some(&connections) - ), - ActiveMachine::Explicit("work".to_string()) - ); - } - - #[test] - fn resolve_active_podman_machine_unmatched_host_is_unresolved() { - let connections: serde_json::Value = serde_json::json!([ - { "Name": "podman-machine-default", "IsMachine": true, "Default": true, - "URI": "ssh://core@127.0.0.1:4444/run/user/1000/podman/podman.sock" } - ]); - // A CONTAINER_HOST that matches no machine connection is unresolved, - // never silently mapped to the default machine. - assert_eq!( - resolve_active_podman_machine(Some("tcp://192.0.2.10:2375"), Some(&connections)), - ActiveMachine::UnresolvedExplicit - ); - } - - #[test] - fn resolve_active_podman_machine_defaults_without_host() { - let connections: serde_json::Value = serde_json::json!([ - { "Name": "podman-machine-default", "IsMachine": true, "Default": true, - "URI": "ssh://core@127.0.0.1:4444/run/user/1000/podman/podman.sock" } - ]); - assert_eq!( - resolve_active_podman_machine(None, Some(&connections)), - ActiveMachine::Default(Some("podman-machine-default".to_string())) - ); - } - - #[test] - fn podman_connection_name_for_uri_ignores_non_machine_matches() { - let connections: serde_json::Value = serde_json::json!([ - { "Name": "remote", "IsMachine": false, "Default": false, - "URI": "tcp://192.0.2.10:2375" } - ]); - // A URI match against a non-machine connection does not map to a local - // machine socket. - assert_eq!( - podman_connection_name_for_uri(&connections, "tcp://192.0.2.10:2375"), - None - ); - } - - #[test] - fn unix_url_socket_path_parses_unix_urls() { - assert_eq!( - unix_url_socket_path("unix:///run/user/1000/podman/podman.sock"), - Some(PathBuf::from("/run/user/1000/podman/podman.sock")) - ); - // Non-unix schemes and empty paths are not sockets. - assert_eq!(unix_url_socket_path("ssh://core@127.0.0.1:22/x"), None); - assert_eq!(unix_url_socket_path("tcp://127.0.0.1:2375"), None); - assert_eq!(unix_url_socket_path("unix://"), None); - } - - #[test] - #[allow(unsafe_code)] // std::env::set_var/remove_var require unsafe in Rust 2024 - fn explicit_unix_container_host_honors_scheme_and_precedence() { - fn set(key: &str, value: Option<&str>) { - unsafe { - match value { - Some(v) => std::env::set_var(key, v), - None => std::env::remove_var(key), - } - } - } - - let original_host = std::env::var("CONTAINER_HOST").ok(); - let original_connection = std::env::var("CONTAINER_CONNECTION").ok(); - - // A unix:// CONTAINER_HOST with no CONTAINER_CONNECTION is used directly. - set("CONTAINER_CONNECTION", None); - set("CONTAINER_HOST", Some("unix:///tmp/podman/custom.sock")); - assert_eq!( - explicit_unix_container_host(), - Some(PathBuf::from("/tmp/podman/custom.sock")) - ); - - // CONTAINER_CONNECTION outranks CONTAINER_HOST. - set("CONTAINER_CONNECTION", Some("work")); - assert_eq!(explicit_unix_container_host(), None); - - // A non-unix CONTAINER_HOST is not a direct socket. - set("CONTAINER_CONNECTION", None); - set( - "CONTAINER_HOST", - Some("ssh://core@127.0.0.1:5555/run/podman.sock"), - ); - assert_eq!(explicit_unix_container_host(), None); - - // Nothing set. - set("CONTAINER_HOST", None); - assert_eq!(explicit_unix_container_host(), None); - - set("CONTAINER_HOST", original_host.as_deref()); - set("CONTAINER_CONNECTION", original_connection.as_deref()); - } - - #[test] - fn parse_default_podman_connection_picks_default_machine() { - let connections: serde_json::Value = serde_json::json!([ - { "Name": "podman-machine-default", "IsMachine": true, "Default": true }, - { "Name": "podman-machine-default-root", "IsMachine": true, "Default": false } - ]); - assert_eq!( - parse_default_podman_connection(&connections), - Some("podman-machine-default".to_string()) - ); - } - - #[test] - fn parse_default_podman_connection_ignores_non_machine_and_missing_default() { - // Default connection that is not a machine is ignored. - let non_machine: serde_json::Value = serde_json::json!([ - { "Name": "remote-host", "IsMachine": false, "Default": true } - ]); - assert_eq!(parse_default_podman_connection(&non_machine), None); - - // No default at all. - let no_default: serde_json::Value = serde_json::json!([ - { "Name": "work", "IsMachine": true, "Default": false } - ]); - assert_eq!(parse_default_podman_connection(&no_default), None); - } } diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index f3279d8105..c8be114ebd 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -437,9 +437,9 @@ pub const PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR: &str = "/spiffe-workloa /// /// The resulting path is `$XDG_STATE_HOME/openshell/[/]//sandbox.jwt`. /// -/// `driver_subdir` is driver-specific, e.g. `"docker-sandbox-tokens"` or -/// `"podman-sandbox-tokens"`. When `namespace` is `Some`, it is appended as -/// an additional path component (with `/` and `\` replaced by `-`). +/// `driver_subdir` is driver-specific. When `namespace` is `Some`, it is +/// appended as an additional path component (with `/` and `\` replaced by +/// `-`). /// /// # Errors /// Returns an error if the XDG state directory cannot be resolved. @@ -472,7 +472,7 @@ pub fn sandbox_log_level(sandbox: &DriverSandbox, default_level: &str) -> String } // --------------------------------------------------------------------------- -// Supervisor image helpers (shared by Docker and Podman drivers) +// Supervisor image helpers shared by container-backed drivers // --------------------------------------------------------------------------- /// Return the tag portion of a supervisor image reference, or `None` if the @@ -507,7 +507,7 @@ pub fn supervisor_image_should_refresh(image: &str) -> bool { } // --------------------------------------------------------------------------- -// Supervisor binary extraction helpers (shared by Docker and Podman drivers) +// Supervisor binary extraction helpers shared by container-backed drivers // --------------------------------------------------------------------------- #[cfg(feature = "driver-extraction")] @@ -584,8 +584,7 @@ pub fn write_cache_binary_atomic(final_path: &Path, bytes: &[u8]) -> Result<(), /// Return the host-side cache path for an extracted supervisor binary. /// /// The path is `$XDG_DATA_HOME/openshell///openshell-sandbox`. -/// `driver_subdir` distinguishes caches across drivers (e.g. `"docker-supervisor"`, -/// `"podman-supervisor"`). +/// `driver_subdir` distinguishes caches across drivers. pub fn supervisor_cache_path(driver_subdir: &str, digest: &str) -> Result { let base = crate::paths::xdg_data_dir() .map_err(|err| format!("failed to resolve XDG data dir: {err}"))?; diff --git a/crates/openshell-core/src/operator_namespace_allowlist.rs b/crates/openshell-core/src/dynamic_string_allowlist.rs similarity index 84% rename from crates/openshell-core/src/operator_namespace_allowlist.rs rename to crates/openshell-core/src/dynamic_string_allowlist.rs index c8f0f7f3de..1a2968188f 100644 --- a/crates/openshell-core/src/operator_namespace_allowlist.rs +++ b/crates/openshell-core/src/dynamic_string_allowlist.rs @@ -4,16 +4,13 @@ use std::collections::BTreeSet; use std::sync::{Arc, RwLock}; -/// Thread-safe dynamic allowlist of Kubernetes operator-mode namespaces. -/// -/// This type lives in the public core API because both the Kubernetes driver -/// and gateway authentication boundary consume it. +/// Thread-safe dynamic allowlist of strings shared across component boundaries. #[derive(Debug, Clone)] -pub struct OperatorNamespaceAllowlist { +pub struct DynamicStringAllowlist { inner: Arc>>, } -impl OperatorNamespaceAllowlist { +impl DynamicStringAllowlist { fn read_guard(&self) -> std::sync::RwLockReadGuard<'_, BTreeSet> { self.inner .read() @@ -71,7 +68,7 @@ impl OperatorNamespaceAllowlist { } } -impl Default for OperatorNamespaceAllowlist { +impl Default for DynamicStringAllowlist { fn default() -> Self { Self::new() } diff --git a/crates/openshell-core/src/error.rs b/crates/openshell-core/src/error.rs index 8c23e30198..145106012d 100644 --- a/crates/openshell-core/src/error.rs +++ b/crates/openshell-core/src/error.rs @@ -106,8 +106,8 @@ impl Error { /// Error type shared by all compute driver implementations. /// -/// Both the Podman and Kubernetes drivers map their backend-specific -/// errors into these variants before crossing crate boundaries. +/// Drivers map backend-specific errors into these variants before crossing +/// crate boundaries. #[derive(Debug, Error)] pub enum ComputeDriverError { /// The requested sandbox already exists. diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 315bb319bb..9aecfcb1f1 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -16,6 +16,7 @@ pub mod container_paths; pub mod denial; pub mod driver_mounts; pub mod driver_utils; +pub mod dynamic_string_allowlist; pub mod endpoint_path; pub mod error; #[cfg(unix)] @@ -28,12 +29,12 @@ pub mod host_pattern; pub mod image; pub mod inference; pub mod jwt; +pub mod local_api_socket; pub mod metadata; pub mod middleware; pub mod net; #[cfg(feature = "oauth")] pub mod oauth; -pub mod operator_namespace_allowlist; pub mod paths; pub mod policy; pub mod progress; @@ -50,16 +51,16 @@ pub mod time; pub mod transport_errors; pub use config::{ - ComputeDriverKind, Config, GatewayAuthConfig, GatewayInterceptorBindingOverride, - GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, - GatewayInterceptorPhaseConfig, GatewayJwtConfig, GatewayProviderProfileSourceConfig, - MtlsAuthConfig, OidcConfig, PolicyValidationFailureMode, TlsConfig, + Config, GatewayAuthConfig, GatewayInterceptorBindingOverride, GatewayInterceptorBindingPolicy, + GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, GatewayInterceptorPhaseConfig, + GatewayJwtConfig, GatewayProviderProfileSourceConfig, MtlsAuthConfig, OidcConfig, + PolicyValidationFailureMode, TlsConfig, }; +pub use dynamic_string_allowlist::DynamicStringAllowlist; pub use error::{ComputeDriverError, Error, Result}; pub use metadata::{ GetResourceVersion, ObjectId, ObjectLabels, ObjectName, ObjectWorkspace, SetResourceVersion, }; -pub use operator_namespace_allowlist::OperatorNamespaceAllowlist; /// Build version string derived from git metadata. /// diff --git a/crates/openshell-core/src/local_api_socket.rs b/crates/openshell-core/src/local_api_socket.rs new file mode 100644 index 0000000000..6804ae513a --- /dev/null +++ b/crates/openshell-core/src/local_api_socket.rs @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Generic discovery and probing for local HTTP APIs over Unix sockets. + +use std::path::{Path, PathBuf}; + +/// Return the first candidate whose HTTP ping response is accepted. +#[must_use] +pub fn first_responsive_socket( + candidates: &[PathBuf], + accepts_response: impl Fn(&[u8]) -> bool, +) -> Option { + candidates + .iter() + .find(|path| socket_responds(path, &accepts_response)) + .cloned() +} + +/// Return whether a byte slice contains another, ignoring ASCII case. +#[must_use] +pub fn contains_ascii(haystack: &[u8], needle: &[u8]) -> bool { + haystack + .windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle)) +} + +/// Return whether an HTTP response starts with a successful status line. +#[must_use] +pub fn http_response_is_success(response: &[u8]) -> bool { + response.starts_with(b"HTTP/1.1 200") || response.starts_with(b"HTTP/1.0 200") +} + +#[cfg(unix)] +fn socket_responds(path: &Path, accepts_response: &impl Fn(&[u8]) -> bool) -> bool { + use std::io::{Read as _, Write as _}; + use std::os::unix::fs::FileTypeExt as _; + use std::time::Duration; + + const PROBE_TIMEOUT: Duration = Duration::from_secs(1); + const PING_REQUEST: &[u8] = + b"GET /_ping HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + + if !path + .metadata() + .is_ok_and(|metadata| metadata.file_type().is_socket()) + { + return false; + } + let Ok(mut stream) = std::os::unix::net::UnixStream::connect(path) else { + return false; + }; + if stream.set_read_timeout(Some(PROBE_TIMEOUT)).is_err() + || stream.set_write_timeout(Some(PROBE_TIMEOUT)).is_err() + || stream.write_all(PING_REQUEST).is_err() + { + return false; + } + + let mut response = [0_u8; 512]; + let mut total = 0; + while total < response.len() { + let Ok(read) = stream.read(&mut response[total..]) else { + return false; + }; + if read == 0 { + break; + } + total += read; + if contains_ascii(&response[..total], b"\r\n\r\n") { + break; + } + } + total > 0 && accepts_response(&response[..total]) +} + +#[cfg(not(unix))] +fn socket_responds(path: &Path, accepts_response: &impl Fn(&[u8]) -> bool) -> bool { + let _ = (path, accepts_response); + false +} diff --git a/crates/openshell-core/src/sandbox_env.rs b/crates/openshell-core/src/sandbox_env.rs index 8da9a7d2a8..99ac55fe6e 100644 --- a/crates/openshell-core/src/sandbox_env.rs +++ b/crates/openshell-core/src/sandbox_env.rs @@ -105,7 +105,7 @@ impl MainProcessConfig { } /// Encode the versioned transport without whitespace for constrained - /// environment-variable transports such as libkrun. + /// environment-variable transports used by embedded runtimes. pub fn encode_driver_spec_base64url( spec: Option<&crate::proto::compute::v1::DriverSandboxSpec>, ) -> Result { diff --git a/crates/openshell-core/src/settings.rs b/crates/openshell-core/src/settings.rs index 625ba4c856..b7bf72c3d3 100644 --- a/crates/openshell-core/src/settings.rs +++ b/crates/openshell-core/src/settings.rs @@ -65,7 +65,7 @@ impl RegisteredSetting { /// /// 1. Add a [`RegisteredSetting`] entry to this array with the key name and /// [`SettingValueKind`]. -/// 2. Recompile `openshell-server` (gateway) and `openshell-sandbox` +/// 2. Recompile `openshell-gateway` and `openshell-sandbox` /// (supervisor). No database migration is needed -- new keys are stored in /// the existing settings JSON blob. /// 3. Add sandbox-side consumption in `openshell-sandbox` to read and act on diff --git a/crates/openshell-core/src/telemetry.rs b/crates/openshell-core/src/telemetry.rs index ad15989944..780e5c7920 100644 --- a/crates/openshell-core/src/telemetry.rs +++ b/crates/openshell-core/src/telemetry.rs @@ -160,50 +160,34 @@ impl SandboxTemplateSource { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TelemetryComputeDriver { - Docker, - Kubernetes, - Podman, - Vm, - Mxc, - Unknown, -} +pub struct TelemetryComputeDriver(&'static str); impl TelemetryComputeDriver { #[must_use] pub const fn as_str(self) -> &'static str { - match self { - Self::Docker => "docker", - Self::Kubernetes => "kubernetes", - Self::Podman => "podman", - Self::Vm => "vm", - Self::Mxc => "mxc", - Self::Unknown => "unknown", - } + self.0 } + /// Classify an unregistered compute driver without exposing its configured + /// name. #[must_use] - pub fn from_raw(raw: &str) -> Self { - match raw.trim().to_ascii_lowercase().as_str() { - "docker" => Self::Docker, - "k8s" | "kubernetes" => Self::Kubernetes, - "podman" => Self::Podman, - "vm" => Self::Vm, - "mxc" => Self::Mxc, - _ => Self::Unknown, - } + pub const fn custom() -> Self { + Self("custom") } + /// Define a bounded, anonymous category at a binary composition boundary. + /// + /// The category must be a static operational label. Never construct it + /// from user input, configuration, resource names, or other runtime data. #[must_use] - pub const fn from_driver_kind(driver_kind: Option) -> Self { - match driver_kind { - Some(crate::ComputeDriverKind::Docker) => Self::Docker, - Some(crate::ComputeDriverKind::Kubernetes) => Self::Kubernetes, - Some(crate::ComputeDriverKind::Podman) => Self::Podman, - Some(crate::ComputeDriverKind::Vm) => Self::Vm, - Some(crate::ComputeDriverKind::Mxc) => Self::Mxc, - None => Self::Unknown, - } + pub const fn anonymous_category(category: &'static str) -> Self { + Self(category) + } +} + +impl Default for TelemetryComputeDriver { + fn default() -> Self { + Self::custom() } } @@ -688,27 +672,11 @@ mod tests { } #[test] - fn compute_driver_values_are_sanitized() { - assert_eq!( - TelemetryComputeDriver::from_raw("docker").as_str(), - "docker" - ); - assert_eq!( - TelemetryComputeDriver::from_raw("k8s").as_str(), - "kubernetes" - ); - assert_eq!( - TelemetryComputeDriver::from_raw("KUBERNETES").as_str(), - "kubernetes" - ); - assert_eq!(TelemetryComputeDriver::from_raw("vm").as_str(), "vm"); - assert_eq!( - TelemetryComputeDriver::from_raw("podman").as_str(), - "podman" - ); + fn compute_driver_values_are_bounded_by_the_composition_boundary() { + assert_eq!(TelemetryComputeDriver::custom().as_str(), "custom"); assert_eq!( - TelemetryComputeDriver::from_raw("private-driver").as_str(), - "unknown" + TelemetryComputeDriver::anonymous_category("first_party").as_str(), + "first_party" ); } @@ -797,7 +765,7 @@ mod disabled_tests { 1, false, SandboxTemplateSource::Default, - TelemetryComputeDriver::Docker, + TelemetryComputeDriver::custom(), ); emit_policy_decision( PolicyDecisionOperation::Approve, diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 3941819c61..d599cac169 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -21,9 +21,7 @@ use bollard::query_parameters::{ }; use bytes::Bytes; use futures::{Stream, StreamExt}; -use openshell_core::config::{ - DEFAULT_DOCKER_NETWORK_NAME, DEFAULT_SANDBOX_PIDS_LIMIT, DEFAULT_STOP_TIMEOUT_SECS, -}; +use openshell_core::config::{DEFAULT_SANDBOX_PIDS_LIMIT, DEFAULT_STOP_TIMEOUT_SECS}; use openshell_core::driver_mounts; use openshell_core::driver_utils::{ CONDITION_EXITED, CONDITION_RUNTIME_RESTART, LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, @@ -56,7 +54,7 @@ use openshell_core::proto::compute::v1::{ use openshell_core::proto_struct::{ deserialize_optional_non_empty_string_list, struct_to_json_value, }; -use openshell_core::{Config, Error, Result as CoreResult}; +use openshell_core::{Error, Result as CoreResult}; use opentelemetry::trace::TraceContextExt as _; use std::collections::{HashMap, HashSet}; use std::future::Future; @@ -528,12 +526,45 @@ impl ComputeDriverService { } } +/// Return the first responsive local Docker API socket. +#[must_use] +pub fn detect_socket() -> Option { + let mut candidates = Vec::new(); + if let Ok(host) = std::env::var("DOCKER_HOST") + && let Some(path) = host.trim().strip_prefix("unix://") + && !path.is_empty() + { + candidates.push(PathBuf::from(path)); + } + candidates.push(PathBuf::from("/var/run/docker.sock")); + if let Some(home) = std::env::var_os("HOME") { + candidates.push(PathBuf::from(home).join(".docker/run/docker.sock")); + } + if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") { + candidates.push(PathBuf::from(runtime_dir).join("docker.sock")); + } + openshell_core::local_api_socket::first_responsive_socket(&candidates, |response| { + openshell_core::local_api_socket::http_response_is_success(response) + && openshell_core::local_api_socket::contains_ascii(response, b"Api-Version:") + && !openshell_core::local_api_socket::contains_ascii(response, b"Libpod-Api-Version:") + }) +} + +#[must_use] +pub fn is_available() -> bool { + detect_socket().is_some() +} + impl DockerComputeDriver { - pub async fn new(config: &Config, docker_config: &DockerComputeConfig) -> CoreResult { + pub async fn new( + gateway_bind_address: SocketAddr, + gateway_log_level: &str, + docker_config: &DockerComputeConfig, + ) -> CoreResult { let socket_path = docker_config .socket_path .clone() - .or_else(openshell_core::config::detect_docker_socket) + .or_else(detect_socket) .unwrap_or_else(|| PathBuf::from("/var/run/docker.sock")); let socket_path_str = socket_path.to_str().ok_or_else(|| { Error::config(format!( @@ -559,7 +590,7 @@ impl DockerComputeDriver { let cdi_gpu_inventory = docker_cdi_gpu_inventory(&info); let allow_all_default_gpu = docker_info_reports_wsl2(&info); validate_sandbox_pids_limit(docker_config.sandbox_pids_limit)?; - let gateway_port = config.bind_address.port(); + let gateway_port = gateway_bind_address.port(); if gateway_port == 0 { return Err(Error::config( "docker compute driver requires a fixed non-zero gateway bind port", @@ -571,7 +602,7 @@ impl DockerComputeDriver { let gateway_route = docker_gateway_route(&info, bridge_gateway_ip, gateway_port, host_gateway_ip); let gateway_callback_bind_address = - docker_gateway_callback_bind_address(&gateway_route, config.bind_address); + docker_gateway_callback_bind_address(&gateway_route, gateway_bind_address); let mut docker_config = docker_config.clone(); if docker_config.grpc_endpoint.trim().is_empty() { let scheme = if docker_guest_tls_configured(&docker_config) { @@ -603,7 +634,7 @@ impl DockerComputeDriver { gateway_callback_bind_address, ssh_socket_path: docker_config.ssh_socket_path.clone(), stop_timeout_secs: DEFAULT_STOP_TIMEOUT_SECS, - log_level: config.log_level.clone(), + log_level: gateway_log_level.to_string(), supervisor_bin, guest_tls, daemon_version: version.version.unwrap_or_else(|| "unknown".to_string()), @@ -636,6 +667,7 @@ impl DockerComputeDriver { default_image: self.config.default_image.clone(), gateway_manages_lifecycle: true, supports_sandbox_authentication: false, + driver_reports_runtime_readiness: false, } } @@ -4140,3 +4172,4 @@ fn internal_status(operation: &str, err: BollardError) -> Status { #[cfg(test)] mod tests; +pub const DEFAULT_DOCKER_NETWORK_NAME: &str = "openshell-docker"; diff --git a/crates/openshell-driver-docker/src/main.rs b/crates/openshell-driver-docker/src/main.rs index 2c6dbfd4c1..11a2762849 100644 --- a/crates/openshell-driver-docker/src/main.rs +++ b/crates/openshell-driver-docker/src/main.rs @@ -6,8 +6,8 @@ use std::path::PathBuf; use clap::Parser; use miette::{IntoDiagnostic, Result}; +use openshell_core::VERSION; use openshell_core::proto::compute::v1::compute_driver_server::ComputeDriverServer; -use openshell_core::{Config, VERSION}; use openshell_driver_docker::otel_tracing::compute_driver_rpc_layer; use openshell_driver_docker::{ComputeDriverService, DockerComputeConfig, DockerComputeDriver}; use tracing::info; @@ -67,8 +67,7 @@ async fn main() -> Result<()> { let config_source = std::fs::read_to_string(&args.config).into_diagnostic()?; let docker_config: DockerComputeConfig = toml::from_str(&config_source).into_diagnostic()?; - let gateway_config = Config::new(None).with_bind_address(args.gateway_bind); - let driver = DockerComputeDriver::new(&gateway_config, &docker_config) + let driver = DockerComputeDriver::new(args.gateway_bind, &args.log_level, &docker_config) .await .into_diagnostic()?; diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index fd81c9b638..07fb2b0ffb 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -43,6 +43,7 @@ fn test_sandbox() -> DriverSandbox { environment: HashMap::from([("TEMPLATE_ENV".to_string(), "template".to_string())]), ..Default::default() }), + policy: None, resource_requirements: None, sandbox_token: String::new(), command: Vec::new(), diff --git a/crates/openshell-driver-kubernetes/src/config.rs b/crates/openshell-driver-kubernetes/src/config.rs index aadcb1342a..805c0314b0 100644 --- a/crates/openshell-driver-kubernetes/src/config.rs +++ b/crates/openshell-driver-kubernetes/src/config.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -pub use openshell_core::OperatorNamespaceAllowlist; +pub use openshell_core::DynamicStringAllowlist as OperatorNamespaceAllowlist; use openshell_core::config; use serde::{Deserialize, Deserializer, Serialize}; use std::collections::BTreeMap; diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index 484cd07708..afe3f579e0 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -575,6 +575,7 @@ impl KubernetesComputeDriver { default_image: self.config.default_image.clone(), gateway_manages_lifecycle: false, supports_sandbox_authentication: true, + driver_reports_runtime_readiness: false, }) } @@ -3864,9 +3865,14 @@ fn sandbox_template_to_k8s_with_validated_config( } apply_pod_driver_config(&mut spec, &driver_config.pod); - // Per-sandbox platform_config.host_users overrides the cluster-wide default. - let use_user_namespaces = platform_config_bool(template, "host_users") - .map_or(params.enable_user_namespaces, |host_users| !host_users); + // Per-sandbox portable intent overrides the cluster-wide default. This + // driver owns the Kubernetes-specific `hostUsers` translation. Accept the + // former platform_config encoding during rolling upgrades from gateways + // that predate the typed field. + let use_user_namespaces = template + .user_namespaces + .or_else(|| platform_config_bool(template, "host_users").map(|host_users| !host_users)) + .unwrap_or(params.enable_user_namespaces); if use_user_namespaces { spec.insert("hostUsers".to_string(), serde_json::json!(false)); @@ -4482,7 +4488,7 @@ fn platform_config_bool(template: &SandboxTemplate, key: &str) -> Option { let config = template.platform_config.as_ref()?; let value = config.fields.get(key)?; match value.kind.as_ref() { - Some(prost_types::value::Kind::BoolValue(b)) => Some(*b), + Some(prost_types::value::Kind::BoolValue(value)) => Some(*value), _ => None, } } @@ -7610,15 +7616,7 @@ mod tests { #[test] fn user_namespaces_per_sandbox_override_enables() { let template = SandboxTemplate { - platform_config: Some(Struct { - fields: std::iter::once(( - "host_users".to_string(), - Value { - kind: Some(Kind::BoolValue(false)), - }, - )) - .collect(), - }), + user_namespaces: Some(true), ..SandboxTemplate::default() }; @@ -7634,7 +7632,7 @@ mod tests { assert_eq!( pod_template["spec"]["hostUsers"], serde_json::json!(false), - "per-sandbox host_users: false must enable user namespaces" + "per-sandbox user namespace intent must set hostUsers: false" ); let caps = pod_template["spec"]["containers"][0]["securityContext"]["capabilities"]["add"] .as_array() @@ -7645,15 +7643,7 @@ mod tests { #[test] fn user_namespaces_per_sandbox_override_disables() { let template = SandboxTemplate { - platform_config: Some(Struct { - fields: std::iter::once(( - "host_users".to_string(), - Value { - kind: Some(Kind::BoolValue(true)), - }, - )) - .collect(), - }), + user_namespaces: Some(false), ..SandboxTemplate::default() }; @@ -7671,7 +7661,7 @@ mod tests { assert!( pod_template["spec"]["hostUsers"].is_null(), - "per-sandbox host_users: true must disable user namespaces even when cluster default is on" + "per-sandbox user namespace intent must override the cluster default" ); let caps = pod_template["spec"]["containers"][0]["securityContext"]["capabilities"]["add"] .as_array() @@ -7683,6 +7673,37 @@ mod tests { ); } + #[test] + fn user_namespaces_accepts_legacy_host_users_encoding() { + let template = SandboxTemplate { + platform_config: Some(Struct { + fields: std::iter::once(( + "host_users".to_string(), + Value { + kind: Some(Kind::BoolValue(false)), + }, + )) + .collect(), + }), + ..SandboxTemplate::default() + }; + + let params = SandboxPodParams::default(); + let pod_template = sandbox_template_to_k8s( + &template, + false, + &std::collections::HashMap::new(), + true, + ¶ms, + ); + + assert_eq!( + pod_template["spec"]["hostUsers"], + serde_json::json!(false), + "legacy host_users: false must still enable user namespaces" + ); + } + #[test] fn automount_service_account_token_is_disabled() { let pod_template = { @@ -7866,43 +7887,6 @@ mod tests { ); } - #[test] - fn platform_config_bool_extracts_value() { - let template = SandboxTemplate { - platform_config: Some(Struct { - fields: std::iter::once(( - "my_bool".to_string(), - Value { - kind: Some(Kind::BoolValue(true)), - }, - )) - .collect(), - }), - ..SandboxTemplate::default() - }; - - assert_eq!(platform_config_bool(&template, "my_bool"), Some(true)); - assert_eq!(platform_config_bool(&template, "missing"), None); - } - - #[test] - fn platform_config_bool_returns_none_for_non_bool() { - let template = SandboxTemplate { - platform_config: Some(Struct { - fields: std::iter::once(( - "a_string".to_string(), - Value { - kind: Some(Kind::StringValue("hello".to_string())), - }, - )) - .collect(), - }), - ..SandboxTemplate::default() - }; - - assert_eq!(platform_config_bool(&template, "a_string"), None); - } - #[test] fn log_level_propagates_as_env_var_to_sandbox_pod() { let spec = SandboxSpec { diff --git a/crates/openshell-driver-kubernetes/src/lib.rs b/crates/openshell-driver-kubernetes/src/lib.rs index edc0a3e97e..28d3c77a7d 100644 --- a/crates/openshell-driver-kubernetes/src/lib.rs +++ b/crates/openshell-driver-kubernetes/src/lib.rs @@ -14,4 +14,4 @@ pub use config::{ }; pub use driver::{KubernetesComputeDriver, KubernetesDriverError}; pub use grpc::ComputeDriverService; -pub use openshell_core::OperatorNamespaceAllowlist; +pub use openshell_core::DynamicStringAllowlist as OperatorNamespaceAllowlist; diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 6496dfe286..bd0b51f48b 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -4,8 +4,11 @@ OpenShell compute driver backed by **Microsoft MXC** (`wxc-exec`) on Windows. ## Design -This driver implements the gateway's `ComputeDriver` contract as an in-process -library linked into `openshell-gateway`. `process_container` launches a one-shot +This driver implements the gateway's ordinary in-process `ComputeDriver` +contract and is linked into `openshell-gateway`. It sets +`driver_reports_runtime_readiness`, so the gateway accepts driver-reported +readiness without a supervisor session. The canonical create-time +`SandboxPolicy` is carried by `DriverSandboxSpec.policy`. `process_container` launches a one-shot AppContainer and is the default. The opt-in `isolation_session` backend uses the state-aware `provision` → `start` → `exec` → `stop` → `deprovision` lifecycle. The driver launches and monitors the configured workload itself and self-reports @@ -51,7 +54,9 @@ openshell sandbox create --name mxc-demo --policy demo.yaml ` The `command` array is required and preserves Windows argument boundaries. `cwd` is optional. Environment variables come from the standard sandbox and template environment maps; the driver never copies values from the gateway host environment. -Network policy and live policy replacement or merge updates are rejected while the gateway uses MXC. Delete and recreate the sandbox to apply a different filesystem policy. +Network policy support depends on the selected containment path. Policy +replacement and merge updates use the gateway's standard sandbox configuration +revision contract. ## Prerequisites (live runs) @@ -64,7 +69,11 @@ no isolation session needed), set `OPENSHELL_MXC_MOCK_WXC=1`. ## Policy mapping -The production driver maps the typed `SandboxPolicy` to MXC configuration before it inserts a registry entry or invokes `wxc-exec`. Mapping failure therefore returns from `CreateSandbox` without leaving a partial sandbox. +The production driver maps the typed `SandboxPolicy` carried by the standard +driver request to MXC configuration before it inserts a registry entry or +invokes `wxc-exec`. Mapping failure therefore returns from `CreateSandbox` +without leaving a partial sandbox. There is no in-process policy side channel +or MXC-specific gateway composition variant. `EmbeddedPolicyMapper` calls the embedded [`policy_map`](src/policy_map/) module directly and normalizes filesystem paths to Windows form. It does not add gateway-configured host paths. The policy supplied for the sandbox is the only source of filesystem grants. diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index d8e981a46d..1ded7130c4 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -177,13 +177,6 @@ pub struct MxcComputeBackend { registry: Arc>>, watch_tx: Arc>, policy_mapper: Arc, - /// Out-of-band side channel for the `SandboxPolicy` (A1). The proto driver - /// contract has no `policy` field and there is no driver-side - /// `GetSandboxConfig`, so `ComputeRuntime::create_sandbox` stages the policy - /// here keyed by sandbox id (mirroring the `sandbox_token` injection), - /// immediately before dispatching to this backend's `create_sandbox`, which - /// removes/consumes it. - pending_policies: Arc>>, } impl std::fmt::Debug for MxcComputeBackend { @@ -279,17 +272,9 @@ impl MxcComputeBackend { // Production policy translation is always handled by the embedded // mapper before any MXC lifecycle side effects begin. policy_mapper: Arc::new(EmbeddedPolicyMapper), - pending_policies: Arc::new(Mutex::new(HashMap::new())), } } - /// Returns a clone of the `pending_policies` side channel so the gateway's - /// `ComputeRuntime` can stage the typed `SandboxPolicy` by sandbox id right - /// before dispatching `create_sandbox` (A1 wiring). - pub fn policy_sink(&self) -> Arc>> { - self.pending_policies.clone() - } - /// Test-only constructor wiring the in-process mock `wxc-exec` shim. #[cfg(test)] pub(crate) fn new_mocked(config: MxcComputeConfig) -> Self { @@ -304,10 +289,12 @@ impl MxcComputeBackend { driver_version: DRIVER_VERSION.to_string(), default_image: DEFAULT_IMAGE_SENTINEL.to_string(), gateway_manages_lifecycle: false, + supports_sandbox_authentication: false, + driver_reports_runtime_readiness: true, } } - pub fn validate_sandbox_create(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { + fn validate_sandbox_fields(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { if let Some(spec) = &sandbox.spec { if effective_driver_gpu_count(driver_gpu_requirements( spec.resource_requirements.as_ref(), @@ -330,6 +317,29 @@ impl MxcComputeBackend { sandbox_config(sandbox)?; Ok(()) } + + fn map_sandbox_policy( + &self, + sandbox_id: &str, + policy: Option<&SandboxPolicy>, + ) -> Result { + self.policy_mapper + .map( + policy, + &MapCtx { + sandbox_id: sandbox_id.to_string(), + egress: None, + }, + ) + .map_err(|error| tonic::Status::invalid_argument(error.to_string())) + } + + pub fn validate_sandbox_create(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { + self.validate_sandbox_fields(sandbox)?; + let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); + self.map_sandbox_policy(&sandbox.id, policy)?; + Ok(()) + } pub async fn get_sandbox(&self, sandbox_name: &str) -> Option { let registry = self.registry.lock().await; registry @@ -346,25 +356,14 @@ impl MxcComputeBackend { pub async fn create_sandbox(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { let sandbox_id = sandbox.id.clone(); - // Consume the out-of-band policy staged by `ComputeRuntime::create_sandbox` - // (A1). Always remove so rejected creates cannot leak policy state. - let policy = self.pending_policies.lock().await.remove(&sandbox_id); - self.validate_sandbox_create(sandbox)?; + self.validate_sandbox_fields(sandbox)?; let sandbox_config = sandbox_config(sandbox)?; // Policy translation is deterministic and side-effect free. Do it before // inserting the registry entry or launching MXC so invalid requests fail // synchronously at the CreateSandbox boundary. - let mapped = self - .policy_mapper - .map( - policy.as_ref(), - &MapCtx { - sandbox_id: sandbox_id.clone(), - egress: None, - }, - ) - .map_err(|error| tonic::Status::invalid_argument(error.to_string()))?; + let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); + let mapped = self.map_sandbox_policy(&sandbox_id, policy)?; if sandbox .spec @@ -941,6 +940,11 @@ mod lifecycle_tests { } } + fn with_policy(mut sandbox: DriverSandbox, policy: SandboxPolicy) -> DriverSandbox { + sandbox.spec.as_mut().unwrap().policy = Some(policy); + sandbox + } + fn ready_condition(sb: &DriverSandbox) -> Option { sb.status .as_ref()? @@ -1027,13 +1031,8 @@ mod lifecycle_tests { ]; let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); - // Stage the policy via the A1 side channel (as ComputeRuntime would). - let sink = backend.policy_sink(); - sink.lock() - .await - .insert("sb-pos".into(), fs_policy(&[&share])); - - let sb = driver_sandbox_with_command("sb-pos", &share, cmd); + let policy = fs_policy(&[&share]); + let sb = with_policy(driver_sandbox_with_command("sb-pos", &share, cmd), policy); backend.create_sandbox(&sb).await.expect("create accepted"); // Self-reported Ready=True (no supervisor) once the agent exec launches. @@ -1085,12 +1084,8 @@ mod lifecycle_tests { ]; let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); - let sink = backend.policy_sink(); - sink.lock() - .await - .insert("sb-pc".into(), fs_policy(&[&share])); - - let sb = driver_sandbox_with_command("sb-pc", &share, cmd); + let policy = fs_policy(&[&share]); + let sb = with_policy(driver_sandbox_with_command("sb-pc", &share, cmd), policy); backend.create_sandbox(&sb).await.expect("create accepted"); let ready = wait_for(&backend, "sb-pc", |s| { @@ -1142,12 +1137,10 @@ mod lifecycle_tests { // Subscribe to the watch stream BEFORE create so we catch the denial event. let mut stream = backend.watch_sandboxes().await; - let sink = backend.policy_sink(); - sink.lock() - .await - .insert("sb-neg".into(), fs_policy(&[&share])); + let policy = fs_policy(&[&share]); + let sandbox = with_policy(driver_sandbox_with_command("sb-neg", &share, cmd), policy); backend - .create_sandbox(&driver_sandbox_with_command("sb-neg", &share, cmd)) + .create_sandbox(&sandbox) .await .expect("create accepted"); @@ -1199,13 +1192,10 @@ mod lifecycle_tests { format!("$null = '{share}'; Start-Sleep -Seconds 60"), ]; let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + let policy = fs_policy(&[&share]); + let sandbox = with_policy(driver_sandbox_with_command("sb-stop", "", command), policy); backend - .policy_sink() - .lock() - .await - .insert("sb-stop".into(), fs_policy(&[&share])); - backend - .create_sandbox(&driver_sandbox_with_command("sb-stop", "", command)) + .create_sandbox(&sandbox) .await .expect("create accepted"); wait_for(&backend, "sb-stop", |sandbox| { @@ -1245,13 +1235,9 @@ mod lifecycle_tests { binaries: Vec::new(), }, ); - backend - .policy_sink() - .lock() - .await - .insert("sb-net".into(), policy); + let sandbox = with_policy(driver_sandbox("sb-net"), policy); let error = backend - .create_sandbox(&driver_sandbox("sb-net")) + .create_sandbox(&sandbox) .await .expect_err("unmappable policy must fail CreateSandbox synchronously"); assert_eq!(error.code(), tonic::Code::InvalidArgument); diff --git a/crates/openshell-driver-mxc/src/grpc.rs b/crates/openshell-driver-mxc/src/grpc.rs index 9e7dada94f..0fa23f7ac9 100644 --- a/crates/openshell-driver-mxc/src/grpc.rs +++ b/crates/openshell-driver-mxc/src/grpc.rs @@ -56,8 +56,8 @@ impl ComputeDriver for ComputeDriverService { &self, request: Request, ) -> Result, Status> { + let request = request.into_inner(); let sandbox = request - .into_inner() .sandbox .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; self.backend.validate_sandbox_create(&sandbox)?; @@ -99,8 +99,8 @@ impl ComputeDriver for ComputeDriverService { &self, request: Request, ) -> Result, Status> { + let request = request.into_inner(); let sandbox = request - .into_inner() .sandbox .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; self.backend.create_sandbox(&sandbox).await?; diff --git a/crates/openshell-driver-mxc/src/policy.rs b/crates/openshell-driver-mxc/src/policy.rs index 1f26d186e5..a909238851 100644 --- a/crates/openshell-driver-mxc/src/policy.rs +++ b/crates/openshell-driver-mxc/src/policy.rs @@ -78,8 +78,8 @@ fn format_loss(items: &[LossItem]) -> String { /// Translates an `OpenShell` `SandboxPolicy` into an MXC `ContainerConfig` /// fragment, returning a loss report of anything unrepresentable. pub trait PolicyMapper: Send + Sync { - /// `policy` is `None` only when the gateway failed to stage one (the MXC - /// path treats that as a hard error — the demo's whole point is enforcement). + /// `policy` is `None` only when the create request omitted it. The MXC path + /// treats that as a hard error because it cannot launch without enforcement. fn map(&self, policy: Option<&SandboxPolicy>, ctx: &MapCtx) -> Result; } diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index ccd4413b3f..e53ddc9f3f 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -457,11 +457,11 @@ matter compared to cluster or rootful runtimes: ## Implementation References -- Gateway integration: `crates/openshell-server/src/compute/mod.rs` - (`new_podman` and `PodmanComputeDriver` wiring). -- Server configuration: `crates/openshell-server/src/lib.rs` - (`ComputeDriverKind::Podman` builds `PodmanComputeConfig` including - `sandbox_ssh_socket_path` from gateway `Config`). +- Gateway integration: `crates/openshell-gateway/src/lib.rs` registers the + driver factory and constructs `PodmanComputeConfig` from the generic server + build context. +- Server configuration: `crates/openshell-server/src/lib.rs` exposes the + backend-agnostic registry and factory context. - Gateway relay path: `openshell-core` `Config::sandbox_ssh_socket_path` in `crates/openshell-core/src/config.rs`. - SSRF mitigation: `crates/openshell-core/src/net.rs`, diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index 8f7c0d32f6..aa41017663 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -311,11 +311,21 @@ fn podman_gpu_selection_error(err: CdiGpuSelectionError) -> ComputeDriverError { ComputeDriverError::Precondition(err.to_string()) } +/// Return the first responsive local Podman API socket. +#[must_use] +pub fn detect_socket() -> Option { + crate::socket_discovery::detect_socket() +} + +#[must_use] +pub fn is_available() -> bool { + detect_socket().is_some() +} + /// Resolve the socket to connect to: explicit configuration wins, otherwise /// fall back to `detect`. Returns an error if neither resolves. /// -/// Takes `detect` as a parameter (rather than calling -/// [`openshell_core::config::detect_podman_socket`] directly) so tests can +/// Takes `detect` as a parameter so tests can /// exercise the precedence deterministically, without touching real /// environment variables or the filesystem. fn resolve_socket_path( @@ -337,10 +347,7 @@ impl PodmanComputeDriver { const MAX_PING_RETRIES: u32 = 5; const PING_RETRY_DELAY: Duration = Duration::from_secs(2); - let socket_path = resolve_socket_path( - config.socket_path.clone(), - openshell_core::config::detect_podman_socket, - )?; + let socket_path = resolve_socket_path(config.socket_path.clone(), detect_socket)?; config.socket_path = Some(socket_path.clone()); if !socket_path.exists() { @@ -508,6 +515,7 @@ impl PodmanComputeDriver { default_image: self.config.default_image.clone(), gateway_manages_lifecycle: true, supports_sandbox_authentication: false, + driver_reports_runtime_readiness: false, }) } diff --git a/crates/openshell-driver-podman/src/lib.rs b/crates/openshell-driver-podman/src/lib.rs index 7a78cf5d92..115e64eb2f 100644 --- a/crates/openshell-driver-podman/src/lib.rs +++ b/crates/openshell-driver-podman/src/lib.rs @@ -7,6 +7,7 @@ pub(crate) mod container; pub mod driver; pub mod grpc; pub mod otel_tracing; +mod socket_discovery; #[cfg(test)] pub(crate) mod test_utils; pub(crate) mod watcher; diff --git a/crates/openshell-driver-podman/src/socket_discovery.rs b/crates/openshell-driver-podman/src/socket_discovery.rs new file mode 100644 index 0000000000..e7a6c79e74 --- /dev/null +++ b/crates/openshell-driver-podman/src/socket_discovery.rs @@ -0,0 +1,402 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Podman socket discovery for local and machine-backed installations. + +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5); +const DISCOVERY_POLL_INTERVAL: Duration = Duration::from_millis(25); + +/// Return the first responsive well-known socket, then ask the Podman CLI for +/// the active native or machine-backed connection. +pub fn detect_socket() -> Option { + openshell_core::local_api_socket::first_responsive_socket(&socket_candidates(), |response| { + openshell_core::local_api_socket::http_response_is_success(response) + && openshell_core::local_api_socket::contains_ascii(response, b"Libpod-Api-Version:") + }) + .or_else(discover_socket) +} + +fn socket_candidates() -> Vec { + let mut candidates = Vec::new(); + if let Some(path) = env_var_nonempty("OPENSHELL_PODMAN_SOCKET") { + candidates.push(PathBuf::from(path)); + } + if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") { + candidates.push(PathBuf::from(runtime_dir).join("podman/podman.sock")); + } + #[cfg(target_os = "linux")] + candidates.push(PathBuf::from(format!( + "/run/user/{}/podman/podman.sock", + rustix::process::geteuid().as_raw() + ))); + if let Some(home) = std::env::var_os("HOME") { + candidates + .push(PathBuf::from(home).join(".local/share/containers/podman/machine/podman.sock")); + } + candidates +} + +/// Query the same active connection selected by the Podman CLI. This covers +/// named/rootful machines and provider-specific forwarded socket locations that +/// do not have a stable well-known path. +fn discover_socket() -> Option { + let stdout = run_podman_capture(&["info", "--format", "json"])?; + + // A successful `podman info` proves this explicit socket is usable. A named + // connection has higher precedence, so only honor CONTAINER_HOST directly + // when no CONTAINER_CONNECTION is set. + if env_var_nonempty("CONTAINER_CONNECTION").is_none() + && let Some(path) = env_var_nonempty("CONTAINER_HOST") + .as_deref() + .and_then(unix_url_socket_path) + { + return Some(path); + } + + let info: serde_json::Value = serde_json::from_slice(&stdout).ok()?; + if !info["host"]["serviceIsRemote"].as_bool().unwrap_or(false) { + return parse_info_socket(&info); + } + + discover_machine_socket() +} + +fn discover_machine_socket() -> Option { + let connections = connection_list(); + let active = active_machine( + env_var_nonempty("CONTAINER_CONNECTION").as_deref(), + env_var_nonempty("CONTAINER_HOST").as_deref(), + connections.as_ref(), + )?; + machine_inspect_targets(&active) + .into_iter() + .find_map(|name| { + let stdout = run_podman_capture(&["machine", "inspect", &name])?; + let machines: serde_json::Value = serde_json::from_slice(&stdout).ok()?; + parse_machine_socket(&machines) + }) +} + +fn active_machine( + container_connection: Option<&str>, + container_host: Option<&str>, + connections: Option<&serde_json::Value>, +) -> Option { + if let Some(name) = container_connection.filter(|name| !name.trim().is_empty()) { + return Some(name.to_string()); + } + if let Some(host) = container_host.filter(|host| !host.trim().is_empty()) { + // An explicit non-machine endpoint must not fall back to an unrelated + // local machine. + return connection_name_for_uri(connections?, host); + } + default_machine_connection(connections).or_else(|| Some("podman-machine-default".to_string())) +} + +fn machine_inspect_targets(connection: &str) -> Vec { + let mut names = vec![connection.to_string()]; + if let Some(machine) = connection.strip_suffix("-root") + && !machine.is_empty() + { + names.push(machine.to_string()); + } + names +} + +fn connection_list() -> Option { + let stdout = run_podman_capture(&["system", "connection", "list", "--format", "json"])?; + serde_json::from_slice(&stdout).ok() +} + +fn default_machine_connection(connections: Option<&serde_json::Value>) -> Option { + connections? + .as_array()? + .iter() + .find(|connection| { + connection["Default"].as_bool().unwrap_or(false) + && connection["IsMachine"].as_bool().unwrap_or(false) + }) + .and_then(|connection| connection["Name"].as_str()) + .map(str::to_string) +} + +fn connection_name_for_uri(connections: &serde_json::Value, uri: &str) -> Option { + connections + .as_array()? + .iter() + .find(|connection| { + connection["IsMachine"].as_bool().unwrap_or(false) + && connection["URI"].as_str() == Some(uri) + }) + .and_then(|connection| connection["Name"].as_str()) + .map(str::to_string) +} + +fn parse_info_socket(info: &serde_json::Value) -> Option { + let path = info["host"]["remoteSocket"]["path"].as_str()?; + unix_url_socket_path(path).or_else(|| (!path.is_empty()).then(|| PathBuf::from(path))) +} + +fn parse_machine_socket(machines: &serde_json::Value) -> Option { + let path = machines.as_array()?.first()?["ConnectionInfo"]["PodmanSocket"]["Path"].as_str()?; + (!path.is_empty()).then(|| PathBuf::from(path)) +} + +fn unix_url_socket_path(url: &str) -> Option { + let path = url.trim().strip_prefix("unix://")?; + (!path.is_empty()).then(|| PathBuf::from(path)) +} + +fn env_var_nonempty(key: &str) -> Option { + std::env::var(key) + .ok() + .filter(|value| !value.trim().is_empty()) +} + +fn run_podman_capture(args: &[&str]) -> Option> { + run_bounded_command("podman", args, DISCOVERY_TIMEOUT) +} + +/// Capture stdout without allowing a stalled Podman machine, SSH transport, or +/// descendant holding the stdout pipe open to block gateway startup forever. +fn run_bounded_command(program: &str, args: &[&str], timeout: Duration) -> Option> { + use std::io::Read as _; + use std::sync::mpsc; + + let mut command = Command::new(program); + command + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + set_new_process_group(&mut command); + let mut child = command.spawn().ok()?; + + let mut stdout = child.stdout.take()?; + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let mut output = Vec::new(); + let _ = stdout.read_to_end(&mut output); + let _ = sender.send(output); + }); + + let deadline = Instant::now() + timeout; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break Some(status), + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(DISCOVERY_POLL_INTERVAL); + } + Ok(None) | Err(_) => break None, + } + }; + + let Some(status) = status else { + terminate_process_group(&mut child); + let _ = child.wait(); + return None; + }; + + match receiver.recv_timeout(deadline.saturating_duration_since(Instant::now())) { + Ok(output) if status.success() => Some(output), + Ok(_) => None, + Err(_) => { + terminate_process_group(&mut child); + None + } + } +} + +#[cfg(unix)] +fn set_new_process_group(command: &mut Command) { + use std::os::unix::process::CommandExt as _; + command.process_group(0); +} + +#[cfg(not(unix))] +fn set_new_process_group(_command: &mut Command) {} + +#[cfg(unix)] +fn terminate_process_group(child: &mut std::process::Child) { + let pid = i32::try_from(child.id()).unwrap_or(i32::MAX); + let group = nix::unistd::Pid::from_raw(-pid); + let _ = nix::sys::signal::kill(group, nix::sys::signal::Signal::SIGKILL); + let _ = child.kill(); +} + +#[cfg(not(unix))] +fn terminate_process_group(child: &mut std::process::Child) { + let _ = child.kill(); +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parse_info_socket_rejects_missing_or_empty_paths() { + assert_eq!( + parse_info_socket(&json!({"host": {"remoteSocket": {}}})), + None + ); + assert_eq!( + parse_info_socket(&json!({"host": {"remoteSocket": {"path": ""}}})), + None + ); + } + + #[test] + fn parse_machine_socket_rejects_missing_socket_or_machine() { + assert_eq!(parse_machine_socket(&json!([])), None); + assert_eq!(parse_machine_socket(&json!([{"ConnectionInfo": {}}])), None); + } + + #[test] + fn parses_native_and_machine_socket_paths() { + let info = json!({"host": {"remoteSocket": {"path": "unix:///run/user/1000/podman.sock"}}}); + assert_eq!( + parse_info_socket(&info), + Some(PathBuf::from("/run/user/1000/podman.sock")) + ); + + let machine = json!([{"ConnectionInfo": {"PodmanSocket": {"Path": "/tmp/machine.sock"}}}]); + assert_eq!( + parse_machine_socket(&machine), + Some(PathBuf::from("/tmp/machine.sock")) + ); + } + + #[test] + fn resolves_named_rootful_and_default_machine_connections() { + let connections = json!([ + {"Name": "team-machine-root", "URI": "ssh://team", "Default": true, "IsMachine": true}, + {"Name": "remote", "URI": "ssh://remote", "Default": false, "IsMachine": false} + ]); + assert_eq!( + active_machine(Some("team-machine-root"), None, Some(&connections)), + Some("team-machine-root".to_string()) + ); + assert_eq!( + machine_inspect_targets("team-machine-root"), + ["team-machine-root", "team-machine"] + ); + assert_eq!( + active_machine(None, None, Some(&connections)), + Some("team-machine-root".to_string()) + ); + } + + #[test] + fn explicit_non_machine_endpoint_does_not_guess_a_machine() { + let connections = json!([ + {"Name": "local", "URI": "ssh://local", "Default": true, "IsMachine": true}, + {"Name": "remote", "URI": "ssh://remote", "Default": false, "IsMachine": false} + ]); + assert_eq!( + active_machine(None, Some("ssh://remote"), Some(&connections)), + None + ); + } + + #[test] + fn container_host_maps_to_the_matching_machine_connection() { + let connections = json!([ + {"Name": "work", "URI": "ssh://core@127.0.0.1:5555/run/podman.sock", "Default": false, "IsMachine": true}, + {"Name": "podman-machine-default", "URI": "ssh://core@127.0.0.1:4444/run/podman.sock", "Default": true, "IsMachine": true} + ]); + assert_eq!( + active_machine( + None, + Some("ssh://core@127.0.0.1:5555/run/podman.sock"), + Some(&connections), + ), + Some("work".to_string()) + ); + } + + #[test] + fn unix_url_socket_path_only_accepts_nonempty_unix_urls() { + assert_eq!( + unix_url_socket_path("unix:///run/user/1000/podman.sock"), + Some(PathBuf::from("/run/user/1000/podman.sock")) + ); + assert_eq!(unix_url_socket_path("ssh://core@127.0.0.1/x"), None); + assert_eq!(unix_url_socket_path("tcp://127.0.0.1:2375"), None); + assert_eq!(unix_url_socket_path("unix://"), None); + } + + #[cfg(unix)] + #[test] + fn bounded_command_captures_stdout_on_success() { + assert_eq!( + run_bounded_command("printf", &["hello"], Duration::from_secs(5)), + Some(b"hello".to_vec()) + ); + } + + #[cfg(unix)] + #[test] + fn bounded_command_returns_none_on_nonzero_exit_or_missing_program() { + assert_eq!( + run_bounded_command("false", &[], Duration::from_secs(5)), + None + ); + assert_eq!( + run_bounded_command( + "openshell-nonexistent-binary-xyz", + &[], + Duration::from_secs(5), + ), + None + ); + } + + #[cfg(unix)] + #[test] + fn bounded_command_kills_child_that_exceeds_deadline() { + let start = Instant::now(); + let result = run_bounded_command("sleep", &["30"], Duration::from_millis(200)); + assert_eq!(result, None); + assert!( + start.elapsed() < Duration::from_secs(5), + "bounded command did not return promptly" + ); + } + + #[cfg(unix)] + #[test] + fn bounded_command_bounds_drain_when_in_group_descendant_holds_stdout() { + let start = Instant::now(); + let result = run_bounded_command( + "sh", + &["-c", "sleep 30 & echo done"], + Duration::from_millis(300), + ); + assert_eq!(result, None); + assert!( + start.elapsed() < Duration::from_secs(2), + "drain blocked on a descendant holding stdout" + ); + } + + #[cfg(unix)] + #[test] + fn bounded_command_bounds_drain_when_descendant_escapes_process_group() { + let start = Instant::now(); + let result = run_bounded_command( + "bash", + &["-c", "set -m; sleep 5 & echo done"], + Duration::from_millis(300), + ); + assert_eq!(result, None); + assert!( + start.elapsed() < Duration::from_secs(2), + "drain blocked on a descendant that escaped the process group" + ); + } +} diff --git a/crates/openshell-driver-podman/src/watcher.rs b/crates/openshell-driver-podman/src/watcher.rs index 0c94bf72b2..c3903a0067 100644 --- a/crates/openshell-driver-podman/src/watcher.rs +++ b/crates/openshell-driver-podman/src/watcher.rs @@ -122,15 +122,12 @@ fn deleted_event(sandbox_id: String) -> WatchSandboxesEvent { /// drops (daemon restart, socket error, or clean shutdown), the stream /// terminates with a final error item and stops producing events. /// -/// Callers are responsible for reconnecting by calling [`start_watch`] again. -/// The server's `ComputeRuntime::watch_loop` in `openshell-server` provides -/// this behaviour with a 2-second backoff: when the stream terminates with an -/// error, `watch_loop` sleeps and then calls `watch_sandboxes()` again, which -/// ultimately calls `start_watch()` again and re-syncs state. +/// Callers are responsible for reconnecting by calling [`start_watch`] again +/// and re-synchronizing state. /// /// **Do not add reconnection logic inside this function.** A local reconnect -/// would race with `watch_loop`'s retry and produce duplicate initial-sync -/// events that corrupt the server's sandbox index. +/// would race with the consumer's retry and produce duplicate initial-sync +/// events. pub async fn start_watch( client: PodmanClient, lifecycle_event_fences: LifecycleEventFences, diff --git a/crates/openshell-driver-vm/Cargo.toml b/crates/openshell-driver-vm/Cargo.toml index 30a0f2a970..0d250b6e71 100644 --- a/crates/openshell-driver-vm/Cargo.toml +++ b/crates/openshell-driver-vm/Cargo.toml @@ -22,6 +22,7 @@ path = "src/main.rs" openshell-core = { path = "../openshell-core", default-features = false } openshell-otel = { path = "../openshell-otel" } openshell-policy = { path = "../openshell-policy" } +openshell-driver-podman = { path = "../openshell-driver-podman" } openshell-vfio = { path = "../openshell-vfio" } bollard = { version = "0.20", features = ["ssh"] } diff --git a/crates/openshell-driver-vm/README.md b/crates/openshell-driver-vm/README.md index f455beaeb5..4fc9ace415 100644 --- a/crates/openshell-driver-vm/README.md +++ b/crates/openshell-driver-vm/README.md @@ -9,7 +9,7 @@ Standalone libkrun-backed [`ComputeDriver`](../../proto/compute_driver.proto) fo ```mermaid flowchart LR subgraph host["Host process"] - gateway["openshell-server
(compute::vm::spawn)"] + gateway["openshell-gateway
(vm::spawn)"] driver["openshell-driver-vm
├── libkrun (VM)
├── gvproxy (net)
└── openshell-sandbox.zst"] gateway <-->|"gRPC over UDS
compute-driver.sock"| driver end @@ -102,7 +102,7 @@ mise run vm:supervisor # if openshell-sandbox.zst is not already presen # 2. Build both binaries with the staged artifacts embedded OPENSHELL_VM_RUNTIME_COMPRESSED_DIR=$PWD/target/vm-runtime-compressed \ - cargo build -p openshell-server -p openshell-driver-vm + cargo build -p openshell-gateway -p openshell-driver-vm # 3. macOS only: codesign the driver for Hypervisor.framework codesign \ @@ -291,5 +291,5 @@ the user explicitly overrides it. ## TODOs -- The gateway still configures the driver via CLI args; this will move to a gRPC bootstrap call so the driver interface is uniform across backends. See the `TODO(driver-abstraction)` notes in `crates/openshell-server/src/lib.rs` and `crates/openshell-server/src/compute/vm.rs`. +- The gateway still configures the driver via CLI args; this will move to a gRPC bootstrap call so the driver interface is uniform across backends. See the `TODO(driver-abstraction)` note in `crates/openshell-gateway/src/vm.rs`. - macOS local builds are codesigned by `tasks/scripts/gateway-vm.sh`; the generated Homebrew formula signs the release tarball driver for local installs. diff --git a/crates/openshell-driver-vm/runtime/README.md b/crates/openshell-driver-vm/runtime/README.md index 11aab67f43..b686874ba2 100644 --- a/crates/openshell-driver-vm/runtime/README.md +++ b/crates/openshell-driver-vm/runtime/README.md @@ -41,7 +41,7 @@ mise run vm:supervisor # Build the gateway and VM driver with embedded runtime artifacts OPENSHELL_VM_RUNTIME_COMPRESSED_DIR=$PWD/target/vm-runtime-compressed \ - cargo build -p openshell-server -p openshell-driver-vm + cargo build -p openshell-gateway -p openshell-driver-vm ``` Use `FROM_SOURCE=1 mise run vm:setup` to build the runtime from source instead diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index 2de65c3add..8adcc79f92 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -525,6 +525,7 @@ impl VmDriver { default_image: self.config.default_image.clone(), gateway_manages_lifecycle: true, supports_sandbox_authentication: false, + driver_reports_runtime_readiness: false, } } @@ -3663,7 +3664,7 @@ async fn connect_local_container_engine() -> Option { return Some(docker); } - let podman_socket = openshell_core::config::detect_podman_socket()?; + let podman_socket = detect_podman_socket()?; if let Ok(docker) = Docker::connect_with_unix(podman_socket.to_str()?, 120, bollard::API_DEFAULT_VERSION) && docker.ping().await.is_ok() @@ -3678,6 +3679,10 @@ async fn connect_local_container_engine() -> Option { None } +fn detect_podman_socket() -> Option { + openshell_driver_podman::driver::detect_socket() +} + fn is_openshell_local_build_image_ref(image_ref: &str) -> bool { image_ref.starts_with("openshell/sandbox-from:") } diff --git a/crates/openshell-gateway/Cargo.toml b/crates/openshell-gateway/Cargo.toml new file mode 100644 index 0000000000..f9d02027e0 --- /dev/null +++ b/crates/openshell-gateway/Cargo.toml @@ -0,0 +1,72 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-gateway" +description = "OpenShell gateway binary composition" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "openshell-gateway" +path = "src/main.rs" + +[dependencies] +openshell-core = { path = "../openshell-core", default-features = false } +openshell-server = { path = "../openshell-server", default-features = false } +openshell-otel = { path = "../openshell-otel", optional = true } +async-trait = "0.1" +miette = { workspace = true } +tokio = { workspace = true } + +[target.'cfg(not(target_os = "windows"))'.dependencies] +openshell-driver-docker = { path = "../openshell-driver-docker", optional = true } +openshell-driver-kubernetes = { path = "../openshell-driver-kubernetes", optional = true } +openshell-driver-podman = { path = "../openshell-driver-podman", optional = true } +hyper-util = { workspace = true, optional = true } +nix = { workspace = true, optional = true } +serde = { workspace = true, optional = true } +rustix = { workspace = true, optional = true } +tonic = { workspace = true, optional = true } +tower = { workspace = true, optional = true } +tracing = { workspace = true, optional = true } + +[target.'cfg(target_os = "windows")'.dependencies] +openshell-driver-mxc = { path = "../openshell-driver-mxc", optional = true } + +[features] +default = ["telemetry", "in-tree-compute-drivers"] +in-tree-compute-drivers = [ + "dep:openshell-driver-docker", + "dep:openshell-driver-kubernetes", + "dep:openshell-driver-podman", + "dep:openshell-otel", + "dep:hyper-util", + "dep:nix", + "dep:serde", + "dep:rustix", + "dep:tonic", + "dep:tower", + "dep:tracing", + "dep:openshell-driver-mxc", +] +telemetry = ["openshell-core/telemetry", "openshell-server/telemetry"] +## Convenience alias: every default feature except `telemetry`. Build a +## telemetry-free gateway with +## `--no-default-features --features defaults-without-telemetry` and stay +## correct as new default features are added. Cargo cannot subtract a single +## default feature, so this alias must be paired with `--no-default-features`; +## enabling it alongside `telemetry` is a compile error rather than a silent +## telemetry-on build. Kept in sync with `default` by +## `rust:verify:defaults-without-telemetry`. +defaults-without-telemetry = ["in-tree-compute-drivers"] +bundled-z3 = ["openshell-server/bundled-z3"] + +[lints] +workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/crates/openshell-gateway/src/lib.rs b/crates/openshell-gateway/src/lib.rs new file mode 100644 index 0000000000..c5a7ebe0e7 --- /dev/null +++ b/crates/openshell-gateway/src/lib.rs @@ -0,0 +1,452 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Standard gateway binary composition. +//! +//! The server remains backend-agnostic. This crate is the composition boundary +//! that links first-party compute drivers into the distributed gateway binary. + +// `defaults-without-telemetry` is an alias for the default feature set minus +// `telemetry`, not a switch that turns telemetry off. Cargo cannot subtract a +// default feature, so adding it on top of the defaults would otherwise produce +// a telemetry-on build that reads as telemetry-free. Fail the build instead. +#[cfg(all(feature = "telemetry", feature = "defaults-without-telemetry"))] +compile_error!( + "features `telemetry` and `defaults-without-telemetry` are mutually exclusive; \ + build a telemetry-free gateway with `--no-default-features --features defaults-without-telemetry`" +); + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +mod vm; + +#[cfg(feature = "in-tree-compute-drivers")] +use openshell_core::telemetry::TelemetryComputeDriver; +#[cfg(feature = "in-tree-compute-drivers")] +use openshell_server::ComputeDriverRegistration; +use openshell_server::ComputeDriverRegistry; + +/// Install every first-party compute driver linked into the standard gateway. +#[must_use] +pub fn install_default_compute_drivers() -> ComputeDriverRegistry { + #[allow(unused_mut)] + let mut registry = ComputeDriverRegistry::new(); + #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] + install_in_tree_compute_drivers(&mut registry); + #[cfg(all(target_os = "windows", feature = "in-tree-compute-drivers"))] + install_mxc_compute_driver(&mut registry); + registry +} + +#[cfg(all(target_os = "windows", feature = "in-tree-compute-drivers"))] +fn install_mxc_compute_driver(registry: &mut ComputeDriverRegistry) { + let registration = ComputeDriverRegistration::new("mxc", u16::MAX, None, MxcFactory) + .expect("first-party driver name is valid") + .with_telemetry_category(TelemetryComputeDriver::anonymous_category("mxc")) + .with_local_singleplayer(); + registry + .install(registration) + .expect("first-party driver names are unique"); + + for name in ["docker", "kubernetes", "podman", "vm"] { + let registration = ComputeDriverRegistration::new( + name, + u16::MAX, + None, + UnsupportedWindowsFactory { name }, + ) + .expect("first-party driver name is valid"); + registry + .install(registration) + .expect("first-party driver names are unique"); + } +} + +#[cfg(all(target_os = "windows", feature = "in-tree-compute-drivers"))] +#[derive(Clone, Copy)] +struct UnsupportedWindowsFactory { + name: &'static str, +} + +#[cfg(all(target_os = "windows", feature = "in-tree-compute-drivers"))] +#[async_trait::async_trait] +impl openshell_server::ComputeDriverFactory for UnsupportedWindowsFactory { + async fn build( + &self, + _context: openshell_server::ComputeDriverBuildContext<'_>, + ) -> openshell_core::Result { + Err(unsupported_windows_compute_driver(self.name)) + } +} + +#[cfg(all(target_os = "windows", feature = "in-tree-compute-drivers"))] +fn unsupported_windows_compute_driver(name: &str) -> openshell_core::Error { + openshell_core::Error::config(format!("compute driver '{name}' is unsupported on Windows")) +} + +#[cfg(all(target_os = "windows", feature = "in-tree-compute-drivers"))] +#[derive(Clone, Copy)] +struct MxcFactory; + +#[cfg(all(target_os = "windows", feature = "in-tree-compute-drivers"))] +#[async_trait::async_trait] +impl openshell_server::ComputeDriverFactory for MxcFactory { + async fn build( + &self, + context: openshell_server::ComputeDriverBuildContext<'_>, + ) -> openshell_core::Result { + let config: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; + let backend = openshell_driver_mxc::MxcComputeBackend::new(config); + let driver = openshell_driver_mxc::ComputeDriverService::new(backend); + Ok(openshell_server::ComputeDriverInstance::InProcess( + std::sync::Arc::new(driver), + )) + } +} + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +fn install_in_tree_compute_drivers(registry: &mut ComputeDriverRegistry) { + for registration in [ + ComputeDriverRegistration::new( + "kubernetes", + 100, + Some(|| std::env::var_os("KUBERNETES_SERVICE_HOST").is_some()), + KubernetesFactory, + ) + .map(|registration| { + registration + .with_telemetry_category(TelemetryComputeDriver::anonymous_category("kubernetes")) + .without_mtls_user_auth() + .with_tracing_setup(kubernetes_tracing_setup) + .with_inherited_config_keys(&[ + "namespace", + "default_image", + "supervisor_image", + "client_tls_secret_name", + "service_account_name", + "host_gateway_ip", + "enable_user_namespaces", + "sa_token_ttl_secs", + ]) + }), + ComputeDriverRegistration::new( + "podman", + 200, + Some(openshell_driver_podman::driver::is_available), + PodmanFactory, + ) + .map(|registration| { + registration + .with_telemetry_category(TelemetryComputeDriver::anonymous_category("podman")) + .with_local_singleplayer() + .with_tracing_setup(podman_tracing_setup) + .with_inherited_config_keys(&[ + "default_image", + "supervisor_image", + "host_gateway_ip", + "guest_tls_ca", + "guest_tls_cert", + "guest_tls_key", + ]) + }), + ComputeDriverRegistration::new( + "docker", + 300, + Some(openshell_driver_docker::is_available), + DockerFactory, + ) + .map(|registration| { + registration + .with_telemetry_category(TelemetryComputeDriver::anonymous_category("docker")) + .with_local_singleplayer() + .with_tracing_setup(docker_tracing_setup) + .with_inherited_config_keys(&[ + "sandbox_namespace", + "default_image", + "supervisor_image", + "host_gateway_ip", + "guest_tls_ca", + "guest_tls_cert", + "guest_tls_key", + ]) + }), + ComputeDriverRegistration::new("vm", u16::MAX, None, VmFactory).map(|registration| { + registration + .with_telemetry_category(TelemetryComputeDriver::anonymous_category("vm")) + .with_local_singleplayer() + .with_inherited_config_keys(&[ + "default_image", + "guest_tls_ca", + "guest_tls_cert", + "guest_tls_key", + ]) + }), + ] { + registry + .install(registration.expect("first-party driver name is valid")) + .expect("first-party driver names are unique"); + } +} + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +fn kubernetes_tracing_setup( + otlp_endpoint: Option<&str>, + gateway_name: Option<&str>, +) -> openshell_server::ComputeDriverTracingSetup { + let (provider, error) = + openshell_driver_kubernetes::otel_tracing::provider_for(otlp_endpoint, gateway_name); + let layer = provider.as_ref().map(|provider| { + let layer: openshell_server::ComputeDriverTracingLayer = Box::new( + openshell_driver_kubernetes::otel_tracing::in_process_layer(provider), + ); + layer + }); + let shutdown = provider.map(|provider| { + let shutdown: openshell_server::ComputeDriverTracingShutdown = + Box::new(move || provider.shutdown().map_err(|error| error.to_string())); + shutdown + }); + openshell_server::ComputeDriverTracingSetup::new( + layer, + shutdown, + error.map(|error| error.to_string()), + Some(openshell_driver_kubernetes::otel_tracing::IN_PROCESS_TARGET_PREFIX), + ) +} + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +fn podman_tracing_setup( + otlp_endpoint: Option<&str>, + gateway_name: Option<&str>, +) -> openshell_server::ComputeDriverTracingSetup { + let (provider, error) = + openshell_driver_podman::otel_tracing::provider_for(otlp_endpoint, gateway_name); + let layer = provider.as_ref().map(|provider| { + let layer: openshell_server::ComputeDriverTracingLayer = Box::new( + openshell_driver_podman::otel_tracing::in_process_layer(provider), + ); + layer + }); + let shutdown = provider.map(|provider| { + let shutdown: openshell_server::ComputeDriverTracingShutdown = + Box::new(move || provider.shutdown().map_err(|error| error.to_string())); + shutdown + }); + openshell_server::ComputeDriverTracingSetup::new( + layer, + shutdown, + error.map(|error| error.to_string()), + Some(openshell_driver_podman::otel_tracing::IN_PROCESS_TARGET_PREFIX), + ) +} + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +fn docker_tracing_setup( + otlp_endpoint: Option<&str>, + gateway_name: Option<&str>, +) -> openshell_server::ComputeDriverTracingSetup { + let (provider, error) = + openshell_driver_docker::otel_tracing::provider_for(otlp_endpoint, gateway_name); + let layer = provider.as_ref().map(|provider| { + let layer: openshell_server::ComputeDriverTracingLayer = Box::new( + openshell_driver_docker::otel_tracing::in_process_layer(provider), + ); + layer + }); + let shutdown = provider.map(|provider| { + let shutdown: openshell_server::ComputeDriverTracingShutdown = + Box::new(move || provider.shutdown().map_err(|error| error.to_string())); + shutdown + }); + openshell_server::ComputeDriverTracingSetup::new( + layer, + shutdown, + error.map(|error| error.to_string()), + Some(openshell_driver_docker::otel_tracing::IN_PROCESS_TARGET_PREFIX), + ) +} + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +#[derive(Clone, Copy)] +struct KubernetesFactory; + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +#[async_trait::async_trait] +impl openshell_server::ComputeDriverFactory for KubernetesFactory { + async fn build( + &self, + context: openshell_server::ComputeDriverBuildContext<'_>, + ) -> openshell_core::Result { + let mut config: openshell_driver_kubernetes::KubernetesComputeConfig = + context.driver_config()?; + if let Ok(size) = std::env::var("OPENSHELL_K8S_WORKSPACE_DEFAULT_STORAGE_SIZE") { + config.workspace_default_storage_size = size; + } + if let Ok(storage_class) = std::env::var("OPENSHELL_K8S_WORKSPACE_STORAGE_CLASS") { + config.workspace_storage_class = storage_class; + } + let driver = openshell_driver_kubernetes::KubernetesComputeDriver::new( + config, + context.shutdown_receiver(), + ) + .await + .map_err(|error| openshell_core::Error::execution(error.to_string()))?; + let driver = openshell_driver_kubernetes::ComputeDriverService::new_in_process(driver); + Ok(openshell_server::ComputeDriverInstance::InProcess( + std::sync::Arc::new(driver), + )) + } +} + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +#[derive(Clone, Copy)] +struct DockerFactory; + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +#[async_trait::async_trait] +impl openshell_server::ComputeDriverFactory for DockerFactory { + async fn build( + &self, + context: openshell_server::ComputeDriverBuildContext<'_>, + ) -> openshell_core::Result { + let mut config: openshell_driver_docker::DockerComputeConfig = context.driver_config()?; + apply_guest_tls( + &mut config.guest_tls_ca, + &mut config.guest_tls_cert, + &mut config.guest_tls_key, + context.guest_tls_paths(), + ); + let driver = openshell_driver_docker::DockerComputeDriver::new( + context.gateway_bind_address(), + context.gateway_log_level(), + &config, + ) + .await + .map_err(|error| openshell_core::Error::execution(error.to_string()))?; + let driver = openshell_driver_docker::ComputeDriverService::new_in_process(driver); + Ok(openshell_server::ComputeDriverInstance::InProcess( + std::sync::Arc::new(driver), + )) + } +} + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +#[derive(Clone, Copy)] +struct PodmanFactory; + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +#[async_trait::async_trait] +impl openshell_server::ComputeDriverFactory for PodmanFactory { + async fn build( + &self, + context: openshell_server::ComputeDriverBuildContext<'_>, + ) -> openshell_core::Result { + let mut config: openshell_driver_podman::PodmanComputeConfig = context.driver_config()?; + config.gateway_port = context.gateway_port(); + if let Ok(path) = std::env::var("OPENSHELL_PODMAN_SOCKET") { + config.socket_path = Some(path.into()); + } + if let Ok(ip) = std::env::var("OPENSHELL_PODMAN_HOST_GATEWAY_IP") { + config.host_gateway_ip = ip; + } + if let Ok(mode) = std::env::var("OPENSHELL_PODMAN_USERNS") { + config.userns = Some(mode); + } + apply_guest_tls( + &mut config.guest_tls_ca, + &mut config.guest_tls_cert, + &mut config.guest_tls_key, + context.guest_tls_paths(), + ); + let driver = openshell_driver_podman::PodmanComputeDriver::new(config) + .await + .map_err(|error| openshell_core::Error::execution(error.to_string()))?; + let driver = openshell_driver_podman::ComputeDriverService::new_in_process(driver); + Ok(openshell_server::ComputeDriverInstance::InProcess( + std::sync::Arc::new(driver), + )) + } +} + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +#[derive(Clone, Copy)] +struct VmFactory; + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +#[async_trait::async_trait] +impl openshell_server::ComputeDriverFactory for VmFactory { + async fn build( + &self, + context: openshell_server::ComputeDriverBuildContext<'_>, + ) -> openshell_core::Result { + let mut config: vm::VmComputeConfig = context.driver_config()?; + if config.state_dir.as_os_str().is_empty() { + config.state_dir = vm::VmComputeConfig::default_state_dir(); + } + if config.grpc_endpoint.trim().is_empty() + && (!context.gateway_tls_enabled() || context.guest_tls_paths().is_some()) + { + let scheme = if context.gateway_tls_enabled() { + "https" + } else { + "http" + }; + config.grpc_endpoint = format!("{scheme}://127.0.0.1:{}", context.gateway_port()); + } + apply_guest_tls( + &mut config.guest_tls_ca, + &mut config.guest_tls_cert, + &mut config.guest_tls_key, + context.guest_tls_paths(), + ); + let endpoint = vm::spawn( + context.gateway_log_level(), + context.gateway_name(), + &config, + context.otlp_config(), + ) + .await?; + Ok(openshell_server::ComputeDriverInstance::ManagedRemote( + endpoint, + )) + } +} + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +fn apply_guest_tls( + ca: &mut Option, + cert: &mut Option, + key: &mut Option, + defaults: Option<(&std::path::Path, &std::path::Path, &std::path::Path)>, +) { + if ca.is_none() + && cert.is_none() + && key.is_none() + && let Some((default_ca, default_cert, default_key)) = defaults + { + *ca = Some(default_ca.to_owned()); + *cert = Some(default_cert.to_owned()); + *key = Some(default_key.to_owned()); + } +} + +#[cfg(all(test, target_os = "windows", feature = "in-tree-compute-drivers"))] +mod windows_tests { + use super::*; + + #[test] + fn windows_builtin_compute_drivers_report_unsupported() { + let registry = install_default_compute_drivers(); + assert_eq!( + registry.installed_driver_names().collect::>(), + ["docker", "kubernetes", "mxc", "podman", "vm"] + ); + + for name in ["docker", "kubernetes", "podman", "vm"] { + let message = unsupported_windows_compute_driver(name).to_string(); + assert!( + message.contains("unsupported on Windows"), + "{name} rejection should be explicit, got: {message}" + ); + } + } +} diff --git a/crates/openshell-server/src/main.rs b/crates/openshell-gateway/src/main.rs similarity index 59% rename from crates/openshell-server/src/main.rs rename to crates/openshell-gateway/src/main.rs index c76761016d..85d0611867 100644 --- a/crates/openshell-server/src/main.rs +++ b/crates/openshell-gateway/src/main.rs @@ -1,14 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! `OpenShell` Gateway binary entrypoint. - -use miette::Result; - #[tokio::main] -async fn main() -> Result<()> { +async fn main() -> miette::Result<()> { openshell_server::cli::run_cli_with_compute_drivers( - openshell_server::install_default_compute_drivers(), + openshell_gateway::install_default_compute_drivers(), ) .await } diff --git a/crates/openshell-server/src/compute/vm.rs b/crates/openshell-gateway/src/vm.rs similarity index 92% rename from crates/openshell-server/src/compute/vm.rs rename to crates/openshell-gateway/src/vm.rs index 6a66fc8aa5..e86de28c12 100644 --- a/crates/openshell-server/src/compute/vm.rs +++ b/crates/openshell-gateway/src/vm.rs @@ -20,28 +20,24 @@ //! [`openshell_core::Config`] so the shared core stays free of driver-specific //! plumbing. //! -//! TODO(driver-abstraction): this module still assumes the concrete VM driver -//! (argv shape, guest-TLS flags, libkrun-specific settings). Once we land the -//! generalized compute-driver interface, the CLI-arg plumbing below should -//! be replaced with a driver-agnostic launcher that speaks gRPC to -//! configure the driver — and this file should collapse to the types that -//! are genuinely VM-specific (libkrun log level, vCPU / memory shape) plus a -//! trait implementation registering the VM driver against the generic -//! interface. - -use super::AcquiredRemoteDriverEndpoint; -#[cfg(unix)] -use super::ManagedDriverProcess; -use crate::config_file::OtlpConfig; -#[cfg(unix)] -use crate::otel_tracing::TraceContextInterceptor; +//! Process launch remains deliberately VM-specific at this binary composition +//! boundary: it translates gateway configuration into the standalone driver's +//! argv and then connects through the same public compute-driver RPC interface +//! used by operator-managed external drivers. + #[cfg(unix)] use hyper_util::rt::TokioIo; #[cfg(unix)] use openshell_core::proto::compute::v1::{ GetCapabilitiesRequest, compute_driver_client::ComputeDriverClient, }; -use openshell_core::{ComputeDriverKind, Config, Error, Result}; +use openshell_core::{Error, Result}; +#[cfg(unix)] +use openshell_otel::TraceContextInterceptor; +use openshell_server::AcquiredRemoteDriverEndpoint; +#[cfg(unix)] +use openshell_server::ManagedDriverProcess; +use openshell_server::config_file::OtlpConfig; #[cfg(unix)] use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt}; #[cfg(unix)] @@ -453,7 +449,8 @@ pub fn compute_driver_guest_tls_paths( /// kills the subprocess and removes the socket on drop. #[cfg(unix)] pub async fn spawn( - config: &Config, + gateway_log_level: &str, + gateway_name: &str, vm_config: &VmComputeConfig, otlp_config: Option<&OtlpConfig>, ) -> Result { @@ -477,8 +474,8 @@ pub async fn spawn( command .arg("--expected-peer-pid") .arg(std::process::id().to_string()); - command.arg("--log-level").arg(&config.log_level); - append_otlp_args(&mut command, otlp_config, &config.name); + command.arg("--log-level").arg(gateway_log_level); + append_otlp_args(&mut command, otlp_config, gateway_name); command .arg("--openshell-endpoint") .arg(&vm_config.grpc_endpoint); @@ -513,10 +510,8 @@ pub async fn spawn( })?; let channel = wait_for_compute_driver(&socket_path, &mut child).await?; let process = Arc::new(ManagedDriverProcess::new(child, socket_path)); - Ok(AcquiredRemoteDriverEndpoint::managed_builtin( - ComputeDriverKind::Vm, - channel, - process, + Ok(AcquiredRemoteDriverEndpoint::managed( + "vm", channel, process, )) } @@ -530,7 +525,8 @@ fn append_otlp_args(command: &mut Command, otlp_config: Option<&OtlpConfig>, gat #[cfg(not(unix))] pub async fn spawn( - _config: &Config, + _gateway_log_level: &str, + _gateway_name: &str, _vm_config: &VmComputeConfig, _otlp_config: Option<&OtlpConfig>, ) -> Result { @@ -613,9 +609,8 @@ mod tests { VmComputeConfig, append_otlp_args, compute_driver_guest_tls_paths, compute_driver_socket_path, current_euid, prepare_compute_driver_socket_path, prepare_vm_state_dir, resolve_compute_driver_bin, resolve_driver_search_dirs, - wait_for_compute_driver, }; - use crate::config_file::OtlpConfig; + use openshell_server::config_file::OtlpConfig; use std::os::unix::fs::PermissionsExt; use std::os::unix::net::UnixListener as StdUnixListener; use std::path::PathBuf; @@ -649,43 +644,6 @@ mod tests { ); } - #[tokio::test] - async fn readiness_probe_propagates_the_active_trace() { - use crate::otel_tracing::test_exporter; - use crate::test_support::FakeComputeDriver; - - let dir = tempdir().unwrap(); - let socket_path = dir.path().join("compute-driver.sock"); - let driver = FakeComputeDriver::new(); - let _server = driver.serve_uds(&socket_path).unwrap(); - let mut child = tokio::process::Command::new("sh") - .arg("-c") - .arg("read _") - .stdin(std::process::Stdio::piped()) - .kill_on_drop(true) - .spawn() - .unwrap(); - - let traced = test_exporter::install_traced(); - wait_for_compute_driver(&socket_path, &mut child) - .await - .unwrap(); - - let readiness = traced.spans_named("driver.wait_for_ready"); - assert_eq!(readiness.len(), 1, "one readiness operation should finish"); - test_exporter::assert_is_root(&readiness[0]); - let trace_id = readiness[0].span_context.trace_id().to_string(); - assert_eq!( - driver.traceparents().len(), - 1, - "the readiness capability probe should carry trace context" - ); - assert!( - driver.traceparents()[0].contains(&trace_id), - "the readiness probe should be part of the active trace" - ); - } - #[test] fn resolve_driver_bin_uses_driver_dir_when_binary_present() { let dir = tempdir().unwrap(); diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 1cfd214f20..898eef334d 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -10,10 +10,6 @@ rust-version.workspace = true license.workspace = true repository.workspace = true -[[bin]] -name = "openshell-gateway" -path = "src/main.rs" - [dependencies] openshell-bootstrap = { path = "../openshell-bootstrap" } openshell-core = { path = "../openshell-core", default-features = false, features = ["oauth"] } @@ -31,7 +27,8 @@ openshell-router = { path = "../openshell-router" } openshell-supervisor-middleware = { path = "../openshell-supervisor-middleware" } openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" } -# Kubernetes client (used by the `generate-certs` subcommand) +# Kubernetes client used by ServiceAccount bootstrap authentication and the +# `generate-certs` subcommand. kube = { workspace = true } k8s-openapi = { workspace = true } @@ -115,38 +112,12 @@ x509-parser = "0.16" arc-swap = "1" notify = "8" -[target.'cfg(not(target_os = "windows"))'.dependencies] -openshell-driver-docker = { path = "../openshell-driver-docker", optional = true } -openshell-driver-kubernetes = { path = "../openshell-driver-kubernetes", optional = true } -openshell-driver-podman = { path = "../openshell-driver-podman", optional = true } - -# MXC is the Windows-only in-process compute backend (openshell-driver-mxc is a -# no-op stub on other targets). It is only linked into the gateway on Windows. -[target.'cfg(target_os = "windows")'.dependencies] -openshell-driver-mxc = { path = "../openshell-driver-mxc" } - [features] -default = ["telemetry", "in-tree-compute-drivers"] -## Link the first-party compute drivers into the standard gateway binary. -## Disable this feature for a protocol-only gateway that uses external drivers. -in-tree-compute-drivers = [ - "dep:openshell-driver-docker", - "dep:openshell-driver-kubernetes", - "dep:openshell-driver-podman", -] +default = ["telemetry"] ## Compile in anonymous telemetry emission (forwards to openshell-core/telemetry). ## On by default; build with `--no-default-features` for a telemetry-free gateway ## that contains no telemetry endpoint, HTTP client, or emission code. telemetry = ["openshell-core/telemetry"] -## Convenience alias: every default feature except `telemetry`. Build a -## telemetry-free gateway with -## `--no-default-features --features defaults-without-telemetry` and stay -## correct as new default features are added. Cargo cannot subtract a single -## default feature, so this alias must be paired with `--no-default-features`; -## enabling it alongside `telemetry` is a compile error rather than a silent -## telemetry-on build. Kept in sync with `default` by -## `rust:verify:defaults-without-telemetry`. -defaults-without-telemetry = ["in-tree-compute-drivers"] bundled-z3 = ["openshell-prover/bundled-z3"] test-support = [] diff --git a/crates/openshell-server/src/auth/compute_driver.rs b/crates/openshell-server/src/auth/compute_driver.rs index cedc2115b5..04caee61b1 100644 --- a/crates/openshell-server/src/auth/compute_driver.rs +++ b/crates/openshell-server/src/auth/compute_driver.rs @@ -52,7 +52,7 @@ impl Authenticator for ComputeDriverAuthenticator { Ok(Some(Principal::Sandbox(SandboxPrincipal { sandbox_id, source: SandboxIdentitySource::ComputeDriver { - driver_name: self.compute.selected_driver_name().to_string(), + driver_name: self.compute.configured_driver_name().to_string(), }, trust_domain: Some("openshell".to_string()), }))) diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index f22e1355e4..f18dbe07aa 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -6,7 +6,6 @@ use clap::parser::ValueSource; use clap::{ArgAction, ArgMatches, Command, CommandFactory, FromArgMatches, Parser}; use miette::{IntoDiagnostic, Result}; -use openshell_core::ComputeDriverKind; use openshell_core::config::{DEFAULT_GATEWAY_NAME, DEFAULT_SERVER_PORT}; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; @@ -17,10 +16,7 @@ use crate::certgen; use crate::compute::driver_config::GuestTlsPaths; use crate::config_file::{self, ConfigFile, GatewayFileSection}; use crate::defaults::{self, LocalTlsPaths}; -use crate::{ - ComputeDriverRegistry, ServerStartupConfig, configured_compute_driver_for_startup, - install_default_compute_drivers, run_server, tracing_bus::TracingLogBus, -}; +use crate::{ComputeDriverRegistry, ServerStartupConfig, run_server, tracing_bus::TracingLogBus}; /// `OpenShell` gateway process - gRPC and HTTP server with protocol multiplexing. /// @@ -107,12 +103,10 @@ struct RunArgs { /// Compute drivers configured for this gateway. /// - /// Accepts a comma-delimited list such as `kubernetes` or - /// `kubernetes,podman`. The configuration format is future-proofed for - /// multiple drivers, but the gateway currently requires exactly one. - /// When unset, the gateway auto-detects the driver based on the runtime - /// environment (Kubernetes → Podman → Docker). VM is never - /// auto-detected and requires explicit configuration. + /// Accepts a comma-delimited list of registered driver names. The + /// configuration format is future-proofed for multiple drivers, but the + /// gateway currently requires exactly one. When unset, the gateway runs + /// detection probes supplied by the drivers compiled into the binary. #[arg( long, alias = "driver", @@ -127,9 +121,9 @@ struct RunArgs { /// /// When set, the socket is associated with the single driver name supplied /// by `--drivers` or `OPENSHELL_DRIVERS` and replaces normal construction - /// for that selected name, including canonical built-in names. The gateway - /// connects to this operator-provided endpoint; it does not provision the - /// remote driver. + /// for that selected name, including a compiled registration with the same + /// name. The gateway connects to this operator-provided endpoint; it does + /// not provision the remote driver. #[arg(long, env = "OPENSHELL_COMPUTE_DRIVER_SOCKET")] compute_driver_socket: Option, @@ -147,9 +141,9 @@ struct RunArgs { /// Enable mTLS client certificate authentication for local single-user gateways. /// - /// When unset, this defaults on for Docker, Podman, and VM gateways that - /// have client certificate verification configured and no OIDC issuer. - /// Kubernetes deployments must use OIDC or fronting-proxy auth instead. + /// When unset, this defaults on for drivers registered as local + /// single-player backends when client certificate verification is + /// configured and no OIDC issuer is present. #[arg( long = "enable-mtls-auth", env = "OPENSHELL_ENABLE_MTLS_AUTH", @@ -231,7 +225,7 @@ pub fn command() -> Command { } pub async fn run_cli() -> Result<()> { - run_cli_with_compute_drivers(install_default_compute_drivers()).await + run_cli_with_compute_drivers(ComputeDriverRegistry::new()).await } /// Run the gateway CLI with the compute drivers linked by the binary. @@ -249,7 +243,12 @@ pub async fn run_cli_with_compute_drivers(compute_drivers: ComputeDriverRegistry } } -fn prepare_server_config( +#[cfg(test)] +fn prepare_server_config(args: &mut RunArgs, matches: &ArgMatches) -> Result { + prepare_server_config_with_drivers(args, matches, &ComputeDriverRegistry::new()) +} + +fn prepare_server_config_with_drivers( args: &mut RunArgs, matches: &ArgMatches, compute_drivers: &ComputeDriverRegistry, @@ -270,7 +269,7 @@ fn prepare_server_config( let compute_driver = compute_drivers .select(&args.drivers) .map_err(|error| miette::miette!("{error}"))?; - let compute_driver_kind = compute_driver.name().parse::().ok(); + let selected_registration = compute_drivers.get(compute_driver.name()); let local_tls = apply_runtime_defaults(args)?; let guest_tls = local_tls.as_ref().map(GuestTlsPaths::from); @@ -281,7 +280,7 @@ fn prepare_server_config( let has_client_ca = args.tls_client_ca.is_some(); let has_oidc = args.oidc_issuer.is_some(); let mtls_auth_enabled = - resolve_mtls_auth_enabled(args, matches, file.as_ref(), compute_driver_kind); + resolve_mtls_auth_enabled(args, matches, file.as_ref(), selected_registration); if args.disable_tls && has_client_ca { return Err(miette::miette!( @@ -298,9 +297,11 @@ fn prepare_server_config( "mTLS user authentication requires --tls-client-ca so client certificates can be verified." )); } - if mtls_auth_enabled && matches!(compute_driver_kind, Some(ComputeDriverKind::Kubernetes)) { + if mtls_auth_enabled + && selected_registration.is_some_and(|registration| !registration.supports_mtls_user_auth()) + { return Err(miette::miette!( - "mTLS user authentication is not supported with the Kubernetes compute driver. Configure OIDC or a trusted fronting proxy for user authentication." + "mTLS user authentication is not supported with the selected compute driver. Configure OIDC or a trusted fronting proxy for user authentication." )); } @@ -501,8 +502,7 @@ async fn run_from_args( matches: ArgMatches, compute_drivers: ComputeDriverRegistry, ) -> Result<()> { - let prepared = prepare_server_config(&mut args, &matches, &compute_drivers)?; - let compute_driver = configured_compute_driver_for_startup(&compute_drivers, &prepared)?; + let prepared = prepare_server_config_with_drivers(&mut args, &matches, &compute_drivers)?; let tracing_log_bus = TracingLogBus::new(); let otlp_config = prepared @@ -511,14 +511,20 @@ async fn run_from_args( .and_then(|f| f.openshell.gateway.otlp.as_ref()); let gateway_resource = crate::otel_tracing::GatewayResourceAttributes::new( Some(prepared.config.name.as_str()), - Some(compute_driver.name()), + Some(prepared.compute_driver.name()), + ); + let compute_driver_tracing = compute_drivers.tracing_setup( + &prepared.compute_driver, + &prepared.config.compute_driver_endpoints, + otlp_config.map(|config| config.endpoint.as_str()), + Some(prepared.config.name.as_str()), ); let (tracing_handle, setup_error) = crate::tracing_setup::install( EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new(&prepared.config.log_level)), &tracing_log_bus, otlp_config, - &compute_driver, + compute_driver_tracing, gateway_resource, ); @@ -576,7 +582,7 @@ async fn run_from_args( info!(bind = %prepared.config.bind_address, "Starting OpenShell server"); - let result = Box::pin(run_server(prepared, compute_driver, tracing_log_bus)).await; + let result = Box::pin(run_server(prepared, tracing_log_bus, compute_drivers)).await; tracing_handle.shutdown(); @@ -817,23 +823,15 @@ fn normalize_compute_driver_socket_args(args: &mut RunArgs, matches: &ArgMatches } } -fn is_singleplayer_driver(driver: Option) -> bool { - matches!( - driver, - Some( - ComputeDriverKind::Docker - | ComputeDriverKind::Podman - | ComputeDriverKind::Vm - | ComputeDriverKind::Mxc - ) - ) +fn is_singleplayer_driver(registration: Option<&crate::ComputeDriverRegistration>) -> bool { + registration.is_some_and(crate::ComputeDriverRegistration::is_local_singleplayer) } fn resolve_mtls_auth_enabled( args: &RunArgs, matches: &ArgMatches, file: Option<&ConfigFile>, - compute_driver: Option, + selected_registration: Option<&crate::ComputeDriverRegistration>, ) -> bool { let file_configured = file .and_then(|f| f.openshell.gateway.mtls_auth.as_ref()) @@ -846,7 +844,7 @@ fn resolve_mtls_auth_enabled( return false; } - is_singleplayer_driver(compute_driver) + is_singleplayer_driver(selected_registration) } #[cfg(test)] @@ -859,37 +857,49 @@ mod tests { static REGISTRY_DETECTION_CALLS: AtomicUsize = AtomicUsize::new(0); - fn detect_registered_docker() -> bool { + fn detect_registered_local() -> bool { REGISTRY_DETECTION_CALLS.fetch_add(1, Ordering::SeqCst); true } #[derive(Clone, Copy)] - struct TestComputeDriverFactory; + struct TestFactory; #[async_trait::async_trait] - impl crate::ComputeDriverFactory for TestComputeDriverFactory { + impl crate::ComputeDriverFactory for TestFactory { async fn build( &self, _context: crate::ComputeDriverBuildContext<'_>, - ) -> openshell_core::Result { - unreachable!("configuration tests do not construct the driver") + ) -> openshell_core::Result { + unreachable!("CLI metadata tests do not build drivers") } } - fn detected_docker_registry() -> crate::ComputeDriverRegistry { + fn test_registry(name: &str, singleplayer: bool, mtls: bool) -> crate::ComputeDriverRegistry { + let mut registration = + crate::ComputeDriverRegistration::new(name, 100, None, TestFactory).unwrap(); + if singleplayer { + registration = registration.with_local_singleplayer(); + } + if !mtls { + registration = registration.without_mtls_user_auth(); + } let mut registry = crate::ComputeDriverRegistry::new(); + registry.install(registration).unwrap(); registry - .install( - crate::ComputeDriverRegistration::new( - "docker", - 100, - Some(detect_registered_docker), - TestComputeDriverFactory, - ) - .unwrap(), - ) - .unwrap(); + } + + fn detected_local_registry() -> crate::ComputeDriverRegistry { + let registration = crate::ComputeDriverRegistration::new( + "local", + 100, + Some(detect_registered_local), + TestFactory, + ) + .unwrap() + .with_local_singleplayer(); + let mut registry = crate::ComputeDriverRegistry::new(); + registry.install(registration).unwrap(); registry } @@ -1350,7 +1360,7 @@ mod tests { "--db-url", "sqlite::memory:", "--drivers", - "docker", + "local", "--tls-cert", "/tmp/server.crt", "--tls-key", @@ -1363,7 +1373,7 @@ mod tests { &args, &matches, None, - Some(openshell_core::ComputeDriverKind::Docker) + test_registry("local", true, true).get("local") )); } @@ -1376,7 +1386,6 @@ mod tests { let config = tempfile::tempdir().unwrap(); let _state = EnvVarGuard::set("XDG_STATE_HOME", state.path().to_str().unwrap()); let _config = EnvVarGuard::set("XDG_CONFIG_HOME", config.path().to_str().unwrap()); - let _kubernetes = EnvVarGuard::set("KUBERNETES_SERVICE_HOST", "10.0.0.1"); let _mtls = EnvVarGuard::remove("OPENSHELL_ENABLE_MTLS_AUTH"); let _drivers = EnvVarGuard::remove("OPENSHELL_DRIVERS"); REGISTRY_DETECTION_CALLS.store(0, Ordering::SeqCst); @@ -1392,18 +1401,19 @@ mod tests { "--tls-client-ca", "/tmp/ca.crt", ]); - let registry = detected_docker_registry(); + let registry = detected_local_registry(); - let prepared = super::prepare_server_config(&mut args, &matches, ®istry).unwrap(); + let prepared = + super::prepare_server_config_with_drivers(&mut args, &matches, ®istry).unwrap(); - assert_eq!(prepared.compute_driver.name(), "docker"); + assert_eq!(prepared.compute_driver.name(), "local"); assert!(prepared.config.compute_drivers.is_empty()); assert!(prepared.config.mtls_auth.enabled); assert_eq!(REGISTRY_DETECTION_CALLS.load(Ordering::SeqCst), 1); } #[test] - fn mtls_auth_does_not_auto_default_for_kubernetes_driver() { + fn mtls_auth_does_not_auto_default_for_shared_driver() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -1414,7 +1424,7 @@ mod tests { "--db-url", "sqlite::memory:", "--drivers", - "kubernetes", + "shared", "--tls-cert", "/tmp/server.crt", "--tls-key", @@ -1427,7 +1437,7 @@ mod tests { &args, &matches, None, - Some(openshell_core::ComputeDriverKind::Kubernetes) + test_registry("shared", false, false).get("shared") )); } @@ -1443,7 +1453,7 @@ mod tests { "--db-url", "sqlite::memory:", "--drivers", - "docker", + "local", "--tls-cert", "/tmp/server.crt", "--tls-key", @@ -1464,7 +1474,7 @@ enabled = false &args, &matches, Some(&file), - Some(openshell_core::ComputeDriverKind::Docker) + test_registry("local", true, true).get("local") )); } @@ -1702,23 +1712,12 @@ ssh_session_ttl_secs = 1234 } #[test] - fn singleplayer_driver_matches_only_one_local_driver() { - for driver in [ - openshell_core::ComputeDriverKind::Docker, - openshell_core::ComputeDriverKind::Podman, - openshell_core::ComputeDriverKind::Vm, - openshell_core::ComputeDriverKind::Mxc, - ] { - assert!( - super::is_singleplayer_driver(Some(driver)), - "{driver} should be singleplayer" - ); - } + fn singleplayer_behavior_comes_from_registration() { + let local = test_registry("local", true, true); + assert!(super::is_singleplayer_driver(local.get("local"))); - assert!(!super::is_singleplayer_driver(Some( - openshell_core::ComputeDriverKind::Kubernetes - ))); - assert!(!super::is_singleplayer_driver(None)); + let shared = test_registry("shared", false, true); + assert!(!super::is_singleplayer_driver(shared.get("shared"))); } #[test] @@ -1744,11 +1743,6 @@ ssh_session_ttl_secs = 1234 Some(std::path::Path::new("/run/openshell/kyma.sock")) ); assert_eq!(args.drivers, ["kyma"]); - assert!( - args.drivers[0] - .parse::() - .is_err() - ); } #[test] @@ -1928,12 +1922,8 @@ mem_mib = "not-a-number" "--disable-tls", ]); - let prepared = super::prepare_server_config( - &mut args, - &matches, - &crate::install_default_compute_drivers(), - ) - .expect("server config is prepared"); + let prepared = + super::prepare_server_config(&mut args, &matches).expect("server config is prepared"); assert_eq!(prepared.config.compute_drivers, vec!["podman".to_string()]); assert_eq!( @@ -1944,53 +1934,4 @@ mem_mib = "not-a-number" assert!(file.openshell.drivers.contains_key("docker")); assert!(file.openshell.drivers.contains_key("vm")); } - - #[test] - #[cfg(not(target_os = "windows"))] - fn driver_inherits_shared_image_from_gateway_section() { - // [openshell.gateway].default_image inherits into the K8s driver - // table when the driver-specific table does not set it. - let file = config_file_from_toml( - r#" -[openshell.gateway] -default_image = "ghcr.io/nvidia/openshell/sandbox:1.0" - -[openshell.drivers.kubernetes] -namespace = "agents" -"#, - ); - let merged = crate::config_file::driver_table( - super::ComputeDriverKind::Kubernetes.as_str(), - &file.openshell.gateway, - file.openshell.drivers.get("kubernetes"), - ); - let parsed = merged - .try_into::() - .expect("merged table deserializes"); - assert_eq!(parsed.default_image, "ghcr.io/nvidia/openshell/sandbox:1.0"); - assert_eq!(parsed.namespace, "agents"); - } - - #[test] - #[cfg(not(target_os = "windows"))] - fn driver_specific_value_overrides_gateway_inheritance() { - let file = config_file_from_toml( - r#" -[openshell.gateway] -default_image = "gateway-default:1.0" - -[openshell.drivers.kubernetes] -default_image = "k8s-specific:1.0" -"#, - ); - let merged = crate::config_file::driver_table( - super::ComputeDriverKind::Kubernetes.as_str(), - &file.openshell.gateway, - file.openshell.drivers.get("kubernetes"), - ); - let parsed = merged - .try_into::() - .expect("deserializes"); - assert_eq!(parsed.default_image, "k8s-specific:1.0"); - } } diff --git a/crates/openshell-server/src/compute/driver_config.rs b/crates/openshell-server/src/compute/driver_config.rs index c25c63ded2..d06e6fbc8f 100644 --- a/crates/openshell-server/src/compute/driver_config.rs +++ b/crates/openshell-server/src/compute/driver_config.rs @@ -3,20 +3,13 @@ //! Selected compute-driver config construction. //! -//! This module owns loading the selected driver config from TOML, applying -//! driver-specific environment overrides, and applying gateway startup defaults. -//! It does not acquire, connect to, or start compute drivers. - -#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] -pub mod builtin; +//! This module owns loading the selected driver config from TOML and applying +//! gateway startup defaults and endpoint overrides. It does not acquire, +//! connect to, or start compute drivers. use crate::config_file; use crate::defaults::LocalTlsPaths; -#[cfg(target_os = "windows")] -use openshell_core::ComputeDriverKind; use openshell_core::{Error, Result}; -#[cfg(target_os = "windows")] -use openshell_driver_mxc::MxcComputeConfig; use serde::Deserialize; use std::collections::BTreeMap; use std::path::PathBuf; @@ -53,15 +46,6 @@ pub struct DriverStartupContext<'a> { pub endpoint_overrides: &'a BTreeMap, } -/// Build the selected MXC config from TOML. MXC is Windows-only and has no -/// runtime-default overlay; the driver reads its own settings from the config. -/// The Linux built-in driver configs now live in the `builtin` submodule -/// (compiled only off Windows). -#[cfg(target_os = "windows")] -pub fn mxc_config_from_context(context: DriverStartupContext<'_>) -> Result { - driver_config_from_context(context, ComputeDriverKind::Mxc.as_str()) -} - pub fn remote_driver_config_from_context( context: DriverStartupContext<'_>, name: &str, @@ -91,16 +75,18 @@ pub struct RemoteDriverConfig { pub fn driver_config_from_context( context: DriverStartupContext<'_>, driver_name: &str, + inherited_config_keys: &[&str], ) -> Result where T: Default + serde::de::DeserializeOwned, { - driver_config_from_file(context.file, driver_name) + driver_config_from_file(context.file, driver_name, inherited_config_keys) } fn driver_config_from_file( file: Option<&config_file::ConfigFile>, driver_name: &str, + inherited_config_keys: &[&str], ) -> Result where T: Default + serde::de::DeserializeOwned, @@ -108,10 +94,11 @@ where let Some(file) = file else { return Ok(T::default()); }; - let merged = config_file::driver_table( + let merged = config_file::driver_table_with_inherited_keys( driver_name, &file.openshell.gateway, file.openshell.drivers.get(driver_name), + inherited_config_keys, ); merged.try_into().map_err(|e| { Error::config(format!( diff --git a/crates/openshell-server/src/compute/driver_config/builtin.rs b/crates/openshell-server/src/compute/driver_config/builtin.rs deleted file mode 100644 index dea867237d..0000000000 --- a/crates/openshell-server/src/compute/driver_config/builtin.rs +++ /dev/null @@ -1,231 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Configuration construction for built-in compute drivers. - -use super::{DriverStartupContext, GuestTlsPaths, driver_config_from_context}; -use crate::compute::VmComputeConfig; -#[cfg(test)] -use crate::config_file; -use openshell_core::{ComputeDriverKind, Result}; -use openshell_driver_docker::DockerComputeConfig; -use openshell_driver_kubernetes::KubernetesComputeConfig; -use openshell_driver_podman::PodmanComputeConfig; -use std::path::PathBuf; - -/// Build the selected Kubernetes config from TOML plus runtime defaults. -pub fn kubernetes_config_from_context( - context: DriverStartupContext<'_>, -) -> Result { - let mut cfg = driver_config_from_context(context, ComputeDriverKind::Kubernetes.as_str())?; - apply_kubernetes_runtime_defaults(&mut cfg); - Ok(cfg) -} - -/// Build the selected Podman config from TOML plus runtime defaults. -pub fn podman_config_from_context( - context: DriverStartupContext<'_>, -) -> Result { - let mut podman = driver_config_from_context(context, ComputeDriverKind::Podman.as_str())?; - apply_podman_runtime_defaults(&mut podman, context); - Ok(podman) -} - -/// Build the selected Docker config from TOML plus runtime defaults. -pub fn docker_config_from_context( - context: DriverStartupContext<'_>, -) -> Result { - let mut cfg = driver_config_from_context(context, ComputeDriverKind::Docker.as_str())?; - apply_docker_runtime_defaults(&mut cfg, context); - Ok(cfg) -} - -/// Build the selected VM config from TOML plus runtime defaults. -pub fn vm_config_from_context(context: DriverStartupContext<'_>) -> Result { - let mut cfg = driver_config_from_context(context, ComputeDriverKind::Vm.as_str())?; - apply_vm_runtime_defaults(&mut cfg, context); - Ok(cfg) -} - -fn apply_kubernetes_runtime_defaults(k8s: &mut KubernetesComputeConfig) { - if let Ok(size) = std::env::var("OPENSHELL_K8S_WORKSPACE_DEFAULT_STORAGE_SIZE") { - k8s.workspace_default_storage_size = size; - } - if let Ok(storage_class) = std::env::var("OPENSHELL_K8S_WORKSPACE_STORAGE_CLASS") { - k8s.workspace_storage_class = storage_class; - } -} - -fn apply_podman_runtime_defaults( - podman: &mut PodmanComputeConfig, - context: DriverStartupContext<'_>, -) { - podman.gateway_port = context.gateway_port; - apply_podman_env_overrides(podman); - apply_guest_tls_defaults_to_split_fields( - &mut podman.guest_tls_ca, - &mut podman.guest_tls_cert, - &mut podman.guest_tls_key, - context.guest_tls, - ); -} - -fn apply_docker_runtime_defaults(cfg: &mut DockerComputeConfig, context: DriverStartupContext<'_>) { - apply_guest_tls_defaults_to_split_fields( - &mut cfg.guest_tls_ca, - &mut cfg.guest_tls_cert, - &mut cfg.guest_tls_key, - context.guest_tls, - ); -} - -fn apply_vm_runtime_defaults(cfg: &mut VmComputeConfig, context: DriverStartupContext<'_>) { - if cfg.state_dir.as_os_str().is_empty() { - cfg.state_dir = VmComputeConfig::default_state_dir(); - } - if cfg.grpc_endpoint.trim().is_empty() - && (!context.gateway_tls_enabled || context.guest_tls.is_some()) - { - let scheme = if context.gateway_tls_enabled { - "https" - } else { - "http" - }; - cfg.grpc_endpoint = format!("{scheme}://127.0.0.1:{}", context.gateway_port); - } - - apply_guest_tls_defaults_to_split_fields( - &mut cfg.guest_tls_ca, - &mut cfg.guest_tls_cert, - &mut cfg.guest_tls_key, - context.guest_tls, - ); -} - -fn apply_guest_tls_defaults_to_split_fields( - ca: &mut Option, - cert: &mut Option, - key: &mut Option, - defaults: Option<&GuestTlsPaths>, -) { - if ca.is_none() - && cert.is_none() - && key.is_none() - && let Some(paths) = defaults - { - *ca = Some(paths.ca.clone()); - *cert = Some(paths.cert.clone()); - *key = Some(paths.key.clone()); - } -} - -fn apply_podman_env_overrides(podman: &mut PodmanComputeConfig) { - if let Ok(p) = std::env::var("OPENSHELL_PODMAN_SOCKET") { - podman.socket_path = Some(PathBuf::from(p)); - } - if let Ok(ip) = std::env::var("OPENSHELL_PODMAN_HOST_GATEWAY_IP") { - podman.host_gateway_ip = ip; - } - if let Ok(mode) = std::env::var("OPENSHELL_PODMAN_USERNS") { - podman.userns = Some(mode); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::BTreeMap; - - fn test_context(file: Option<&config_file::ConfigFile>) -> DriverStartupContext<'_> { - static EMPTY_ENDPOINT_OVERRIDES: std::sync::LazyLock> = - std::sync::LazyLock::new(BTreeMap::new); - DriverStartupContext { - file, - guest_tls: None, - gateway_port: openshell_core::config::DEFAULT_SERVER_PORT, - gateway_tls_enabled: false, - endpoint_overrides: &EMPTY_ENDPOINT_OVERRIDES, - } - } - - #[test] - fn podman_config_reads_bind_mount_opt_in_from_driver_table() { - let file: config_file::ConfigFile = toml::from_str( - r" -[openshell.drivers.podman] -enable_bind_mounts = true -", - ) - .expect("valid config"); - - let cfg = podman_config_from_context(test_context(Some(&file))).expect("podman config"); - - assert!(cfg.enable_bind_mounts); - } - - #[test] - fn docker_config_reads_bind_mount_opt_in_from_driver_table() { - let file: config_file::ConfigFile = toml::from_str( - r" -[openshell.drivers.docker] -enable_bind_mounts = true -", - ) - .expect("valid config"); - - let cfg = docker_config_from_context(test_context(Some(&file))).expect("docker config"); - - assert!(cfg.enable_bind_mounts); - } - - #[test] - fn docker_config_reads_socket_path_from_driver_table() { - let file: config_file::ConfigFile = toml::from_str( - r#" -[openshell.drivers.docker] -socket_path = "/tmp/docker.sock" -"#, - ) - .expect("valid config"); - - let cfg = docker_config_from_context(test_context(Some(&file))).expect("docker config"); - - assert_eq!(cfg.socket_path, Some(PathBuf::from("/tmp/docker.sock"))); - } - - #[test] - fn docker_config_reports_selected_invalid_driver_table() { - let file: config_file::ConfigFile = toml::from_str( - r" -[openshell.drivers.docker] -unknown_docker_key = true -", - ) - .expect("valid config"); - - let err = docker_config_from_context(test_context(Some(&file))).unwrap_err(); - - assert!( - err.to_string() - .contains("invalid [openshell.drivers.docker] table") - ); - } - - #[test] - fn vm_config_reports_selected_invalid_driver_table() { - let file: config_file::ConfigFile = toml::from_str( - r#" -[openshell.drivers.vm] -mem_mib = "not-a-number" -"#, - ) - .expect("valid config"); - - let err = vm_config_from_context(test_context(Some(&file))).unwrap_err(); - - assert!( - err.to_string() - .contains("invalid [openshell.drivers.vm] table") - ); - } -} diff --git a/crates/openshell-server/src/compute/lease.rs b/crates/openshell-server/src/compute/lease.rs index bf58fae48b..3310946dab 100644 --- a/crates/openshell-server/src/compute/lease.rs +++ b/crates/openshell-server/src/compute/lease.rs @@ -242,10 +242,10 @@ impl ReconcilerLease { /// Derive a stable replica identity for lease ownership. /// -/// Kubernetes sets `HOSTNAME` to the pod name, Docker sets it to the -/// container ID, and systemd units inherit the machine hostname. -/// `OPENSHELL_REPLICA_ID` allows explicit override. The UUID fallback -/// handles edge cases where neither env var is set. +/// Managed workloads commonly receive a stable runtime identity through +/// `HOSTNAME`, while systemd units inherit the machine hostname. +/// `OPENSHELL_REPLICA_ID` allows an explicit override. The UUID fallback +/// handles environments where neither variable is set. pub fn replica_id() -> String { std::env::var("OPENSHELL_REPLICA_ID") .or_else(|_| std::env::var("HOSTNAME")) diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 78ae6fddb3..053b08de9c 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -5,17 +5,6 @@ pub mod driver_config; pub mod lease; -#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] -pub mod vm; - -#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] -pub use openshell_driver_docker::DockerComputeConfig; -#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] -pub use openshell_driver_kubernetes::KubernetesComputeConfig; -#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] -pub use openshell_driver_podman::PodmanComputeConfig; -#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] -pub use vm::VmComputeConfig; use crate::grpc::policy::SANDBOX_SETTINGS_OBJECT_TYPE; use crate::otel_tracing::TraceContextInterceptor; @@ -30,9 +19,6 @@ use crate::tracing_bus::TracingLogBus; use futures::{Stream, StreamExt}; #[cfg(unix)] use hyper_util::rt::TokioIo; -use openshell_core::ComputeDriverKind; -#[cfg(target_os = "windows")] -use openshell_core::proto::SandboxPolicy; use openshell_core::proto::compute::v1::{ AuthenticateSandboxRequest, CreateSandboxRequest, DeleteSandboxRequest, DeleteWorkspaceRequest, DeleteWorkspaceResponse, DriverCondition, DriverPlatformEvent, DriverResourceRequirements, @@ -51,18 +37,8 @@ use openshell_core::proto::{ PlatformEvent, Sandbox, SandboxCondition, SandboxPhase, SandboxSpec, SandboxStatus, SandboxTemplate, ServiceEndpoint, SshSession, }; +use openshell_core::telemetry::TelemetryComputeDriver; use openshell_core::{ObjectLabels, ObjectWorkspace}; -#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] -use openshell_driver_docker::{ComputeDriverService as DockerDriverService, DockerComputeDriver}; -#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] -use openshell_driver_kubernetes::{ - ComputeDriverService as KubernetesDriverService, KubernetesComputeDriver, - OperatorNamespaceAllowlist, -}; -#[cfg(target_os = "windows")] -use openshell_driver_mxc::{ComputeDriverService as MxcDriverService, MxcComputeConfig}; -#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] -use openshell_driver_podman::{ComputeDriverService as PodmanDriverService, PodmanComputeDriver}; use prost::Message; use std::collections::HashMap; use std::fmt; @@ -287,6 +263,8 @@ pub struct ComputeDriverInfoSnapshot { pub gateway_manages_lifecycle: bool, /// Whether the driver authenticates driver-native sandbox credentials. pub supports_sandbox_authentication: bool, + /// Whether the driver reports runtime readiness without a supervisor session. + pub driver_reports_runtime_readiness: bool, } /// Interval between store-vs-backend reconciliation sweeps. @@ -306,8 +284,8 @@ pub struct ManagedDriverProcess { } impl ManagedDriverProcess { - #[cfg(all(unix, any(test, feature = "in-tree-compute-drivers")))] - pub(crate) fn new(child: tokio::process::Child, socket_path: PathBuf) -> Self { + #[cfg(unix)] + pub fn new(child: tokio::process::Child, socket_path: PathBuf) -> Self { Self { child: std::sync::Mutex::new(Some(child)), socket_path, @@ -404,14 +382,13 @@ pub struct AcquiredRemoteDriverEndpoint { } impl AcquiredRemoteDriverEndpoint { - #[cfg(any(test, feature = "in-tree-compute-drivers"))] - pub(crate) fn managed_builtin( - driver_kind: ComputeDriverKind, + pub fn managed( + name: impl Into, channel: Channel, driver_process: Arc, ) -> Self { Self { - name: driver_kind.as_str().to_string(), + name: name.into(), channel, driver_process: Some(driver_process), } @@ -575,6 +552,7 @@ impl ComputeDriver for RemoteComputeDriver { pub struct ComputeRuntime { driver: TracedDriver, driver_info: ComputeDriverInfoSnapshot, + telemetry_compute_driver: TelemetryComputeDriver, driver_process: Option>, default_image: String, store: Arc, @@ -586,14 +564,6 @@ pub struct ComputeRuntime { lifecycle_gates: Arc, gateway_listener_requirements: Vec, replica_id: String, - /// A1 policy side channel for the in-process MXC driver. `create_sandbox` - /// stages the typed `SandboxPolicy` here by sandbox id immediately before - /// dispatching to the driver, which consumes it. `None` for all other - /// drivers. The proto driver contract has no `policy` field and there is no - /// driver-side `GetSandboxConfig`, so this in-process map is how the policy - /// reaches the MXC backend without changing the cross-process contract. - #[cfg(target_os = "windows")] - mxc_policy_sink: Option>>>, } impl fmt::Debug for ComputeRuntime { @@ -631,11 +601,9 @@ impl ComputeRuntime { compute_error_from_status(status) })? .into_inner(); - let driver_kind = driver_name.parse::().ok(); info!( configured_driver = %driver_name, advertised_driver = %capabilities.driver_name, - in_tree = driver_kind.is_some(), "Compute driver connected" ); let driver_info = ComputeDriverInfoSnapshot { @@ -644,6 +612,7 @@ impl ComputeRuntime { driver_version: capabilities.driver_version, gateway_manages_lifecycle: capabilities.gateway_manages_lifecycle, supports_sandbox_authentication: capabilities.supports_sandbox_authentication, + driver_reports_runtime_readiness: capabilities.driver_reports_runtime_readiness, }; let default_image = capabilities.default_image; let gateway_listener_requirements = match driver @@ -702,6 +671,7 @@ impl ComputeRuntime { Ok(Self { driver: TracedDriver::new(driver, driver_name), driver_info, + telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process, default_image, store, @@ -713,8 +683,6 @@ impl ComputeRuntime { lifecycle_gates: Arc::new(LifecycleGateRegistry::default()), gateway_listener_requirements, replica_id: lease::replica_id(), - #[cfg(target_os = "windows")] - mxc_policy_sink: None, }) } @@ -743,62 +711,6 @@ impl ComputeRuntime { self.lifecycle_gates.entry_count() } - #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] - pub async fn new_docker( - config: openshell_core::Config, - docker_config: DockerComputeConfig, - store: Arc, - sandbox_index: SandboxIndex, - sandbox_watch_bus: SandboxWatchBus, - tracing_log_bus: TracingLogBus, - supervisor_sessions: Arc, - ) -> Result { - let driver = DockerComputeDriver::new(&config, &docker_config) - .await - .map_err(|err| ComputeError::Message(err.to_string()))?; - let driver: SharedComputeDriver = Arc::new(DockerDriverService::new_in_process(driver)); - Self::from_driver( - ComputeDriverKind::Docker.as_str().to_string(), - driver, - None, - store, - sandbox_index, - sandbox_watch_bus, - tracing_log_bus, - supervisor_sessions, - ) - .await - } - - #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] - pub async fn new_kubernetes( - config: KubernetesComputeConfig, - store: Arc, - sandbox_index: SandboxIndex, - sandbox_watch_bus: SandboxWatchBus, - tracing_log_bus: TracingLogBus, - supervisor_sessions: Arc, - shutdown_rx: watch::Receiver, - ) -> Result<(Self, Option), ComputeError> { - let driver = KubernetesComputeDriver::new(config, shutdown_rx) - .await - .map_err(|err| ComputeError::Message(err.to_string()))?; - let operator_allowlist_arc = driver.operator_allowlist().cloned(); - let driver: SharedComputeDriver = Arc::new(KubernetesDriverService::new_in_process(driver)); - let runtime = Self::from_driver( - ComputeDriverKind::Kubernetes.as_str().to_string(), - driver, - None, - store, - sandbox_index, - sandbox_watch_bus, - tracing_log_bus, - supervisor_sessions, - ) - .await?; - Ok((runtime, operator_allowlist_arc)) - } - pub(crate) async fn new_remote_driver( endpoint: AcquiredRemoteDriverEndpoint, store: Arc, @@ -821,64 +733,6 @@ impl ComputeRuntime { .await } - #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] - pub async fn new_podman( - config: PodmanComputeConfig, - store: Arc, - sandbox_index: SandboxIndex, - sandbox_watch_bus: SandboxWatchBus, - tracing_log_bus: TracingLogBus, - supervisor_sessions: Arc, - ) -> Result { - let driver = PodmanComputeDriver::new(config) - .await - .map_err(|err| ComputeError::Message(err.to_string()))?; - let driver: SharedComputeDriver = Arc::new(PodmanDriverService::new_in_process(driver)); - Self::from_driver( - ComputeDriverKind::Podman.as_str().to_string(), - driver, - None, - store, - sandbox_index, - sandbox_watch_bus, - tracing_log_bus, - supervisor_sessions, - ) - .await - } - - /// Construct a `ComputeRuntime` backed by the MXC compute driver. - /// - /// MXC is Windows-only, in-process, and self-reports `Ready` — there is - /// no supervisor session argument because no surrogate or relay is used. - #[cfg(target_os = "windows")] - pub async fn new_mxc( - mxc_config: MxcComputeConfig, - store: Arc, - sandbox_index: SandboxIndex, - sandbox_watch_bus: SandboxWatchBus, - tracing_log_bus: TracingLogBus, - supervisor_sessions: Arc, - ) -> Result { - let backend = openshell_driver_mxc::MxcComputeBackend::new(mxc_config); - // Grab the A1 policy side channel before moving `backend` into the service. - let sink = backend.policy_sink(); - let service: SharedComputeDriver = Arc::new(MxcDriverService::new(backend)); - let mut runtime = Self::from_driver( - ComputeDriverKind::Mxc.as_str().to_string(), - service, - None, - store, - sandbox_index, - sandbox_watch_bus, - tracing_log_bus, - supervisor_sessions, - ) - .await?; - runtime.mxc_policy_sink = Some(sink); - Ok(runtime) - } - #[must_use] pub fn default_image(&self) -> &str { &self.default_image @@ -890,12 +744,7 @@ impl ComputeRuntime { } #[must_use] - pub fn driver_kind(&self) -> Option { - self.driver_info.name.parse().ok() - } - - #[must_use] - pub fn selected_driver_name(&self) -> &str { + pub fn configured_driver_name(&self) -> &str { &self.driver_info.name } @@ -921,6 +770,20 @@ impl ComputeRuntime { .map(|response| response.into_inner().sandbox_id) } + #[must_use] + pub(crate) fn telemetry_compute_driver(&self) -> TelemetryComputeDriver { + self.telemetry_compute_driver + } + + #[must_use] + pub(crate) fn with_telemetry_compute_driver( + mut self, + telemetry_compute_driver: TelemetryComputeDriver, + ) -> Self { + self.telemetry_compute_driver = telemetry_compute_driver; + self + } + #[must_use] pub(crate) fn gateway_listener_requirements(&self) -> &[GatewayListenerRequirement] { &self.gateway_listener_requirements @@ -1035,16 +898,6 @@ impl ComputeRuntime { if let Some(spec) = driver_sandbox.spec.as_mut() { spec.await_main_process_attachment = await_main_process_attachment; } - // A1: stage the typed SandboxPolicy out-of-band into the MXC backend's - // side channel, keyed by sandbox id (== DriverSandbox.id), immediately - // before dispatch. The driver removes/consumes it in create_sandbox. The - // proto driver contract has no policy field, so this is the only path. - #[cfg(target_os = "windows")] - if let Some(sink) = &self.mxc_policy_sink - && let Some(p) = sandbox.spec.as_ref().and_then(|s| s.policy.clone()) - { - sink.lock().await.insert(sandbox_id.clone(), p); - } match self .driver .call( @@ -1421,12 +1274,11 @@ impl ComputeRuntime { let runtime_restart_during_stop = expected_stopped && driver_snapshot_reports_runtime_restart(&snapshot); if suspension_progressing || runtime_restart_during_stop { - // The Kubernetes controller has accepted the stop and - // is waiting for its pod to terminate. A container - // runtime restart can likewise be the expected SIGTERM - // exit from an in-flight stop. Preserve the durable - // transition so completion or recovery, rather than the - // watcher, determines its terminal state. + // The backend has accepted the stop but has not finished + // terminating the sandbox. A runtime restart can likewise + // be the expected exit from an in-flight stop. Preserve the + // durable transition so completion or recovery, rather than + // a watch snapshot, determines its terminal state. debug!(sandbox_id, "Sandbox stop is still progressing"); } else if backend_phase == SandboxPhase::Error || observed_stopped == expected_stopped @@ -1471,7 +1323,12 @@ impl ComputeRuntime { match self .store .update_message_cas::(&sandbox_id, expected_resource_version, |sandbox| { - apply_driver_snapshot(sandbox, snapshot, session_connected); + apply_driver_snapshot( + sandbox, + snapshot, + session_connected, + self.driver_info.driver_reports_runtime_readiness, + ); }) .await { @@ -1919,7 +1776,14 @@ impl ComputeRuntime { sandbox_id, deleting_resource_version, "reconcile observed backend snapshot", - |sandbox| apply_driver_snapshot(sandbox, &snapshot, session_connected), + |sandbox| { + apply_driver_snapshot( + sandbox, + &snapshot, + session_connected, + self.driver_info.driver_reports_runtime_readiness, + ); + }, ) .await; } @@ -2131,7 +1995,7 @@ impl ComputeRuntime { let watch_runtime = runtime.clone(); let watch_shutdown = shutdown_rx.clone(); tokio::spawn(async move { - watch_runtime.watch_loop(watch_shutdown).await; + Box::pin(watch_runtime.watch_loop(watch_shutdown)).await; }); tokio::spawn(async move { runtime.reconcile_loop(shutdown_rx).await; @@ -2657,7 +2521,7 @@ impl ComputeRuntime { let runtime = self.clone(); let watch_cancel = cancel_rx.clone(); let watch_handle = tokio::spawn(async move { - runtime.watch_loop(watch_cancel).await; + Box::pin(runtime.watch_loop(watch_cancel)).await; }); let runtime = self.clone(); @@ -2959,7 +2823,14 @@ impl ComputeRuntime { .update_message_cas::( &incoming.id, expected_resource_version, - |sandbox| apply_driver_snapshot(sandbox, &incoming, session_connected), + |sandbox| { + apply_driver_snapshot( + sandbox, + &incoming, + session_connected, + self.driver_info.driver_reports_runtime_readiness, + ); + }, ) .await .map_err(|e| match e { @@ -3867,6 +3738,7 @@ fn driver_sandbox_spec_from_public( .as_ref() .map(|template| driver_sandbox_template_from_public(template, driver_name)) .transpose()?, + policy: spec.policy.clone(), resource_requirements: spec.resource_requirements.as_ref().map(|requirements| { DriverSandboxResourceRequirements { gpu: requirements @@ -3894,6 +3766,7 @@ fn driver_sandbox_template_from_public( resources: extract_typed_resources(&template.resources), platform_config: build_platform_config(template), driver_config: select_driver_config(&template.driver_config, driver_name)?, + user_namespaces: template.user_namespaces, }) } @@ -3999,19 +3872,6 @@ fn build_platform_config(template: &SandboxTemplate) -> Option Self { + fn new( + incoming_status: &DriverSandboxStatus, + session_connected: bool, + driver_reports_runtime_readiness: bool, + ) -> Self { let backend_phase = derive_phase(Some(incoming_status)); // A live supervisor session is a stronger readiness signal than the backend phase. // set_supervisor_session_state may have already promoted the store record to Ready @@ -4349,13 +4222,15 @@ impl ComposedPhase { // backend phase overwrite it. let phase = match backend_phase { SandboxPhase::Error | SandboxPhase::Deleting | SandboxPhase::Stopped => backend_phase, + SandboxPhase::Ready if driver_reports_runtime_readiness => SandboxPhase::Ready, _ if session_connected => SandboxPhase::Ready, _ => SandboxPhase::Provisioning, }; Self { phase, session_connected, - backend_ready_without_session: backend_phase == SandboxPhase::Ready + backend_ready_without_session: !driver_reports_runtime_readiness + && backend_phase == SandboxPhase::Ready && !session_connected, } } @@ -4639,6 +4514,7 @@ impl ComputeDriver for NoopTestDriver { default_image: "openshell/sandbox:test".to_string(), gateway_manages_lifecycle: false, supports_sandbox_authentication: self.sandbox_authentication.is_some(), + driver_reports_runtime_readiness: false, }, )) } @@ -4782,7 +4658,9 @@ pub async fn new_test_runtime_with_driver( driver_version: "test".to_string(), gateway_manages_lifecycle: false, supports_sandbox_authentication, + driver_reports_runtime_readiness: false, }, + telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process: None, default_image: "openshell/sandbox:test".to_string(), store, @@ -4794,8 +4672,6 @@ pub async fn new_test_runtime_with_driver( lifecycle_gates: Arc::new(LifecycleGateRegistry::default()), gateway_listener_requirements: Vec::new(), replica_id: "test-replica".to_string(), - #[cfg(target_os = "windows")] - mxc_policy_sink: None, } } @@ -4960,6 +4836,7 @@ mod tests { default_image: "openshell/sandbox:test".to_string(), gateway_manages_lifecycle: false, supports_sandbox_authentication: false, + driver_reports_runtime_readiness: false, })) } @@ -5300,6 +5177,7 @@ mod tests { default_image: "openshell/sandbox:test".to_string(), gateway_manages_lifecycle: false, supports_sandbox_authentication: false, + driver_reports_runtime_readiness: false, })) } @@ -5509,7 +5387,9 @@ mod tests { driver_version: "test".to_string(), gateway_manages_lifecycle: false, supports_sandbox_authentication: false, + driver_reports_runtime_readiness: false, }, + telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process: None, default_image: "openshell/sandbox:test".to_string(), store, @@ -5521,8 +5401,6 @@ mod tests { lifecycle_gates: Arc::new(LifecycleGateRegistry::default()), gateway_listener_requirements: Vec::new(), replica_id: "test-replica".to_string(), - #[cfg(target_os = "windows")] - mxc_policy_sink: None, } } @@ -6141,7 +6019,7 @@ mod tests { ) -> (watch::Sender, tokio::task::JoinHandle<()>) { let (shutdown_tx, shutdown_rx) = watch::channel(false); let runtime = Arc::new(runtime.clone()); - let handle = tokio::spawn(async move { runtime.watch_loop(shutdown_rx).await }); + let handle = tokio::spawn(async move { Box::pin(runtime.watch_loop(shutdown_rx)).await }); tokio::time::timeout(Duration::from_secs(1), driver.watch_started.notified()) .await .expect("watch loop did not start"); @@ -6165,6 +6043,22 @@ mod tests { assert!(store.is_single_replica()); } + #[test] + fn driver_reported_runtime_uses_driver_readiness() { + let status = make_driver_status(DriverCondition { + r#type: "Ready".to_string(), + status: "True".to_string(), + reason: "AgentRunning".to_string(), + message: "MXC workload is running".to_string(), + last_transition_time: String::new(), + }); + + let composed = ComposedPhase::new(&status, false, true); + + assert_eq!(composed.phase, SandboxPhase::Ready); + assert!(!composed.backend_ready_without_session); + } + #[test] fn delete_gate_registry_removes_stale_entries() { let registry = LifecycleGateRegistry::default(); @@ -6480,8 +6374,7 @@ mod tests { )); } - /// Driver calls are a remote boundary even in-process: they reach the - /// Docker daemon, the Kubernetes API, or a Podman socket. + /// Driver calls are a remote boundary even when the driver is in-process. #[tokio::test] async fn driver_calls_export_spans_with_parents() { use tracing::Instrument as _; @@ -9239,7 +9132,7 @@ mod tests { #[tokio::test] async fn backend_not_ready_with_supervisor_becomes_ready() { - // VM path: supervisor connects before backend reports Ready. + // The supervisor may connect before the backend reports Ready. let runtime = test_runtime(Arc::new(TestDriver::default())).await; let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Provisioning); runtime.store.put_message(&sandbox).await.unwrap(); @@ -10401,45 +10294,16 @@ mod tests { } #[test] - fn build_platform_config_inverts_user_namespaces_to_host_users() { - use prost_types::value::Kind; - - // user_namespaces: true → host_users: false - let mut template = SandboxTemplate { + fn driver_template_preserves_user_namespace_intent() { + let template = SandboxTemplate { user_namespaces: Some(true), ..SandboxTemplate::default() }; - let config = build_platform_config(&template).expect("config should be Some"); - let host_users = config - .fields - .get("host_users") - .expect("host_users must exist"); - assert_eq!( - host_users.kind, - Some(Kind::BoolValue(false)), - "user_namespaces: true must produce host_users: false" - ); - - // user_namespaces: false → host_users: true - template.user_namespaces = Some(false); - let config = build_platform_config(&template).expect("config should be Some"); - let host_users = config - .fields - .get("host_users") - .expect("host_users must exist"); - assert_eq!( - host_users.kind, - Some(Kind::BoolValue(true)), - "user_namespaces: false must produce host_users: true" - ); + let driver_template = driver_sandbox_template_from_public(&template, "test") + .expect("template conversion should succeed"); - // user_namespaces: None → host_users absent - template.user_namespaces = None; - let config = build_platform_config(&template); - assert!( - config.is_none() || !config.as_ref().unwrap().fields.contains_key("host_users"), - "unset user_namespaces must not produce host_users" - ); + assert_eq!(driver_template.user_namespaces, Some(true)); + assert!(driver_template.platform_config.is_none()); } #[tokio::test] @@ -10645,6 +10509,10 @@ mod tests { let mut sandbox = sandbox_record("sb-uds", "uds-sandbox", SandboxPhase::Provisioning); sandbox.spec = Some(SandboxSpec { log_level: "debug".to_string(), + policy: Some(openshell_core::proto::SandboxPolicy { + version: 42, + ..Default::default() + }), template: Some(SandboxTemplate { image: "ghcr.io/nvidia/openshell/sandbox:test".to_string(), driver_config: Some(prost_types::Struct { @@ -10684,6 +10552,20 @@ mod tests { .expect("selected driver_config should be forwarded"); assert!(driver_config.fields.contains_key("pool")); assert!(!driver_config.fields.contains_key("network_mode")); + assert_eq!( + validated + .spec + .as_ref() + .and_then(|spec| spec.policy.as_ref()) + .map(|policy| policy.version), + Some(42) + ); + assert!(matches!( + &calls[3], + FakeComputeDriverCall::CreateSandbox { sandbox: Some(sandbox) } + if sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()) + .is_some_and(|policy| policy.version == 42) + )); driver.clear_calls(); runtime diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index 74b6aad01b..65709748b1 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -26,7 +26,6 @@ use std::net::SocketAddr; use std::path::{Path, PathBuf}; use base64::Engine as _; -use openshell_core::config::ComputeDriverKind; use openshell_core::proto::SupervisorMiddlewareService; use openshell_core::{ GatewayAuthConfig, GatewayInterceptorConfig, GatewayJwtConfig, @@ -432,13 +431,22 @@ pub fn driver_table( driver_name: &str, gateway: &GatewayFileSection, raw: Option<&toml::Value>, +) -> toml::Value { + driver_table_with_inherited_keys(driver_name, gateway, raw, &[]) +} + +pub(crate) fn driver_table_with_inherited_keys( + _driver_name: &str, + gateway: &GatewayFileSection, + raw: Option<&toml::Value>, + inheritable_keys: &[&str], ) -> toml::Value { let mut merged = match raw { Some(toml::Value::Table(table)) => table.clone(), _ => toml::Table::new(), }; - for key in inheritable_keys(driver_name) { + for key in inheritable_keys { if merged.contains_key(*key) { continue; } @@ -450,50 +458,6 @@ pub fn driver_table( toml::Value::Table(merged) } -/// Inheritance allowlist (the Q4 "high-overlap set"). Each driver opts in -/// to a specific subset so a gateway-wide default does not accidentally land -/// in a driver table that does not understand the field. -fn inheritable_keys(driver_name: &str) -> &'static [&'static str] { - match driver_name.parse::().ok() { - Some(ComputeDriverKind::Kubernetes) => &[ - "namespace", - "default_image", - "supervisor_image", - "client_tls_secret_name", - "service_account_name", - "host_gateway_ip", - "enable_user_namespaces", - "sa_token_ttl_secs", - ], - Some(ComputeDriverKind::Docker) => &[ - "sandbox_namespace", - "default_image", - "supervisor_image", - "host_gateway_ip", - "guest_tls_ca", - "guest_tls_cert", - "guest_tls_key", - ], - Some(ComputeDriverKind::Podman) => &[ - "default_image", - "supervisor_image", - "host_gateway_ip", - "guest_tls_ca", - "guest_tls_cert", - "guest_tls_key", - ], - Some(ComputeDriverKind::Vm) => &[ - "default_image", - "guest_tls_ca", - "guest_tls_cert", - "guest_tls_key", - ], - // MXC reads its own settings from the driver config table and has no - // gateway-inherited required fields. - Some(ComputeDriverKind::Mxc) | None => &[], - } -} - fn gateway_inherited_value(g: &GatewayFileSection, key: &str) -> Option { match key { "namespace" | "sandbox_namespace" => g.sandbox_namespace.as_deref().map(string_value), @@ -986,10 +950,11 @@ version = 2 let raw = toml::toml! { namespace = "agents" }; - let merged = driver_table( - ComputeDriverKind::Kubernetes.as_str(), + let merged = driver_table_with_inherited_keys( + "alpha", &gateway, Some(&toml::Value::Table(raw)), + &["default_image", "supervisor_image"], ); let table = merged.as_table().expect("table"); assert_eq!( @@ -1007,14 +972,19 @@ version = 2 } #[test] - fn docker_driver_table_inherits_gateway_defaults() { + fn registered_driver_table_inherits_selected_gateway_defaults() { let gateway = GatewayFileSection { sandbox_namespace: Some("agents".to_string()), default_image: Some("ghcr.io/nvidia/openshell/sandbox:0.9".to_string()), host_gateway_ip: Some("10.0.0.1".to_string()), ..Default::default() }; - let merged = driver_table(ComputeDriverKind::Docker.as_str(), &gateway, None); + let merged = driver_table_with_inherited_keys( + "alpha", + &gateway, + None, + &["sandbox_namespace", "default_image", "host_gateway_ip"], + ); let table = merged.as_table().expect("table"); assert_eq!( table.get("sandbox_namespace").and_then(|v| v.as_str()), @@ -1031,13 +1001,18 @@ version = 2 } #[test] - fn podman_driver_table_inherits_gateway_host_gateway_ip() { + fn registered_driver_table_can_select_network_defaults() { let gateway = GatewayFileSection { default_image: Some("ghcr.io/nvidia/openshell/sandbox:0.9".to_string()), host_gateway_ip: Some("192.168.127.254".to_string()), ..Default::default() }; - let merged = driver_table(ComputeDriverKind::Podman.as_str(), &gateway, None); + let merged = driver_table_with_inherited_keys( + "beta", + &gateway, + None, + &["default_image", "host_gateway_ip"], + ); let table = merged.as_table().expect("table"); assert_eq!( table.get("default_image").and_then(|v| v.as_str()), @@ -1058,10 +1033,11 @@ version = 2 let raw = toml::toml! { default_image = "driver-specific" }; - let merged = driver_table( - ComputeDriverKind::Podman.as_str(), + let merged = driver_table_with_inherited_keys( + "alpha", &gateway, Some(&toml::Value::Table(raw)), + &["default_image"], ); assert_eq!( merged @@ -1075,13 +1051,12 @@ version = 2 #[test] fn driver_table_does_not_leak_keys_outside_allowlist() { - // `client_tls_secret_name` is K8s-only; Docker must not receive it - // even when set at gateway scope. + // Fields not selected by the registration must remain gateway-only. let gateway = GatewayFileSection { client_tls_secret_name: Some("openshell-sandbox-tls".to_string()), ..Default::default() }; - let merged = driver_table(ComputeDriverKind::Docker.as_str(), &gateway, None); + let merged = driver_table_with_inherited_keys("alpha", &gateway, None, &["default_image"]); assert!( !merged .as_table() diff --git a/crates/openshell-server/src/gateway_listener.rs b/crates/openshell-server/src/gateway_listener.rs index 640757bfb3..b638fc33e4 100644 --- a/crates/openshell-server/src/gateway_listener.rs +++ b/crates/openshell-server/src/gateway_listener.rs @@ -386,10 +386,10 @@ mod tests { #[test] fn gateway_listener_specs_reuse_primary_when_wildcard_covers_driver_address() { let primary: SocketAddr = "0.0.0.0:8080".parse().unwrap(); - let docker: SocketAddr = "172.18.0.1:8080".parse().unwrap(); + let callback: SocketAddr = "172.18.0.1:8080".parse().unwrap(); let requirements = [ - docker_listener_requirement(docker), - docker_listener_requirement(docker), + exact_listener_requirement(callback), + exact_listener_requirement(callback), ]; assert_eq!( @@ -401,15 +401,15 @@ mod tests { #[test] fn gateway_listener_scope_for_reused_primary_remains_primary() { let primary: SocketAddr = "0.0.0.0:8080".parse().unwrap(); - let docker: SocketAddr = "172.18.0.1:8080".parse().unwrap(); + let callback: SocketAddr = "172.18.0.1:8080".parse().unwrap(); let loopback: SocketAddr = "127.0.0.1:8080".parse().unwrap(); - let [spec] = gateway_listener_specs(primary, &[docker_listener_requirement(docker)]) + let [spec] = gateway_listener_specs(primary, &[exact_listener_requirement(callback)]) .unwrap() .try_into() .unwrap(); assert_eq!( - spec.scope_for_local_addr(docker), + spec.scope_for_local_addr(callback), GatewayListenerScope::Primary, ); assert_eq!( @@ -421,10 +421,10 @@ mod tests { #[test] fn gateway_listener_specs_preserve_driver_callback_scope() { let primary: SocketAddr = "127.0.0.1:8080".parse().unwrap(); - let docker: SocketAddr = "172.18.0.1:8080".parse().unwrap(); + let callback: SocketAddr = "172.18.0.1:8080".parse().unwrap(); let requirements = [ - docker_listener_requirement(docker), - docker_listener_requirement(docker), + exact_listener_requirement(callback), + exact_listener_requirement(callback), ]; assert_eq!( @@ -437,11 +437,11 @@ mod tests { provenance: None, }, GatewayListenerSpec { - address: docker, + address: callback, scope: GatewayListenerScope::ComputeDriverCallback, covered_addresses: Vec::new(), provenance: Some(GatewayListenerProvenance { - driver_name: "docker".to_string(), + driver_name: "alpha".to_string(), reason: "managed bridge".to_string(), }), }, @@ -473,7 +473,7 @@ mod tests { "172.18.0.1:0", "172.18.0.1:9090", ] { - let requirement = docker_listener_requirement(address.parse().unwrap()); + let requirement = exact_listener_requirement(address.parse().unwrap()); assert!( gateway_listener_specs(primary, &[requirement]).is_err(), "{address} should be rejected" @@ -482,41 +482,41 @@ mod tests { } #[test] - fn gateway_listener_specs_use_exact_podman_network_gateway() { + fn gateway_listener_specs_use_exact_network_gateway() { let primary: SocketAddr = "127.0.0.1:8080".parse().unwrap(); - let podman_gateway: SocketAddr = "10.89.1.1:8080".parse().unwrap(); + let network_gateway: SocketAddr = "10.89.1.1:8080".parse().unwrap(); assert_eq!( - gateway_listener_specs(primary, &[podman_listener_requirement(podman_gateway)]) + gateway_listener_specs(primary, &[network_listener_requirement(network_gateway)]) .unwrap(), vec![ primary_listener_spec(primary), - callback_listener_spec(podman_gateway, "podman", "Podman managed bridge",), + callback_listener_spec(network_gateway, "beta", "managed bridge",), ] ); } #[test] - fn gateway_listener_specs_reuse_primary_when_it_covers_podman_exact() { + fn gateway_listener_specs_reuse_primary_when_it_covers_exact_requirement() { let primary: SocketAddr = "0.0.0.0:8080".parse().unwrap(); - let podman_gateway: SocketAddr = "10.89.1.1:8080".parse().unwrap(); + let network_gateway: SocketAddr = "10.89.1.1:8080".parse().unwrap(); assert_eq!( - gateway_listener_specs(primary, &[podman_listener_requirement(podman_gateway)],) + gateway_listener_specs(primary, &[network_listener_requirement(network_gateway)],) .unwrap(), vec![primary_listener_spec(primary)] ); } #[test] - fn gateway_listener_specs_resolve_podman_default_route_source() { + fn gateway_listener_specs_resolve_default_route_source() { let primary: SocketAddr = "127.0.0.1:8080".parse().unwrap(); let default_route_ip = "192.168.20.20".parse().unwrap(); assert_eq!( gateway_listener_specs_with_default_route_ip( primary, - &[podman_default_route_listener_requirement()], + &[default_route_listener_requirement()], Some(default_route_ip), ) .unwrap(), @@ -524,8 +524,8 @@ mod tests { primary_listener_spec(primary), callback_listener_spec( "192.168.20.20:8080".parse().unwrap(), - "podman", - "rootless pasta upstream interface", + "beta", + "default route interface", ), ] ); @@ -537,7 +537,7 @@ mod tests { let err = gateway_listener_specs_with_default_route_ip( primary, - &[podman_default_route_listener_requirement()], + &[default_route_listener_requirement()], Some("203.0.113.20".parse().unwrap()), ) .unwrap_err(); @@ -552,7 +552,7 @@ mod tests { assert_eq!( gateway_listener_specs_with_default_route_ip( primary, - &[podman_default_route_listener_requirement()], + &[default_route_listener_requirement()], Some(default_route_ip), ) .unwrap(), @@ -561,28 +561,24 @@ mod tests { } #[test] - fn gateway_listener_specs_resolve_podman_loopback_separately() { + fn gateway_listener_specs_resolve_loopback_separately() { let primary: SocketAddr = "192.168.20.20:8080".parse().unwrap(); assert_eq!( - gateway_listener_specs(primary, &[podman_loopback_listener_requirement()]).unwrap(), + gateway_listener_specs(primary, &[loopback_listener_requirement()]).unwrap(), vec![ primary_listener_spec(primary), - callback_listener_spec( - "127.0.0.1:8080".parse().unwrap(), - "podman", - "Podman machine host forwarder", - ), + callback_listener_spec("127.0.0.1:8080".parse().unwrap(), "beta", "host forwarder",), ] ); } #[test] - fn gateway_listener_specs_reuse_wildcard_primary_for_podman_loopback() { + fn gateway_listener_specs_reuse_wildcard_primary_for_loopback() { let primary = "0.0.0.0:8080".parse().unwrap(); assert_eq!( - gateway_listener_specs(primary, &[podman_loopback_listener_requirement()]).unwrap(), + gateway_listener_specs(primary, &[loopback_listener_requirement()]).unwrap(), vec![primary_listener_spec(primary)] ); } @@ -592,7 +588,7 @@ mod tests { let primary = "127.0.0.1:8080".parse().unwrap(); assert_eq!( - gateway_listener_specs(primary, &[podman_loopback_listener_requirement()]).unwrap(), + gateway_listener_specs(primary, &[loopback_listener_requirement()]).unwrap(), vec![primary_listener_spec(primary)] ); } @@ -602,7 +598,7 @@ mod tests { for primary in ["[::1]:8080", "[::]:8080"] { let primary = primary.parse().unwrap(); let specs = - gateway_listener_specs(primary, &[podman_loopback_listener_requirement()]).unwrap(); + gateway_listener_specs(primary, &[loopback_listener_requirement()]).unwrap(); assert_eq!(specs.len(), 2); assert_eq!(specs[1].address, SocketAddr::from(([127, 0, 0, 1], 8080))); @@ -613,7 +609,7 @@ mod tests { fn gateway_listener_specs_validate_selector_independently_of_driver_name() { let primary: SocketAddr = "192.168.20.20:8080".parse().unwrap(); let requirement = GatewayListenerRequirement::LoopbackInterface { - driver_name: "docker".to_string(), + driver_name: "alpha".to_string(), reason: "wrong selector".to_string(), }; @@ -634,7 +630,7 @@ mod tests { let result: openshell_core::Result<()> = async { let _listeners = bind_gateway_listeners( primary_address, - &[docker_listener_requirement(occupied_address)], + &[exact_listener_requirement(occupied_address)], ) .await?; continuation_reached.store(true, Ordering::SeqCst); @@ -663,7 +659,7 @@ mod tests { drop(probe); let primary = format!("[::]:{port}").parse().unwrap(); - let listeners = bind_gateway_listeners(primary, &[podman_loopback_listener_requirement()]) + let listeners = bind_gateway_listeners(primary, &[loopback_listener_requirement()]) .await .expect("IPv6 wildcard and IPv4 callback listeners should both bind"); @@ -675,33 +671,33 @@ mod tests { ); } - fn docker_listener_requirement(address: SocketAddr) -> GatewayListenerRequirement { + fn exact_listener_requirement(address: SocketAddr) -> GatewayListenerRequirement { GatewayListenerRequirement::Exact { address, - driver_name: "docker".to_string(), + driver_name: "alpha".to_string(), reason: "managed bridge".to_string(), } } - fn podman_listener_requirement(address: SocketAddr) -> GatewayListenerRequirement { + fn network_listener_requirement(address: SocketAddr) -> GatewayListenerRequirement { GatewayListenerRequirement::Exact { address, - driver_name: "podman".to_string(), - reason: "Podman managed bridge".to_string(), + driver_name: "beta".to_string(), + reason: "managed bridge".to_string(), } } - fn podman_default_route_listener_requirement() -> GatewayListenerRequirement { + fn default_route_listener_requirement() -> GatewayListenerRequirement { GatewayListenerRequirement::DefaultRouteInterface { - driver_name: "podman".to_string(), - reason: "rootless pasta upstream interface".to_string(), + driver_name: "beta".to_string(), + reason: "default route interface".to_string(), } } - fn podman_loopback_listener_requirement() -> GatewayListenerRequirement { + fn loopback_listener_requirement() -> GatewayListenerRequirement { GatewayListenerRequirement::LoopbackInterface { - driver_name: "podman".to_string(), - reason: "Podman machine host forwarder".to_string(), + driver_name: "beta".to_string(), + reason: "host forwarder".to_string(), } } diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 83bdc0b00b..0e4c74af95 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -3135,20 +3135,6 @@ pub(super) async fn handle_get_sandbox_provider_environment( // Update config handler (policy + settings mutations) // --------------------------------------------------------------------------- -fn validate_live_policy_update_support( - driver_kind: Option, - has_policy: bool, - has_merge_ops: bool, -) -> Result<(), Status> { - if (has_policy || has_merge_ops) && driver_kind == Some(openshell_core::ComputeDriverKind::Mxc) - { - return Err(Status::failed_precondition( - "live policy updates are not supported for MXC sandboxes; recreate the sandbox so the new policy is mapped before launch", - )); - } - Ok(()) -} - pub(super) async fn handle_update_config( state: &Arc, request: Request, @@ -3223,7 +3209,6 @@ async fn handle_update_config_inner( "one of policy, setting_key, or merge_operations must be provided", )); } - validate_live_policy_update_support(state.compute.driver_kind(), has_policy, has_merge_ops)?; if req.global { if !req.annotations.is_empty() { return Err(Status::invalid_argument( @@ -6911,27 +6896,6 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use tonic::Code; - #[test] - fn mxc_rejects_sandbox_policy_replacement_and_merge_updates() { - for (has_policy, has_merge_ops) in [(true, false), (false, true)] { - let error = validate_live_policy_update_support( - Some(openshell_core::ComputeDriverKind::Mxc), - has_policy, - has_merge_ops, - ) - .expect_err("MXC must reject policy mutations after launch"); - assert_eq!(error.code(), Code::FailedPrecondition); - } - - let error = validate_live_policy_update_support( - Some(openshell_core::ComputeDriverKind::Mxc), - true, - false, - ) - .expect_err("global policy replacement also changes desired state for live MXC sandboxes"); - assert_eq!(error.code(), Code::FailedPrecondition); - } - /// Wrap a request with a user `Principal` so handler scope guards treat /// the test caller as a CLI user. Most handler tests exercise /// user-facing behavior and should not trip sandbox equality checks. diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index de658909b0..5364e0541e 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -29,8 +29,7 @@ use openshell_core::proto::{ }; use openshell_core::proto::{Sandbox, SandboxPhase, SandboxTemplate, SshSession}; use openshell_core::telemetry::{ - LifecycleOperation, LifecycleResource, SandboxTemplateSource, TelemetryComputeDriver, - TelemetryOutcome, + LifecycleOperation, LifecycleResource, SandboxTemplateSource, TelemetryOutcome, }; use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; use prost::Message; @@ -172,7 +171,7 @@ fn emit_sandbox_create_telemetry( request: &CreateSandboxRequest, outcome: TelemetryOutcome, ) { - let compute_driver = telemetry_compute_driver(state.compute.driver_kind()); + let compute_driver = state.compute.telemetry_compute_driver(); let Some(spec) = request.spec.as_ref() else { openshell_core::telemetry::emit_sandbox_create( outcome, @@ -205,12 +204,6 @@ fn emit_sandbox_create_telemetry( ); } -fn telemetry_compute_driver( - driver_kind: Option, -) -> TelemetryComputeDriver { - TelemetryComputeDriver::from_driver_kind(driver_kind) -} - async fn handle_create_sandbox_inner( state: &Arc, request: Request, @@ -347,11 +340,8 @@ async fn handle_create_sandbox_inner( status })?; - // Mint the gateway JWT for singleplayer drivers. K8s sandboxes skip - // this mint and bootstrap via `IssueSandboxToken` at supervisor - // startup; identifying "is this K8s?" lives in the compute layer, so - // we mint unconditionally here when the issuer is configured and let - // the K8s driver simply ignore the field. + // Mint a gateway JWT whenever the issuer is configured. Compute runtimes + // that bootstrap through another authentication mechanism may ignore it. let sandbox_token = state.sandbox_jwt_issuer.as_ref().map(|issuer| { issuer.mint(&id).map(|minted| { tracing::info!( @@ -2600,30 +2590,6 @@ mod tests { } } - #[test] - fn telemetry_compute_driver_uses_resolved_driver_kind() { - assert_eq!( - telemetry_compute_driver(Some(openshell_core::ComputeDriverKind::Docker)), - TelemetryComputeDriver::Docker - ); - assert_eq!( - telemetry_compute_driver(Some(openshell_core::ComputeDriverKind::Kubernetes)), - TelemetryComputeDriver::Kubernetes - ); - assert_eq!( - telemetry_compute_driver(Some(openshell_core::ComputeDriverKind::Podman)), - TelemetryComputeDriver::Podman - ); - assert_eq!( - telemetry_compute_driver(Some(openshell_core::ComputeDriverKind::Vm)), - TelemetryComputeDriver::Vm - ); - assert_eq!( - telemetry_compute_driver(None), - TelemetryComputeDriver::Unknown - ); - } - #[test] fn shell_escape_safe_chars_pass_through() { assert_eq!(shell_escape("ls").unwrap(), "ls"); @@ -3636,8 +3602,7 @@ mod tests { #[tokio::test] async fn create_and_get_preserve_partial_process_identity() { - let state = - test_server_state_with_driver(openshell_core::ComputeDriverKind::Docker.as_str()).await; + let state = test_server_state_with_driver("docker").await; let policy = openshell_core::proto::SandboxPolicy { version: 1, process: Some(openshell_core::proto::ProcessPolicy { @@ -3701,9 +3666,7 @@ mod tests { #[tokio::test] async fn create_and_get_preserve_partial_process_identity_for_kubernetes() { - let state = - test_server_state_with_driver(openshell_core::ComputeDriverKind::Kubernetes.as_str()) - .await; + let state = test_server_state_with_driver("kubernetes").await; let policy = openshell_core::proto::SandboxPolicy { version: 1, process: Some(openshell_core::proto::ProcessPolicy { diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index c2306b6632..2acd34fc44 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -13,16 +13,6 @@ //! startup. Runtime selection only consults that registry or a configured //! external endpoint; it does not switch on driver names. -// `defaults-without-telemetry` is an alias for the default feature set minus -// `telemetry`, not a switch that turns telemetry off. Cargo cannot subtract a -// default feature, so adding it on top of the defaults would otherwise produce -// a telemetry-on build that reads as telemetry-free. Fail the build instead. -#[cfg(all(feature = "telemetry", feature = "defaults-without-telemetry"))] -compile_error!( - "features `telemetry` and `defaults-without-telemetry` are mutually exclusive; \ - build a telemetry-free gateway with `--no-default-features --features defaults-without-telemetry`" -); - mod auth; pub mod certgen; pub mod cli; @@ -58,9 +48,8 @@ mod tracing_setup; mod ws_tunnel; use metrics_exporter_prometheus::PrometheusBuilder; -#[cfg(target_os = "windows")] -use openshell_core::ComputeDriverKind; use openshell_core::net::set_tcp_nodelay_best_effort; +use openshell_core::telemetry::TelemetryComputeDriver; use openshell_core::{Config, Error, ObjectLabels, Result}; use openshell_extension_core::{ BearerTokenSlot, ExtensionAudience, ExtensionCallerKind, ExtensionKind, MAX_EXTENSION_TOKEN_TTL, @@ -69,7 +58,7 @@ use openshell_supervisor_middleware::MiddlewareRegistry; use std::collections::{BTreeMap, HashMap}; use std::io::ErrorKind; use std::net::SocketAddr; -use std::path::Path; +use std::path::{Path, PathBuf}; #[cfg(test)] use std::sync::LazyLock; use std::sync::{ @@ -303,8 +292,8 @@ pub struct ServerState { /// Registry of active supervisor sessions and pending relay channels. /// - /// Stored as `Arc` so compute drivers (e.g. the Docker driver) - /// can be constructed before `ServerState` and still + /// Stored as `Arc` so compiled compute drivers can be constructed before + /// `ServerState` and still /// query session state to surface supervisor readiness. pub supervisor_sessions: Arc, @@ -452,14 +441,14 @@ impl ServerState { /// Returns an error if the server fails to start or encounters a fatal error. pub(crate) async fn run_server( startup: ServerStartupConfig, - compute_driver: ConfiguredComputeDriver, tracing_log_bus: TracingLogBus, + compute_drivers: ComputeDriverRegistry, ) -> Result<()> { let ServerStartupConfig { config, config_file, guest_tls, - compute_driver: _, + compute_driver, } = startup; let (shutdown_tx, shutdown_rx) = watch::channel(false); @@ -600,12 +589,18 @@ pub(crate) async fn run_server( let sandbox_index = SandboxIndex::new(); let sandbox_watch_bus = SandboxWatchBus::new(); let supervisor_sessions = Arc::new(supervisor_session::SupervisorSessionRegistry::new()); - let driver_startup = - compute_driver_startup_context(&config, config_file.as_ref(), guest_tls.as_ref()); - let (compute, _operator_allowlist) = build_compute_runtime( + let driver_startup = compute::driver_config::DriverStartupContext { + file: config_file.as_ref(), + guest_tls: guest_tls.as_ref(), + gateway_port: config.bind_address.port(), + gateway_tls_enabled: config.tls.is_some(), + endpoint_overrides: &config.compute_driver_endpoints, + }; + let compute = build_compute_runtime( + &compute_drivers, + &compute_driver, &config, driver_startup, - compute_driver, store.clone(), sandbox_index.clone(), sandbox_watch_bus.clone(), @@ -677,7 +672,7 @@ pub(crate) async fn run_server( auth::compute_driver::ComputeDriverAuthenticator::new(state.compute.clone()), )); info!( - driver = state.compute.selected_driver_name(), + driver = state.compute.configured_driver_name(), "compute-driver sandbox bootstrap authenticator enabled" ); } @@ -1048,30 +1043,59 @@ async fn terminate_signal() { let _ = signal.recv().await; } -#[cfg(all(target_os = "windows", feature = "in-tree-compute-drivers"))] -fn unsupported_builtin_compute_driver(driver: ComputeDriverKind) -> compute::ComputeError { - compute::ComputeError::Message(format!( - "{} compute driver is unsupported on Windows", - driver.as_str() - )) +pub use compute::{ + AcquiredRemoteDriverEndpoint, DriverWatchStream, ManagedDriverProcess, SharedComputeDriver, +}; + +/// Driver instance returned by a compiled compute-driver factory. +pub enum ComputeDriverInstance { + /// A driver hosted in the gateway process. + InProcess(SharedComputeDriver), + /// A driver process launched and owned by the gateway. + ManagedRemote(AcquiredRemoteDriverEndpoint), } -type OperatorAllowlistArc = Option; -pub use compute::{DriverWatchStream, SharedComputeDriver}; +/// Type-erased tracing layer contributed by a compiled compute driver. +pub type ComputeDriverTracingLayer = + Box + Send + Sync>; + +/// Shutdown callback for resources owned by a compute-driver tracing layer. +pub type ComputeDriverTracingShutdown = + Box std::result::Result<(), String> + Send + Sync>; + +/// Optional process-wide tracing integration supplied by a compiled driver. +#[derive(Default)] +pub struct ComputeDriverTracingSetup { + layer: Option, + shutdown: Option, + error: Option, + target_prefix: Option<&'static str>, +} -/// Opaque result returned by a compiled compute-driver factory. -pub struct ComputeDriverBuildOutput { - runtime: ComputeRuntime, - operator_allowlist: OperatorAllowlistArc, +impl ComputeDriverTracingSetup { + #[must_use] + pub fn new( + layer: Option, + shutdown: Option, + error: Option, + target_prefix: Option<&'static str>, + ) -> Self { + Self { + layer, + shutdown, + error, + target_prefix, + } + } } +/// Factory for a compiled driver's optional tracing integration. +pub type ComputeDriverTracingFactory = fn(Option<&str>, Option<&str>) -> ComputeDriverTracingSetup; + /// Factory for a compute driver linked into a gateway binary. #[async_trait::async_trait] pub trait ComputeDriverFactory: Send + Sync { - async fn build( - &self, - context: ComputeDriverBuildContext<'_>, - ) -> Result; + async fn build(&self, context: ComputeDriverBuildContext<'_>) -> Result; } /// One named compiled-driver registration. @@ -1081,6 +1105,11 @@ pub struct ComputeDriverRegistration { detection_priority: u16, detect: Option bool>, factory: Arc, + telemetry_category: TelemetryComputeDriver, + inherited_config_keys: &'static [&'static str], + local_singleplayer: bool, + supports_mtls_user_auth: bool, + tracing_setup: Option, } impl std::fmt::Debug for ComputeDriverRegistration { @@ -1109,8 +1138,59 @@ impl ComputeDriverRegistration { detection_priority, detect, factory: Arc::new(factory), + telemetry_category: TelemetryComputeDriver::custom(), + inherited_config_keys: &[], + local_singleplayer: false, + supports_mtls_user_auth: true, + tracing_setup: None, }) } + + /// Select gateway-wide defaults understood by this driver's config type. + #[must_use] + pub fn with_inherited_config_keys(mut self, keys: &'static [&'static str]) -> Self { + self.inherited_config_keys = keys; + self + } + + /// Assign a bounded telemetry category chosen by the binary composition + /// boundary. Runtime driver names are never used as telemetry values. + #[must_use] + pub fn with_telemetry_category(mut self, category: TelemetryComputeDriver) -> Self { + self.telemetry_category = category; + self + } + + /// Mark a backend whose local deployment should use single-player defaults. + #[must_use] + pub fn with_local_singleplayer(mut self) -> Self { + self.local_singleplayer = true; + self + } + + /// Mark a backend that requires user authentication other than mTLS. + #[must_use] + pub fn without_mtls_user_auth(mut self) -> Self { + self.supports_mtls_user_auth = false; + self + } + + /// Attach optional process-wide tracing for this compiled driver. + #[must_use] + pub fn with_tracing_setup(mut self, setup: ComputeDriverTracingFactory) -> Self { + self.tracing_setup = Some(setup); + self + } + + #[must_use] + pub(crate) fn is_local_singleplayer(&self) -> bool { + self.local_singleplayer + } + + #[must_use] + pub(crate) fn supports_mtls_user_auth(&self) -> bool { + self.supports_mtls_user_auth + } } /// Registry of compute drivers compiled into this gateway binary. @@ -1175,10 +1255,28 @@ impl ComputeDriverRegistry { self.drivers.keys().map(String::as_str) } - fn get(&self, name: &str) -> Option<&ComputeDriverRegistration> { + pub(crate) fn get(&self, name: &str) -> Option<&ComputeDriverRegistration> { self.drivers.get(name) } + fn tracing_setup( + &self, + selection: &ComputeDriverSelection, + endpoint_overrides: &BTreeMap, + otlp_endpoint: Option<&str>, + gateway_name: Option<&str>, + ) -> ComputeDriverTracingSetup { + let name = selection.name(); + if endpoint_overrides.contains_key(name) { + return ComputeDriverTracingSetup::default(); + } + self.get(name) + .and_then(|registration| registration.tracing_setup) + .map_or_else(ComputeDriverTracingSetup::default, |setup| { + setup(otlp_endpoint, gateway_name) + }) + } + fn detect(&self) -> ComputeDriverDetection { let mut candidates = self .drivers @@ -1205,7 +1303,7 @@ impl ComputeDriverRegistry { if detection.selected().is_none() { return Err(Error::config( "no compute driver configured and auto-detection found no suitable installed \ - driver; set --drivers or OPENSHELL_DRIVERS=", + driver; set --drivers or OPENSHELL_DRIVERS=", )); } Ok(ComputeDriverSelection::AutoDetected(detection)) @@ -1223,88 +1321,14 @@ impl ComputeDriverRegistry { } } -/// Install every first-party compute driver linked into the standard gateway. -#[must_use] -pub fn install_default_compute_drivers() -> ComputeDriverRegistry { - #[allow(unused_mut)] - let mut registry = ComputeDriverRegistry::new(); - #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] - { - registry - .install( - ComputeDriverRegistration::new( - "kubernetes", - 100, - Some(|| std::env::var_os("KUBERNETES_SERVICE_HOST").is_some()), - KubernetesComputeDriverFactory, - ) - .expect("valid kubernetes registration"), - ) - .expect("unique kubernetes registration"); - registry - .install( - ComputeDriverRegistration::new( - "podman", - 200, - Some(openshell_core::config::is_podman_available), - PodmanComputeDriverFactory, - ) - .expect("valid podman registration"), - ) - .expect("unique podman registration"); - registry - .install( - ComputeDriverRegistration::new( - "docker", - 300, - Some(openshell_core::config::is_docker_available), - DockerComputeDriverFactory, - ) - .expect("valid docker registration"), - ) - .expect("unique docker registration"); - registry - .install( - ComputeDriverRegistration::new("vm", u16::MAX, None, VmComputeDriverFactory) - .expect("valid vm registration"), - ) - .expect("unique vm registration"); - } - #[cfg(all(target_os = "windows", feature = "in-tree-compute-drivers"))] - { - registry - .install( - ComputeDriverRegistration::new("mxc", u16::MAX, None, MxcComputeDriverFactory) - .expect("valid mxc registration"), - ) - .expect("unique mxc registration"); - for name in ["kubernetes", "podman", "docker", "vm"] { - registry - .install( - ComputeDriverRegistration::new( - name, - u16::MAX, - None, - UnsupportedComputeDriverFactory, - ) - .expect("valid unsupported registration"), - ) - .expect("unique unsupported registration"); - } - } - registry -} - pub struct ComputeDriverBuildContext<'a> { driver_name: String, - config: &'a Config, + gateway_name: &'a str, + gateway_bind_address: SocketAddr, + gateway_log_level: &'a str, driver_startup: compute::driver_config::DriverStartupContext<'a>, - store: Arc, - sandbox_index: SandboxIndex, - sandbox_watch_bus: SandboxWatchBus, - tracing_log_bus: TracingLogBus, - supervisor_sessions: Arc, shutdown_rx: watch::Receiver, + inherited_config_keys: &'static [&'static str], } impl ComputeDriverBuildContext<'_> { @@ -1314,8 +1338,18 @@ impl ComputeDriverBuildContext<'_> { } #[must_use] - pub fn gateway_config(&self) -> &Config { - self.config + pub fn gateway_name(&self) -> &str { + self.gateway_name + } + + #[must_use] + pub fn gateway_bind_address(&self) -> SocketAddr { + self.gateway_bind_address + } + + #[must_use] + pub fn gateway_log_level(&self) -> &str { + self.gateway_log_level } #[must_use] @@ -1341,7 +1375,11 @@ impl ComputeDriverBuildContext<'_> { where T: Default + serde::de::DeserializeOwned, { - compute::driver_config::driver_config_from_context(self.driver_startup, &self.driver_name) + compute::driver_config::driver_config_from_context( + self.driver_startup, + &self.driver_name, + self.inherited_config_keys, + ) } #[must_use] @@ -1349,242 +1387,84 @@ impl ComputeDriverBuildContext<'_> { self.shutdown_rx.clone() } - /// Finish construction of an in-process driver through the common runtime path. - pub async fn finish_in_process( - self, - driver: SharedComputeDriver, - ) -> Result { - let runtime = ComputeRuntime::from_driver( - self.driver_name, - driver, - None, - self.store, - self.sandbox_index, - self.sandbox_watch_bus, - self.tracing_log_bus, - self.supervisor_sessions, - ) - .await - .map_err(|error| Error::execution(format!("failed to create compute runtime: {error}")))?; - Ok(ComputeDriverBuildOutput { - runtime, - operator_allowlist: None, - }) - } -} - -#[cfg(all(target_os = "windows", feature = "in-tree-compute-drivers"))] -#[derive(Clone, Copy)] -struct MxcComputeDriverFactory; - -#[cfg(all(target_os = "windows", feature = "in-tree-compute-drivers"))] -#[async_trait::async_trait] -impl ComputeDriverFactory for MxcComputeDriverFactory { - async fn build( - &self, - context: ComputeDriverBuildContext<'_>, - ) -> Result { - let mxc_config = compute::driver_config::mxc_config_from_context(context.driver_startup)?; - let runtime = ComputeRuntime::new_mxc( - mxc_config, - context.store, - context.sandbox_index, - context.sandbox_watch_bus, - context.tracing_log_bus, - context.supervisor_sessions, - ) - .await - .map_err(|error| Error::execution(format!("failed to create compute runtime: {error}")))?; - Ok(ComputeDriverBuildOutput { - runtime, - operator_allowlist: None, - }) - } -} - -#[cfg(all(target_os = "windows", feature = "in-tree-compute-drivers"))] -#[derive(Clone, Copy)] -struct UnsupportedComputeDriverFactory; - -#[cfg(all(target_os = "windows", feature = "in-tree-compute-drivers"))] -#[async_trait::async_trait] -impl ComputeDriverFactory for UnsupportedComputeDriverFactory { - async fn build( - &self, - context: ComputeDriverBuildContext<'_>, - ) -> Result { - Err(Error::execution( - unsupported_builtin_compute_driver( - context - .driver_name - .parse() - .expect("default driver names are valid"), - ) - .to_string(), - )) - } -} - -#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] -#[derive(Clone, Copy)] -struct KubernetesComputeDriverFactory; - -#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] -#[async_trait::async_trait] -impl ComputeDriverFactory for KubernetesComputeDriverFactory { - async fn build( - &self, - context: ComputeDriverBuildContext<'_>, - ) -> Result { - warn_if_kubernetes_sandbox_jwt_expiry_disabled(context.config); - let config = compute::driver_config::builtin::kubernetes_config_from_context( - context.driver_startup, - )?; - let (runtime, operator_allowlist) = ComputeRuntime::new_kubernetes( - config, - context.store, - context.sandbox_index, - context.sandbox_watch_bus, - context.tracing_log_bus, - context.supervisor_sessions, - context.shutdown_rx, - ) - .await - .map_err(|error| Error::execution(format!("failed to create compute runtime: {error}")))?; - Ok(ComputeDriverBuildOutput { - runtime, - operator_allowlist, - }) - } -} - -#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] -#[derive(Clone, Copy)] -struct DockerComputeDriverFactory; - -#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] -#[async_trait::async_trait] -impl ComputeDriverFactory for DockerComputeDriverFactory { - async fn build( - &self, - context: ComputeDriverBuildContext<'_>, - ) -> Result { - let driver_config = - compute::driver_config::builtin::docker_config_from_context(context.driver_startup)?; - let runtime = ComputeRuntime::new_docker( - context.config.clone(), - driver_config, - context.store, - context.sandbox_index, - context.sandbox_watch_bus, - context.tracing_log_bus, - context.supervisor_sessions, - ) - .await - .map_err(|error| Error::execution(format!("failed to create compute runtime: {error}")))?; - Ok(ComputeDriverBuildOutput { - runtime, - operator_allowlist: None, - }) - } -} - -#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] -#[derive(Clone, Copy)] -struct PodmanComputeDriverFactory; - -#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] -#[async_trait::async_trait] -impl ComputeDriverFactory for PodmanComputeDriverFactory { - async fn build( - &self, - context: ComputeDriverBuildContext<'_>, - ) -> Result { - let driver_config = - compute::driver_config::builtin::podman_config_from_context(context.driver_startup)?; - let runtime = ComputeRuntime::new_podman( - driver_config, - context.store, - context.sandbox_index, - context.sandbox_watch_bus, - context.tracing_log_bus, - context.supervisor_sessions, - ) - .await - .map_err(|error| Error::execution(format!("failed to create compute runtime: {error}")))?; - Ok(ComputeDriverBuildOutput { - runtime, - operator_allowlist: None, - }) - } -} - -#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] -#[derive(Clone, Copy)] -struct VmComputeDriverFactory; - -#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] -#[async_trait::async_trait] -impl ComputeDriverFactory for VmComputeDriverFactory { - async fn build( - &self, - context: ComputeDriverBuildContext<'_>, - ) -> Result { - let driver_config = - compute::driver_config::builtin::vm_config_from_context(context.driver_startup)?; - let otlp_config = context - .driver_startup + #[must_use] + pub fn otlp_config(&self) -> Option<&config_file::OtlpConfig> { + self.driver_startup .file - .and_then(|file| file.openshell.gateway.otlp.as_ref()); - let endpoint = compute::vm::spawn(context.config, &driver_config, otlp_config).await?; - let runtime = ComputeRuntime::new_remote_driver( - endpoint, - context.store, - context.sandbox_index, - context.sandbox_watch_bus, - context.tracing_log_bus, - context.supervisor_sessions, - ) - .await - .map_err(|error| Error::execution(format!("failed to create compute runtime: {error}")))?; - Ok(ComputeDriverBuildOutput { - runtime, - operator_allowlist: None, - }) + .and_then(|file| file.openshell.gateway.otlp.as_ref()) } } #[allow(clippy::too_many_arguments)] async fn build_compute_runtime( + registry: &ComputeDriverRegistry, + selection: &ComputeDriverSelection, config: &Config, driver_startup: compute::driver_config::DriverStartupContext<'_>, - driver: ConfiguredComputeDriver, store: Arc, sandbox_index: SandboxIndex, sandbox_watch_bus: SandboxWatchBus, tracing_log_bus: TracingLogBus, supervisor_sessions: Arc, shutdown_rx: watch::Receiver, -) -> Result<(ComputeRuntime, OperatorAllowlistArc)> { +) -> Result { + let driver = resolve_configured_compute_driver(registry, selection.name(), driver_startup)?; + let telemetry_compute_driver = driver.telemetry_compute_driver(registry); info!(driver = %driver.name(), "Using compute driver"); + if config + .gateway_jwt + .as_ref() + .is_some_and(|jwt| jwt.ttl_secs == 0) + && !driver.is_local_singleplayer(registry) + { + warn!( + "Gateway configured with non-expiring sandbox JWTs; set gateway_jwt.ttl_secs > 0 for shared deployments" + ); + } - let (runtime, operator_allowlist) = match driver { + let runtime = match driver { ConfiguredComputeDriver::Registered(registration) => { - let output = registration - .factory - .build(ComputeDriverBuildContext { - driver_name: registration.name, - config, - driver_startup, + let build_context = ComputeDriverBuildContext { + driver_name: registration.name.clone(), + gateway_name: &config.name, + gateway_bind_address: config.bind_address, + gateway_log_level: &config.log_level, + driver_startup, + shutdown_rx, + inherited_config_keys: registration.inherited_config_keys, + }; + let instance = registration.factory.build(build_context).await?; + match instance { + ComputeDriverInstance::InProcess(driver) => ComputeRuntime::from_driver( + registration.name, + driver, + None, store, sandbox_index, sandbox_watch_bus, tracing_log_bus, supervisor_sessions, - shutdown_rx, - }) - .await?; - (output.runtime, output.operator_allowlist) + ) + .await + .map_err(|error| { + Error::execution(format!("failed to create compute runtime: {error}")) + })?, + ComputeDriverInstance::ManagedRemote(mut endpoint) => { + endpoint.name = registration.name; + ComputeRuntime::new_remote_driver( + endpoint, + store, + sandbox_index, + sandbox_watch_bus, + tracing_log_bus, + supervisor_sessions, + ) + .await + .map_err(|error| { + Error::execution(format!("failed to create compute runtime: {error}")) + })? + } + } } ConfiguredComputeDriver::Remote { name } => { let remote_config = @@ -1597,7 +1477,7 @@ async fn build_compute_runtime( let endpoint = compute::connect_remote_compute_driver(name, &remote_config.socket_path) .await .map_err(|e| Error::execution(format!("failed to create compute runtime: {e}")))?; - let rt = ComputeRuntime::new_remote_driver( + ComputeRuntime::new_remote_driver( endpoint, store, sandbox_index, @@ -1606,58 +1486,56 @@ async fn build_compute_runtime( supervisor_sessions, ) .await - .map_err(|e| Error::execution(format!("failed to create compute runtime: {e}")))?; - (rt, None) + .map_err(|e| Error::execution(format!("failed to create compute runtime: {e}")))? } }; - Ok((runtime, operator_allowlist)) -} - -fn compute_driver_startup_context<'a>( - config: &'a Config, - config_file: Option<&'a config_file::ConfigFile>, - guest_tls: Option<&'a compute::driver_config::GuestTlsPaths>, -) -> compute::driver_config::DriverStartupContext<'a> { - compute::driver_config::DriverStartupContext { - file: config_file, - guest_tls, - gateway_port: config.bind_address.port(), - gateway_tls_enabled: config.tls.is_some(), - endpoint_overrides: &config.compute_driver_endpoints, - } + Ok(runtime.with_telemetry_compute_driver(telemetry_compute_driver)) } #[derive(Debug, Clone)] -pub(crate) enum ConfiguredComputeDriver { +enum ConfiguredComputeDriver { Registered(ComputeDriverRegistration), Remote { name: String }, } impl ConfiguredComputeDriver { - pub(crate) fn name(&self) -> &str { + fn name(&self) -> &str { match self { Self::Registered(registration) => ®istration.name, Self::Remote { name } => name, } } + + fn is_local_singleplayer(&self, registry: &ComputeDriverRegistry) -> bool { + match self { + Self::Registered(registration) => registration.is_local_singleplayer(), + Self::Remote { name } => registry + .get(name) + .is_some_and(ComputeDriverRegistration::is_local_singleplayer), + } + } + + fn telemetry_compute_driver(&self, registry: &ComputeDriverRegistry) -> TelemetryComputeDriver { + match self { + Self::Registered(registration) => registration.telemetry_category, + Self::Remote { name } => registry + .get(name) + .map_or_else(TelemetryComputeDriver::custom, |registration| { + registration.telemetry_category + }), + } + } } -pub(crate) fn configured_compute_driver_for_startup( +#[cfg(test)] +fn configured_compute_driver( registry: &ComputeDriverRegistry, - startup: &ServerStartupConfig, + config: &Config, + driver_startup: compute::driver_config::DriverStartupContext<'_>, ) -> Result { - resolve_configured_compute_driver( - registry, - startup.compute_driver.name(), - compute::driver_config::DriverStartupContext { - file: startup.config_file.as_ref(), - guest_tls: startup.guest_tls.as_ref(), - gateway_port: startup.config.bind_address.port(), - gateway_tls_enabled: startup.config.tls.is_some(), - endpoint_overrides: &startup.config.compute_driver_endpoints, - }, - ) + let selection = registry.select(&config.compute_drivers)?; + resolve_configured_compute_driver(registry, selection.name(), driver_startup) } fn resolve_configured_compute_driver( @@ -1681,23 +1559,6 @@ fn resolve_configured_compute_driver( Ok(ConfiguredComputeDriver::Remote { name }) } -#[cfg(any(test, feature = "in-tree-compute-drivers"))] -fn kubernetes_sandbox_jwt_expiry_disabled(config: &Config) -> bool { - config - .gateway_jwt - .as_ref() - .is_some_and(|jwt| jwt.ttl_secs == 0) -} - -#[cfg(feature = "in-tree-compute-drivers")] -fn warn_if_kubernetes_sandbox_jwt_expiry_disabled(config: &Config) { - if kubernetes_sandbox_jwt_expiry_disabled(config) { - warn!( - "Kubernetes gateway configured with non-expiring sandbox JWTs (gateway_jwt.ttl_secs = 0); set ttl_secs > 0 for shared Kubernetes deployments" - ); - } -} - pub(crate) async fn ensure_default_workspace(store: &Store) -> Result<()> { use grpc::workspace::{DEFAULT_WORKSPACE_NAME, WORKSPACE_OBJECT_TYPE}; use openshell_core::proto::Workspace; @@ -1763,11 +1624,11 @@ mod tests { BoundGatewayListener, ConfiguredComputeDriver, ConnectionProtocol, ExtensionKind, GatewayListenerScope, MultiplexService, ServerState, TlsAcceptor, allow_plaintext_service_http, bind_gateway_listeners, classify_initial_bytes, - is_benign_tls_handshake_failure, kubernetes_sandbox_jwt_expiry_disabled, + configured_compute_driver, is_benign_tls_handshake_failure, mint_gateway_extension_credential, serve_gateway_listener, }; use openshell_core::{ - ComputeDriverKind, Config, + Config, proto::{HealthRequest, open_shell_client::OpenShellClient}, }; use std::io::{Error, ErrorKind}; @@ -1788,6 +1649,26 @@ mod tests { tls_test_utils::{generate_test_certs_with_ca, install_rustls_provider}, }; + static DETECTION_PROBE_ORDER: LazyLock>> = + LazyLock::new(|| Mutex::new(Vec::new())); + + fn record_detection_probe(name: &'static str, available: bool) -> bool { + DETECTION_PROBE_ORDER.lock().unwrap().push(name); + available + } + + fn unavailable_first_probe() -> bool { + record_detection_probe("first", false) + } + + fn available_second_probe() -> bool { + record_detection_probe("second", true) + } + + fn available_third_probe() -> bool { + record_detection_probe("third", true) + } + fn extension_test_issuer() -> Arc { let material = openshell_bootstrap::jwt::generate_jwt_key().expect("jwt key"); Arc::new( @@ -1890,47 +1771,37 @@ mod tests { } fn test_compute_drivers() -> super::ComputeDriverRegistry { - super::install_default_compute_drivers() - } - - fn select_compute_driver( - registry: &super::ComputeDriverRegistry, - config: &Config, - driver_startup: crate::compute::driver_config::DriverStartupContext<'_>, - ) -> openshell_core::Result { - let selection = registry.select(&config.compute_drivers)?; - super::resolve_configured_compute_driver(registry, selection.name(), driver_startup) + let mut registry = super::ComputeDriverRegistry::new(); + for (name, priority) in [("alpha", 100), ("beta", 200), ("gamma", 300)] { + registry + .install( + super::ComputeDriverRegistration::new( + name, + priority, + None, + TestComputeDriverFactory, + ) + .unwrap() + .with_telemetry_category( + openshell_core::telemetry::TelemetryComputeDriver::anonymous_category( + "registered", + ), + ), + ) + .unwrap(); + } + registry } #[derive(Clone, Copy)] struct TestComputeDriverFactory; - static DETECTION_PROBE_ORDER: LazyLock>> = - LazyLock::new(|| Mutex::new(Vec::new())); - - fn record_detection_probe(name: &'static str, available: bool) -> bool { - DETECTION_PROBE_ORDER.lock().unwrap().push(name); - available - } - - fn unavailable_first_probe() -> bool { - record_detection_probe("first", false) - } - - fn available_second_probe() -> bool { - record_detection_probe("second", true) - } - - fn available_third_probe() -> bool { - record_detection_probe("third", true) - } - #[async_trait::async_trait] impl super::ComputeDriverFactory for TestComputeDriverFactory { async fn build( &self, _context: super::ComputeDriverBuildContext<'_>, - ) -> openshell_core::Result { + ) -> openshell_core::Result { unreachable!("selection tests do not construct the driver") } } @@ -2244,38 +2115,31 @@ mod tests { #[test] fn configured_compute_driver_triggers_auto_detection_when_empty() { - let config = Config::new(None).with_compute_drivers(std::iter::empty::()); - // Empty drivers triggers auto-detection, which may return Some or None - // depending on the environment. This test verifies the auto-detection path - // is taken rather than immediately returning an error. - let result = select_compute_driver( - &test_compute_drivers(), - &config, - test_driver_startup(&config, None), - ); - // Either we get a detected driver or an error about none being detected. - match result { - Ok(ConfiguredComputeDriver::Registered(registration)) => { - assert!( - matches!( - registration.name.as_str(), - "kubernetes" | "docker" | "podman" - ), - "auto-detected unexpected driver: {}", - registration.name - ); - } - Ok(ConfiguredComputeDriver::Remote { name }) => { - panic!("auto-detection returned remote driver: {name}"); - } - Err(e) => { - assert!( - e.to_string() - .contains("auto-detection found no suitable installed driver"), - "unexpected error: {e}" - ); - } + fn available() -> bool { + true } + + let mut registry = super::ComputeDriverRegistry::new(); + registry + .install( + super::ComputeDriverRegistration::new( + "detected", + 100, + Some(available), + TestComputeDriverFactory, + ) + .unwrap(), + ) + .unwrap(); + let config = Config::new(None).with_compute_drivers(std::iter::empty::()); + let result = + configured_compute_driver(®istry, &config, test_driver_startup(&config, None)) + .unwrap(); + + let ConfiguredComputeDriver::Registered(registration) = result else { + panic!("auto-detection must select a registered driver"); + }; + assert_eq!(registration.name, "detected"); } #[test] @@ -2338,9 +2202,8 @@ mod tests { #[test] fn configured_compute_driver_rejects_multiple_entries() { - let config = Config::new(None) - .with_compute_drivers([ComputeDriverKind::Kubernetes, ComputeDriverKind::Podman]); - let err = select_compute_driver( + let config = Config::new(None).with_compute_drivers(["alpha", "beta"]); + let err = configured_compute_driver( &test_compute_drivers(), &config, test_driver_startup(&config, None), @@ -2350,64 +2213,38 @@ mod tests { err.to_string() .contains("multiple compute drivers are not supported yet") ); - assert!(err.to_string().contains("kubernetes,podman")); - } - - #[test] - fn configured_compute_driver_accepts_podman() { - let config = Config::new(None).with_compute_drivers([ComputeDriverKind::Podman]); - let driver = select_compute_driver( - &test_compute_drivers(), - &config, - test_driver_startup(&config, None), - ) - .unwrap(); - assert!(matches!( - driver, - ConfiguredComputeDriver::Registered(registration) if registration.name == "podman" - )); - } - - #[test] - fn configured_compute_driver_accepts_vm() { - let config = Config::new(None).with_compute_drivers([ComputeDriverKind::Vm]); - let driver = select_compute_driver( - &test_compute_drivers(), - &config, - test_driver_startup(&config, None), - ) - .unwrap(); - assert!(matches!( - driver, - ConfiguredComputeDriver::Registered(registration) if registration.name == "vm" - )); + assert!(err.to_string().contains("alpha,beta")); } #[test] - fn configured_compute_driver_accepts_docker() { - let config = Config::new(None).with_compute_drivers([ComputeDriverKind::Docker]); - let driver = select_compute_driver( - &test_compute_drivers(), - &config, - test_driver_startup(&config, None), - ) - .unwrap(); + fn configured_compute_driver_accepts_registered_name() { + let config = Config::new(None).with_compute_drivers(["beta"]); + let registry = test_compute_drivers(); + let driver = + configured_compute_driver(®istry, &config, test_driver_startup(&config, None)) + .unwrap(); + assert_eq!( + driver.telemetry_compute_driver(®istry).as_str(), + "registered" + ); assert!(matches!( driver, - ConfiguredComputeDriver::Registered(registration) if registration.name == "docker" + ConfiguredComputeDriver::Registered(registration) if registration.name == "beta" )); } #[test] fn configured_compute_driver_resolves_named_remote() { let config = Config::new(None).with_compute_drivers(["kyma"]); + let registry = test_compute_drivers(); - let driver = select_compute_driver( - &test_compute_drivers(), - &config, - test_driver_startup(&config, None), - ) - .unwrap(); + let driver = + configured_compute_driver(®istry, &config, test_driver_startup(&config, None)) + .unwrap(); + assert_eq!( + driver.telemetry_compute_driver(®istry).as_str(), + "custom" + ); match driver { ConfiguredComputeDriver::Remote { name } => { @@ -2423,30 +2260,32 @@ mod tests { } #[test] - fn configured_compute_driver_uses_vm_endpoint_override() { + fn configured_compute_driver_uses_endpoint_override() { let config = Config::new(None) - .with_compute_drivers([ComputeDriverKind::Vm]) - .with_compute_driver_endpoint("vm", "/run/openshell/vm.sock"); + .with_compute_drivers(["alpha"]) + .with_compute_driver_endpoint("alpha", "/run/openshell/alpha.sock"); + let registry = test_compute_drivers(); - let driver = select_compute_driver( - &test_compute_drivers(), - &config, - test_driver_startup(&config, None), - ) - .unwrap(); + let driver = + configured_compute_driver(®istry, &config, test_driver_startup(&config, None)) + .unwrap(); + assert_eq!( + driver.telemetry_compute_driver(®istry).as_str(), + "registered" + ); assert!(matches!( driver, - ConfiguredComputeDriver::Remote { name } if name == "vm" + ConfiguredComputeDriver::Remote { name } if name == "alpha" )); } #[test] fn configured_compute_driver_uses_builtin_endpoint_override() { let config = Config::new(None) - .with_compute_drivers([ComputeDriverKind::Docker]) - .with_compute_driver_endpoint("docker", "/run/openshell/docker.sock"); + .with_compute_drivers(["beta"]) + .with_compute_driver_endpoint("beta", "/run/openshell/beta.sock"); - let driver = select_compute_driver( + let driver = configured_compute_driver( &test_compute_drivers(), &config, test_driver_startup(&config, None), @@ -2454,48 +2293,8 @@ mod tests { .unwrap(); assert!(matches!( driver, - ConfiguredComputeDriver::Remote { name } if name == "docker" - )); - } - - #[test] - fn kubernetes_sandbox_jwt_expiry_disabled_warns_for_zero_ttl() { - fn config_with_jwt_ttl(ttl_secs: u64) -> Config { - let mut config = Config::new(None); - config.gateway_jwt = Some(openshell_core::GatewayJwtConfig { - signing_key_path: "/tmp/signing.pem".into(), - public_key_path: "/tmp/public.pem".into(), - kid_path: "/tmp/kid".into(), - gateway_id: "openshell".to_string(), - ttl_secs, - }); - config - } - - assert!(kubernetes_sandbox_jwt_expiry_disabled( - &config_with_jwt_ttl(0) - )); - assert!(!kubernetes_sandbox_jwt_expiry_disabled( - &config_with_jwt_ttl(3600) + ConfiguredComputeDriver::Remote { name } if name == "beta" )); - assert!(!kubernetes_sandbox_jwt_expiry_disabled(&Config::new(None))); - } - - #[cfg(target_os = "windows")] - #[test] - fn windows_builtin_compute_drivers_report_unsupported() { - for driver in [ - ComputeDriverKind::Docker, - ComputeDriverKind::Kubernetes, - ComputeDriverKind::Podman, - ComputeDriverKind::Vm, - ] { - let message = super::unsupported_builtin_compute_driver(driver).to_string(); - assert!( - message.contains("unsupported on Windows"), - "{driver} rejection should be explicit, got: {message}" - ); - } } #[tokio::test] diff --git a/crates/openshell-server/src/otel_tracing.rs b/crates/openshell-server/src/otel_tracing.rs index b4e8d9e506..16a94a7321 100644 --- a/crates/openshell-server/src/otel_tracing.rs +++ b/crates/openshell-server/src/otel_tracing.rs @@ -51,11 +51,6 @@ impl<'a> GatewayResourceAttributes<'a> { compute_driver, } } - - /// The configured gateway installation name, if any. - pub fn name(&self) -> Option<&'a str> { - self.name - } } fn trace_config<'cfg>( @@ -134,11 +129,13 @@ pub fn provider_for( openshell_otel::provider_for(cfg.map(|cfg| trace_config(cfg, gateway))) } -/// Build the gateway layer while routing one selected in-process driver to -/// its own tracer provider. -pub fn layer_excluding_driver( +/// Build the `tracing` layer that forwards spans to `provider`. +/// +/// Events stay on the gateway's logging layers. Spans emitted by the +/// OpenTelemetry crates are excluded to prevent recursive export traffic. +pub fn layer( provider: &SdkTracerProvider, - driver_target_prefix: Option<&'static str>, + excluded_target_prefix: Option<&'static str>, ) -> openshell_otel::TargetOtlpLayer where S: Subscriber + for<'span> LookupSpan<'span>, @@ -146,7 +143,7 @@ where openshell_otel::layer_excluding_target_prefix( provider, INSTRUMENTATION_SCOPE, - driver_target_prefix, + excluded_target_prefix, ) } @@ -180,8 +177,7 @@ pub mod test_exporter { let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder() .with_simple_exporter(exporter.clone()) .build(); - let subscriber = - tracing_subscriber::registry().with(super::layer_excluding_driver(&provider, None)); + let subscriber = tracing_subscriber::registry().with(super::layer(&provider, None)); let dispatch = tracing::Dispatch::new(subscriber); TracingTestGuard { _default: tracing::dispatcher::set_default(&dispatch), diff --git a/crates/openshell-server/src/sandbox_index.rs b/crates/openshell-server/src/sandbox_index.rs index 589f88fd88..c119ca6889 100644 --- a/crates/openshell-server/src/sandbox_index.rs +++ b/crates/openshell-server/src/sandbox_index.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! In-memory indexes for correlating Kubernetes objects back to sandbox ids. +//! In-memory indexes for correlating compute resources back to sandbox ids. use std::collections::HashMap; use std::sync::{Arc, RwLock}; diff --git a/crates/openshell-server/src/test_support.rs b/crates/openshell-server/src/test_support.rs index 5f4063ae45..0e4c318192 100644 --- a/crates/openshell-server/src/test_support.rs +++ b/crates/openshell-server/src/test_support.rs @@ -96,6 +96,7 @@ impl FakeComputeDriver { default_image: "openshell/sandbox:test".to_string(), gateway_manages_lifecycle: false, supports_sandbox_authentication: false, + driver_reports_runtime_readiness: false, }, gateway_listener_requirements: Vec::new(), gateway_listener_requirements_supported: true, @@ -284,11 +285,13 @@ impl ComputeDriver for FakeComputeDriver { request: Request, ) -> Result, Status> { self.record_traceparent(request.metadata()); - let sandbox = request.into_inner().sandbox; + let request = request.into_inner(); self.with_state(|state| { state .calls - .push(FakeComputeDriverCall::ValidateSandboxCreate { sandbox }); + .push(FakeComputeDriverCall::ValidateSandboxCreate { + sandbox: request.sandbox, + }); }); Ok(Response::new(ValidateSandboxCreateResponse {})) } @@ -337,7 +340,8 @@ impl ComputeDriver for FakeComputeDriver { request: Request, ) -> Result, Status> { self.record_traceparent(request.metadata()); - let sandbox = request.into_inner().sandbox; + let request = request.into_inner(); + let sandbox = request.sandbox; self.with_state(|state| { if let Some(sandbox) = sandbox.as_ref() { state.sandboxes.insert(sandbox.id.clone(), sandbox.clone()); diff --git a/crates/openshell-server/src/tracing_setup.rs b/crates/openshell-server/src/tracing_setup.rs index edcf303072..2bc583dac7 100644 --- a/crates/openshell-server/src/tracing_setup.rs +++ b/crates/openshell-server/src/tracing_setup.rs @@ -11,14 +11,14 @@ use opentelemetry_sdk::trace::SdkTracerProvider; use tracing_subscriber::EnvFilter; use tracing_subscriber::prelude::*; -use crate::ConfiguredComputeDriver; use crate::config_file::OtlpConfig; -use crate::otel_tracing::{GatewayResourceAttributes, SetupError}; +use crate::otel_tracing::GatewayResourceAttributes; use crate::tracing_bus::TracingLogBus; +use crate::{ComputeDriverTracingSetup, ComputeDriverTracingShutdown}; pub struct TracingHandle { tracer_provider: Option, - driver_tracer_provider: Option, + compute_driver_shutdown: Option, } impl TracingHandle { @@ -28,205 +28,46 @@ impl TracingHandle { { tracing::warn!(error = %err, "OTLP tracer provider shutdown failed"); } - if let Some(provider) = &self.driver_tracer_provider - && let Err(err) = provider.shutdown() + if let Some(shutdown) = &self.compute_driver_shutdown + && let Err(err) = shutdown() { - tracing::warn!(error = %err, "compute-driver OTLP tracer provider shutdown failed"); - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum InProcessDriverTracing { - #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] - Docker, - #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] - Kubernetes, - #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] - Podman, -} - -impl InProcessDriverTracing { - #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] - fn target_prefix(self) -> &'static str { - match self { - Self::Docker => openshell_driver_docker::otel_tracing::IN_PROCESS_TARGET_PREFIX, - Self::Kubernetes => openshell_driver_kubernetes::otel_tracing::IN_PROCESS_TARGET_PREFIX, - Self::Podman => openshell_driver_podman::otel_tracing::IN_PROCESS_TARGET_PREFIX, + tracing::warn!(error = %err, "Compute driver tracing shutdown failed"); } } } -fn in_process_driver_tracing(driver: &ConfiguredComputeDriver) -> Option { - #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] - match driver { - ConfiguredComputeDriver::Registered(registration) if registration.name == "docker" => { - Some(InProcessDriverTracing::Docker) - } - ConfiguredComputeDriver::Registered(registration) if registration.name == "podman" => { - Some(InProcessDriverTracing::Podman) - } - ConfiguredComputeDriver::Registered(registration) if registration.name == "kubernetes" => { - Some(InProcessDriverTracing::Kubernetes) - } - _ => None, - } - #[cfg(not(all(not(target_os = "windows"), feature = "in-tree-compute-drivers")))] - { - let _ = driver; - None - } -} - -fn in_process_driver_target_prefix(driver: Option) -> Option<&'static str> { - #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] - { - driver.map(InProcessDriverTracing::target_prefix) - } - #[cfg(not(all(not(target_os = "windows"), feature = "in-tree-compute-drivers")))] - { - let _ = driver; - None - } -} - -#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] -fn in_process_driver_provider( - driver: Option, - endpoint: Option<&str>, - gateway_name: Option<&str>, -) -> (Option, Option) { - match driver { - Some(InProcessDriverTracing::Docker) => { - openshell_driver_docker::otel_tracing::provider_for(endpoint, gateway_name) - } - Some(InProcessDriverTracing::Kubernetes) => { - openshell_driver_kubernetes::otel_tracing::provider_for(endpoint, gateway_name) - } - Some(InProcessDriverTracing::Podman) => { - openshell_driver_podman::otel_tracing::provider_for(endpoint, gateway_name) - } - None => (None, None), - } -} - -#[cfg(not(all(not(target_os = "windows"), feature = "in-tree-compute-drivers")))] -fn in_process_driver_provider( - _driver: Option, - _endpoint: Option<&str>, - _gateway_name: Option<&str>, -) -> (Option, Option) { - (None, None) -} - -#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] -fn in_process_driver_layer( - provider: &Option, - driver: Option, -) -> Option> -where - S: tracing::Subscriber + for<'span> tracing_subscriber::registry::LookupSpan<'span>, -{ - provider.as_ref().map(|provider| match driver { - Some(InProcessDriverTracing::Docker) => { - openshell_driver_docker::otel_tracing::in_process_layer(provider) - } - Some(InProcessDriverTracing::Kubernetes) => { - openshell_driver_kubernetes::otel_tracing::in_process_layer(provider) - } - Some(InProcessDriverTracing::Podman) => { - openshell_driver_podman::otel_tracing::in_process_layer(provider) - } - None => unreachable!("a driver provider requires a selected driver"), - }) -} - -#[cfg(not(all(not(target_os = "windows"), feature = "in-tree-compute-drivers")))] -fn in_process_driver_layer( - _provider: &Option, - _driver: Option, -) -> Option> -where - S: tracing::Subscriber + for<'span> tracing_subscriber::registry::LookupSpan<'span>, -{ - None -} - pub fn install( env_filter: EnvFilter, tracing_log_bus: &TracingLogBus, otlp_config: Option<&OtlpConfig>, - driver: &ConfiguredComputeDriver, + compute_driver_tracing: ComputeDriverTracingSetup, gateway: GatewayResourceAttributes<'_>, -) -> (TracingHandle, Option) { +) -> (TracingHandle, Option) { let (tracer_provider, setup_error) = crate::otel_tracing::provider_for(otlp_config, gateway); - let selected_driver = in_process_driver_tracing(driver); - let driver_endpoint = selected_driver - .is_some() - .then_some(otlp_config) - .flatten() - .map(|config| config.endpoint.as_str()); - let (driver_tracer_provider, driver_setup_error) = - in_process_driver_provider(selected_driver, driver_endpoint, gateway.name()); + let ComputeDriverTracingSetup { + layer, + shutdown, + error, + target_prefix, + } = compute_driver_tracing; tracing_subscriber::registry() + .with(layer) .with(env_filter) .with(tracing_subscriber::fmt::layer()) .with(tracing_log_bus.layer()) - .with(tracer_provider.as_ref().map(|provider| { - crate::otel_tracing::layer_excluding_driver( - provider, - in_process_driver_target_prefix(selected_driver), - ) - })) - .with(in_process_driver_layer( - &driver_tracer_provider, - selected_driver, - )) + .with( + tracer_provider + .as_ref() + .map(|provider| crate::otel_tracing::layer(provider, target_prefix)), + ) .init(); ( TracingHandle { tracer_provider, - driver_tracer_provider, + compute_driver_shutdown: shutdown, }, - setup_error.or(driver_setup_error), + setup_error.map(|error| error.to_string()).or(error), ) } - -#[cfg(test)] -mod tests { - use super::*; - - #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] - #[test] - fn in_process_driver_tracing_selects_registered_compute_drivers() { - let registry = crate::install_default_compute_drivers(); - let registered = |name| { - ConfiguredComputeDriver::Registered( - registry - .get(name) - .unwrap_or_else(|| panic!("{name} driver is registered")) - .clone(), - ) - }; - assert_eq!( - in_process_driver_tracing(®istered("podman")), - Some(InProcessDriverTracing::Podman) - ); - assert_eq!( - in_process_driver_tracing(®istered("docker")), - Some(InProcessDriverTracing::Docker) - ); - assert_eq!( - in_process_driver_tracing(®istered("kubernetes")), - Some(InProcessDriverTracing::Kubernetes) - ); - assert_eq!( - in_process_driver_tracing(&ConfiguredComputeDriver::Remote { - name: "custom".to_string(), - }), - None - ); - } -} diff --git a/deploy/docker/Dockerfile.gateway-macos b/deploy/docker/Dockerfile.gateway-macos index 122f16eba8..c7d526a039 100644 --- a/deploy/docker/Dockerfile.gateway-macos +++ b/deploy/docker/Dockerfile.gateway-macos @@ -53,6 +53,7 @@ ENV BINDGEN_EXTRA_CLANG_ARGS_aarch64_apple_darwin=--target=arm64-apple-macosx\ - COPY Cargo.toml Cargo.lock ./ COPY crates/openshell-core/Cargo.toml crates/openshell-core/Cargo.toml +COPY crates/openshell-gateway/Cargo.toml crates/openshell-gateway/Cargo.toml COPY crates/openshell-driver-kubernetes/Cargo.toml crates/openshell-driver-kubernetes/Cargo.toml COPY crates/openshell-policy/Cargo.toml crates/openshell-policy/Cargo.toml COPY crates/openshell-prover/Cargo.toml crates/openshell-prover/Cargo.toml @@ -61,39 +62,42 @@ COPY crates/openshell-server/Cargo.toml crates/openshell-server/Cargo.toml COPY crates/openshell-core/build.rs crates/openshell-core/build.rs COPY proto/ proto/ -RUN sed -i 's|members = \["crates/\*"\]|members = ["crates/openshell-server", "crates/openshell-core", "crates/openshell-driver-kubernetes", "crates/openshell-policy", "crates/openshell-prover", "crates/openshell-router"]|' Cargo.toml +RUN sed -i 's|members = \["crates/\*"\]|members = ["crates/openshell-gateway", "crates/openshell-server", "crates/openshell-core", "crates/openshell-driver-kubernetes", "crates/openshell-policy", "crates/openshell-prover", "crates/openshell-router"]|' Cargo.toml RUN mkdir -p crates/openshell-core/src \ + crates/openshell-gateway/src \ crates/openshell-driver-kubernetes/src \ crates/openshell-policy/src \ crates/openshell-prover/src \ crates/openshell-router/src \ crates/openshell-server/src && \ touch crates/openshell-core/src/lib.rs && \ + touch crates/openshell-gateway/src/lib.rs && \ + printf 'fn main() {}\n' > crates/openshell-gateway/src/main.rs && \ touch crates/openshell-driver-kubernetes/src/lib.rs && \ printf 'fn main() {}\n' > crates/openshell-driver-kubernetes/src/main.rs && \ touch crates/openshell-policy/src/lib.rs && \ touch crates/openshell-prover/src/lib.rs && \ touch crates/openshell-router/src/lib.rs && \ - touch crates/openshell-server/src/lib.rs && \ - printf 'fn main() {}\n' > crates/openshell-server/src/main.rs + touch crates/openshell-server/src/lib.rs RUN --mount=type=cache,id=cargo-registry-gateway-macos,sharing=locked,target=/root/.cargo/registry \ --mount=type=cache,id=cargo-git-gateway-macos,sharing=locked,target=/root/.cargo/git \ --mount=type=cache,id=cargo-target-gateway-macos-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \ - cargo build --release --target aarch64-apple-darwin -p openshell-server --features bundled-z3 2>/dev/null || true + cargo build --release --target aarch64-apple-darwin -p openshell-gateway --features bundled-z3 2>/dev/null || true COPY crates/ crates/ COPY providers/ providers/ RUN touch crates/openshell-core/src/lib.rs \ + crates/openshell-gateway/src/lib.rs \ + crates/openshell-gateway/src/main.rs \ crates/openshell-driver-kubernetes/src/lib.rs \ crates/openshell-driver-kubernetes/src/main.rs \ crates/openshell-policy/src/lib.rs \ crates/openshell-prover/src/lib.rs \ crates/openshell-router/src/lib.rs \ crates/openshell-server/src/lib.rs \ - crates/openshell-server/src/main.rs \ crates/openshell-core/build.rs \ proto/*.proto @@ -105,7 +109,7 @@ RUN --mount=type=cache,id=cargo-registry-gateway-macos,sharing=locked,target=/ro if [ -n "${OPENSHELL_CARGO_VERSION:-}" ]; then \ sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${OPENSHELL_CARGO_VERSION}"'"/}' Cargo.toml; \ fi && \ - cargo build --release --target aarch64-apple-darwin -p openshell-server --features bundled-z3 && \ + cargo build --release --target aarch64-apple-darwin -p openshell-gateway --features bundled-z3 && \ cp target/aarch64-apple-darwin/release/openshell-gateway /openshell-gateway FROM scratch AS binary diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index 82aa68a956..f00c49afd6 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -3,14 +3,21 @@ # SPDX-License-Identifier: Apache-2.0 title: "Sandbox Compute Drivers" sidebar-title: "Compute Drivers" -description: "Reference for Docker, Podman, MicroVM, and Kubernetes sandbox compute drivers." -keywords: "Generative AI, Cybersecurity, AI Agents, Sandboxing, Docker, Podman, MicroVM, Kubernetes, Reference" +description: "Reference for Docker, Podman, MicroVM, Kubernetes, and Windows MXC sandbox compute drivers." +keywords: "Generative AI, Cybersecurity, AI Agents, Sandboxing, Docker, Podman, MicroVM, Kubernetes, MXC, Reference" position: 4 --- The gateway's configured compute driver determines how OpenShell creates each sandbox. The CLI workflow stays the same across drivers: you create, connect to, inspect, stop, start, and delete sandboxes through the gateway API. -Every compute driver runs the OpenShell supervisor inside the sandbox workload. The supervisor launches the agent process, applies policy, routes egress through the proxy, injects configured credentials, and maintains the gateway session. +Most compute drivers run the OpenShell supervisor inside the sandbox workload. +The supervisor launches the agent process, applies policy, routes egress through +the proxy, injects configured credentials, and maintains the gateway session. +A driver may instead set `driver_reports_runtime_readiness`. In that mode, +driver-reported readiness does not require a supervisor session. The canonical +create-time policy is part of `DriverSandboxSpec`; a driver that enforces policy +outside the standard supervisor fetches later revisions through the existing +sandbox configuration API. The Windows MXC driver reports its own readiness. Stop stops compute but retains the sandbox record and the driver's persistent workspace boundary. Start reactivates the same driver resource. @@ -42,11 +49,12 @@ Configure the compute driver on the gateway. Current releases accept one driver compute_drivers = ["docker"] ``` -Reserved built-in values are `docker`, `podman`, `kubernetes`, and `vm`. +Reserved built-in values are `docker`, `podman`, `kubernetes`, `vm`, and `mxc`. +The `mxc` driver is available only in native Windows gateway builds. Non-reserved names select an extension driver and require a `socket_path` in `[openshell.drivers.]`. -When `compute_drivers` is unset, the gateway auto-detects Kubernetes, then Podman, then Docker. Local container runtimes must respond to an API probe before the gateway selects them. The VM driver is never auto-detected; configure it explicitly with `compute_drivers = ["vm"]` or set `OPENSHELL_DRIVERS=vm` in the launch environment. +When `compute_drivers` is unset, the gateway auto-detects Kubernetes, then Podman, then Docker. Docker must respond on a known API socket. Podman first probes known API sockets and then asks the `podman` CLI for the active native or machine-backed socket. The VM driver is never auto-detected; configure it explicitly with `compute_drivers = ["vm"]` or set `OPENSHELL_DRIVERS=vm` in the launch environment. Common gateway options: @@ -227,7 +235,7 @@ namespace roots. These checks do not make host bind mounts safe. [Podman](https://podman.io/)-backed sandboxes run as rootless containers on the gateway host. Use Podman for Linux workstation workflows that avoid a rootful Docker daemon. -The gateway talks to the Podman API socket. The Podman driver requires Podman 5.x, cgroups v2, rootless networking, and an active Podman user socket. When `socket_path` is not set, the driver probes for a responsive Podman socket and fails to start if none respond. +The gateway talks to the Podman API socket. The Podman driver requires Podman 5.x, cgroups v2, rootless networking, and an active Podman user socket. When `socket_path` is not set, the driver probes known socket paths, then uses the `podman` CLI to resolve the active native or machine-backed connection. It fails to start if neither method finds a socket. For maintainer-level implementation details, refer to the [Podman driver README](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-driver-podman/README.md) and [Podman networking notes](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-driver-podman/NETWORKING.md). @@ -340,7 +348,7 @@ The gateway starts `openshell-driver-vm` over a private Unix socket and passes i ### Local image resolution -The VM driver resolves sandbox images from a local container engine before falling back to registry pulls. It tries Docker first, then falls back to the Podman socket (Docker-compatible API). On Linux with Podman, enable the API socket so the driver can find local images: +The VM driver resolves sandbox images from a local container engine before falling back to registry pulls. It tries Docker first, then uses the same Podman socket discovery as the Podman driver. On Linux with Podman, enable the API socket so the driver can find local images: ```shell systemctl --user start podman.socket diff --git a/e2e/no-compute-driver-gateway.sh b/e2e/no-compute-driver-gateway.sh index 7ad3896ca1..baa4a7d193 100755 --- a/e2e/no-compute-driver-gateway.sh +++ b/e2e/no-compute-driver-gateway.sh @@ -8,11 +8,13 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "${ROOT}" echo "Building gateway without compiled compute drivers..." -cargo build -p openshell-server --bin openshell-gateway \ +cargo build -p openshell-gateway --bin openshell-gateway \ --no-default-features --features telemetry +cargo check -p openshell-core --no-default-features --all-targets -dependency_tree="$(cargo tree -p openshell-server \ +dependency_tree="$(cargo tree -p openshell-gateway \ --no-default-features --features telemetry --edges normal)" +server_dependency_tree="$(cargo tree -p openshell-server --edges normal)" for driver in \ openshell-driver-docker \ openshell-driver-kubernetes \ @@ -22,7 +24,18 @@ for driver in \ echo "ERROR: driver-free gateway dependency graph contains ${driver}" >&2 exit 1 fi + if grep -q "${driver} v" <<<"${server_dependency_tree}"; then + echo "ERROR: openshell-server dependency graph contains ${driver}" >&2 + exit 1 + fi done +if rg -n \ + 'ComputeDriverKind|openshell_driver_(docker|podman|kubernetes)([^_[:alnum:]]|$)|ComputeRuntime::new_(docker|podman|kubernetes)|VmComputeConfig|compute::vm|driver_config::builtin|libkrun|gvproxy|qemu' \ + crates/openshell-core crates/openshell-server; then + echo "ERROR: backend-specific compute-driver knowledge leaked into core/server" >&2 + exit 1 +fi + "${ROOT}/target/debug/openshell-gateway" --version echo "Driver-free gateway build passed." diff --git a/e2e/run.sh b/e2e/run.sh index 0505730f05..875186394c 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -250,7 +250,7 @@ guest_gateway_bin= if [ "${mode}" = host ]; then echo "==> Building native host openshell-gateway" mise x -- cargo build "${cargo_jobs[@]}" \ - -p openshell-server \ + -p openshell-gateway \ --bin openshell-gateway \ --features bundled-z3 host_gateway_bin="${target_dir}/debug/openshell-gateway" @@ -268,7 +268,7 @@ else mise x -- cargo zigbuild "${cargo_jobs[@]}" \ --release \ --target "${linux_gateway_zig_target}" \ - -p openshell-server \ + -p openshell-gateway \ --bin openshell-gateway \ --features bundled-z3 ) diff --git a/e2e/rust/e2e-vm.sh b/e2e/rust/e2e-vm.sh index de7e3ad09d..9acc633d65 100755 --- a/e2e/rust/e2e-vm.sh +++ b/e2e/rust/e2e-vm.sh @@ -103,10 +103,10 @@ if [ -z "${OPENSHELL_GATEWAY_BIN:-}" ]; then if [ "${OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER:-0}" = "1" ]; then echo "==> Building driver-free openshell-gateway" cargo build \ - -p openshell-server --bin openshell-gateway \ - --no-default-features + -p openshell-gateway --bin openshell-gateway \ + --no-default-features --features telemetry else - build_packages+=(-p openshell-server) + build_packages+=(-p openshell-gateway) fi else echo "==> Using prebuilt openshell-gateway at ${GATEWAY_BIN}" diff --git a/e2e/support/gateway-common.sh b/e2e/support/gateway-common.sh index c512dab60a..b1f62e380b 100644 --- a/e2e/support/gateway-common.sh +++ b/e2e/support/gateway-common.sh @@ -219,11 +219,11 @@ e2e_build_gateway_binaries() { echo "Building openshell-gateway..." if [ "${OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER:-0}" = "1" ]; then cargo build "${jobs[@]}" \ - -p openshell-server --bin openshell-gateway \ - --no-default-features + -p openshell-gateway --bin openshell-gateway \ + --no-default-features --features telemetry else cargo build "${jobs[@]}" \ - -p openshell-server --bin openshell-gateway + -p openshell-gateway --bin openshell-gateway fi else echo "Using prebuilt openshell gateway at ${OPENSHELL_GATEWAY_BIN}" diff --git a/e2e/with-kube-gateway.sh b/e2e/with-kube-gateway.sh index 08dd08113e..bd7f246d24 100755 --- a/e2e/with-kube-gateway.sh +++ b/e2e/with-kube-gateway.sh @@ -642,9 +642,12 @@ if [ "${OPENSHELL_E2E_KUBE_BUILD_IMAGES}" = "1" ]; then fi external_gateway="${OPENSHELL_GATEWAY_BIN:-${ROOT}/target/debug/openshell-gateway}" external_driver="${OPENSHELL_EXTERNAL_DRIVER_BIN:-${ROOT}/target/debug/openshell-driver-kubernetes}" + # The test image uses a distroless runtime, so keep Z3 self-contained just + # like the production gateway image artifact. A host-linked debug binary + # would otherwise require libz3.so from the CI build machine at runtime. if [ -z "${OPENSHELL_GATEWAY_BIN:-}" ]; then - cargo build -p openshell-server --bin openshell-gateway \ - --no-default-features --features bundled-z3 + cargo build -p openshell-gateway --bin openshell-gateway \ + --no-default-features --features telemetry,bundled-z3 fi if [ -z "${OPENSHELL_EXTERNAL_DRIVER_BIN:-}" ]; then cargo build -p openshell-driver-kubernetes --bin openshell-driver-kubernetes diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh index feaa42df05..e25ee0d3cb 100755 --- a/examples/governance-interceptor/smoke.sh +++ b/examples/governance-interceptor/smoke.sh @@ -649,7 +649,7 @@ wait_until_stopped() { cd "$ROOT" -run_setup_step "building gateway" cargo build --quiet -p openshell-server --bin openshell-gateway +run_setup_step "building gateway" cargo build --quiet -p openshell-gateway --bin openshell-gateway run_setup_step "building governance interceptor" cargo build --quiet --manifest-path "$EXAMPLE_DIR/Cargo.toml" run_setup_step "building CLI" cargo build --quiet -p openshell-cli --bin openshell diff --git a/examples/supervisor-middleware-content-guard/smoke.sh b/examples/supervisor-middleware-content-guard/smoke.sh index 509ffd403d..b30475c8ca 100755 --- a/examples/supervisor-middleware-content-guard/smoke.sh +++ b/examples/supervisor-middleware-content-guard/smoke.sh @@ -444,7 +444,7 @@ EXAMPLE_TARGET_DIR="$(cargo_target_dir "$EXAMPLE_DIR/Cargo.toml")" GATEWAY_BIN="$ROOT_TARGET_DIR/debug/openshell-gateway" CLI_BIN="$ROOT_TARGET_DIR/debug/openshell" MIDDLEWARE_BIN="$EXAMPLE_TARGET_DIR/debug/supervisor-middleware-content-guard" -run_setup_step "building gateway" cargo build --quiet -p openshell-server --bin openshell-gateway +run_setup_step "building gateway" cargo build --quiet -p openshell-gateway --bin openshell-gateway run_setup_step "building content guard" cargo build --quiet --manifest-path "$EXAMPLE_DIR/Cargo.toml" run_setup_step "building CLI" cargo build --quiet -p openshell-cli --bin openshell generate_gateway_jwt_bundle diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index eff5cfa5ea..b737e7de62 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -7,6 +7,7 @@ package openshell.compute.v1; import "google/protobuf/struct.proto"; import "options.proto"; +import "sandbox.proto"; // Gateway/compute-driver extension contract. // @@ -86,6 +87,10 @@ message GetCapabilitiesResponse { bool gateway_manages_lifecycle = 6; // Whether AuthenticateSandbox is implemented by this driver. bool supports_sandbox_authentication = 7; + // Whether the driver reports runtime readiness itself. When false, the + // gateway waits for the standard OpenShell supervisor session in addition + // to the driver's platform-ready observation. + bool driver_reports_runtime_readiness = 8; } message AuthenticateSandboxRequest { @@ -154,6 +159,10 @@ message DriverSandboxSpec { map environment = 5; // Runtime template consumed by the driver during provisioning. DriverSandboxTemplate template = 6; + // Canonical effective policy supplied to the driver for validation and + // provisioning. Drivers that enforce policy outside the standard supervisor + // use GetSandboxConfig to fetch later revisions. + openshell.sandbox.v1.SandboxPolicy policy = 7; // Portable resource requirements used by the gateway for driver selection // and by drivers for provisioning. ResourceRequirements resource_requirements = 9; @@ -214,6 +223,10 @@ message DriverSandboxTemplate { // This is the inner block selected from public SandboxTemplate.driver_config. // The selected driver owns nested schema validation. google.protobuf.Struct driver_config = 12; + // Enable Linux user namespace isolation for the sandbox workload. Drivers + // map this portable intent to their compute platform; when unset, the + // driver's configured default applies. + optional bool user_namespaces = 13; } // Typed compute-resource requirements. diff --git a/tasks/ci.toml b/tasks/ci.toml index 4c0b5f8ea7..cc4158448f 100644 --- a/tasks/ci.toml +++ b/tasks/ci.toml @@ -31,7 +31,7 @@ hide = true description = "Build release Rust binaries consumed by the hand-staged snap" run = [ "cargo build --release -p openshell-cli", - "cargo build --release -p openshell-server --features bundled-z3", + "cargo build --release -p openshell-gateway --features bundled-z3", "cargo build --release -p openshell-sandbox", ] diff --git a/tasks/gateway.toml b/tasks/gateway.toml index fb88849348..bbf309c459 100644 --- a/tasks/gateway.toml +++ b/tasks/gateway.toml @@ -5,7 +5,7 @@ ["build:gateway"] description = "Build the standalone openshell-gateway binary" -run = "cargo build -p openshell-server --bin openshell-gateway" +run = "cargo build -p openshell-gateway --bin openshell-gateway" hide = true ["gateway"] diff --git a/tasks/rust.toml b/tasks/rust.toml index 6ef34f60df..03b7ebb7ce 100644 --- a/tasks/rust.toml +++ b/tasks/rust.toml @@ -51,12 +51,12 @@ description = "Verify telemetry emission code is compiled out with --no-default- run = [ # Positive control: the default (telemetry-on) gateway must contain the # markers, so the absent checks below can never become silently vacuous. - "cargo build -p openshell-server --bin openshell-gateway", + "cargo build -p openshell-gateway --bin openshell-gateway", "tasks/scripts/verify-telemetry-compiled-out.sh present target/debug/openshell-gateway", # Guard: telemetry-free builds must contain no telemetry markers. Built # through the `defaults-without-telemetry` alias, which is how the docs tell # operators to produce these artifacts. - "cargo build -p openshell-server --bin openshell-gateway --no-default-features --features defaults-without-telemetry", + "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", diff --git a/tasks/scripts/gateway-docker.sh b/tasks/scripts/gateway-docker.sh index b38bfb7942..6826829fd8 100644 --- a/tasks/scripts/gateway-docker.sh +++ b/tasks/scripts/gateway-docker.sh @@ -161,7 +161,7 @@ fi echo "Building openshell-gateway..." cargo build ${CARGO_BUILD_JOBS_ARG[@]+"${CARGO_BUILD_JOBS_ARG[@]}"} \ - -p openshell-server --bin openshell-gateway + -p openshell-gateway --bin openshell-gateway TLS_DIR="${STATE_DIR}/tls" echo "Generating local gateway credentials..." diff --git a/tasks/scripts/gateway-vm.sh b/tasks/scripts/gateway-vm.sh index 2e8aecc5d0..3818dca364 100755 --- a/tasks/scripts/gateway-vm.sh +++ b/tasks/scripts/gateway-vm.sh @@ -311,7 +311,7 @@ fi echo "==> Building openshell-gateway and openshell-driver-vm" cargo build ${CARGO_BUILD_JOBS_ARG[@]+"${CARGO_BUILD_JOBS_ARG[@]}"} \ - -p openshell-server -p openshell-driver-vm + -p openshell-gateway -p openshell-driver-vm if [ "$(uname -s)" = "Darwin" ]; then echo "==> Codesigning openshell-driver-vm (Hypervisor entitlement)" diff --git a/tasks/scripts/package-deb-install.sh b/tasks/scripts/package-deb-install.sh index b6e4730674..e20d409bbd 100755 --- a/tasks/scripts/package-deb-install.sh +++ b/tasks/scripts/package-deb-install.sh @@ -56,7 +56,7 @@ remove_existing_gateway_registration() { echo "==> Building release binaries" cargo build --release \ -p openshell-cli \ - -p openshell-server \ + -p openshell-gateway \ -p openshell-driver-vm echo "==> Building Debian package" diff --git a/tasks/scripts/stage-prebuilt-binaries.sh b/tasks/scripts/stage-prebuilt-binaries.sh index 757a21298b..fe4913439a 100755 --- a/tasks/scripts/stage-prebuilt-binaries.sh +++ b/tasks/scripts/stage-prebuilt-binaries.sh @@ -124,7 +124,7 @@ components_for_target() { resolve_component() { case "$1" in gateway) - crate=openshell-server + crate=openshell-gateway binary=openshell-gateway target_libc=gnu ;; diff --git a/tasks/scripts/verify-defaults-without-telemetry.sh b/tasks/scripts/verify-defaults-without-telemetry.sh index 4d78cbc5b4..1fd7e67dff 100755 --- a/tasks/scripts/verify-defaults-without-telemetry.sh +++ b/tasks/scripts/verify-defaults-without-telemetry.sh @@ -22,7 +22,7 @@ set -euo pipefail # Crates that define the alias. Each must forward `telemetry` and define # `defaults-without-telemetry`. CRATES=( - openshell-server + openshell-gateway openshell-sandbox openshell-driver-vm ) diff --git a/tasks/scripts/vm/smoke-orphan-cleanup.sh b/tasks/scripts/vm/smoke-orphan-cleanup.sh index 6da48919d1..7d0b05334d 100755 --- a/tasks/scripts/vm/smoke-orphan-cleanup.sh +++ b/tasks/scripts/vm/smoke-orphan-cleanup.sh @@ -37,7 +37,7 @@ trap cleanup_stray EXIT build_binaries() { echo "==> Ensuring binaries are built" if [ ! -x "$ROOT/target/debug/openshell-gateway" ] || [ ! -x "$ROOT/target/debug/openshell-driver-vm" ]; then - cargo build -p openshell-server -p openshell-driver-vm >&2 + cargo build -p openshell-gateway -p openshell-driver-vm >&2 fi if [ "$(uname -s)" = "Darwin" ]; then codesign \ diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index cf1b4c8686..0a0410f4ca 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -583,7 +583,7 @@ function Assert-GatewayExcludesUnsupportedDriverCrates([string] $RustTarget) { $logName = "build-$RustTarget-driver-tree.log" Invoke-VsCargo ` -RustTarget $RustTarget ` - -CargoArgs "cargo tree -p openshell-server --target $RustTarget --prefix none" ` + -CargoArgs "cargo tree -p openshell-gateway --target $RustTarget --prefix none" ` -LogName $logName $logPath = Join-Path $LogDir $logName @@ -643,7 +643,7 @@ function Invoke-UnsupportedContractTests([string] $RustTarget) { foreach ($test in $tests) { Invoke-VsCargo ` -RustTarget $RustTarget ` - -CargoArgs "cargo test -p openshell-server --target $RustTarget $test $Z3ServerFeatures" ` + -CargoArgs "cargo test -p openshell-gateway --target $RustTarget $test $Z3ServerFeatures" ` -LogName "test-$RustTarget-unsupported-$test.log" } } From 7b64c5c88ece0878592160cb86da5e0fd1f64158 Mon Sep 17 00:00:00 2001 From: grs Date: Tue, 1 Sep 2026 23:01:32 +0000 Subject: [PATCH 079/505] fix(cli): continue multi-item deletes after failures (#3111) Signed-off-by: Gordon Sim --- crates/openshell-cli/src/run.rs | 61 +++++++++++++--- .../tests/provider_commands_integration.rs | 72 +++++++++++++++++++ .../sandbox_create_lifecycle_integration.rs | 43 ++++++++++- docs/sandboxes/manage-providers.mdx | 20 ++++++ docs/sandboxes/manage-sandboxes.mdx | 10 +++ 5 files changed, 196 insertions(+), 10 deletions(-) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index ebf82713e7..36cdaf01ba 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -119,6 +119,20 @@ impl ProgressOutput { } } +fn aggregate_delete_failures(resource: &str, failures: &[String]) -> Result<()> { + if failures.is_empty() { + Ok(()) + } else { + Err(miette!( + "failed to delete {} {}{}: {}", + failures.len(), + resource, + if failures.len() == 1 { "" } else { "s" }, + failures.join(", ") + )) + } +} + #[derive(Debug, Clone)] struct CurrentUserView { subject: String, @@ -2518,6 +2532,7 @@ pub async fn sandbox_delete( names.to_vec() }; + let mut failures = Vec::new(); for name in &names_to_delete { // Stop any background port forwards for this sandbox before deleting. if let Ok(stopped) = stop_forwards_for_sandbox(name) { @@ -2542,7 +2557,14 @@ pub async fn sandbox_delete( println!("{} Sandbox {name} already deleted", "✓".green().bold()); continue; } - Err(status) => return Err(status).into_diagnostic(), + Err(status) => { + eprintln!( + "{} Failed to delete sandbox {name}: {status}", + "!".red().bold() + ); + failures.push(name.clone()); + continue; + } }; let deleted = response.into_inner().deleted; @@ -2554,7 +2576,7 @@ pub async fn sandbox_delete( } } - Ok(()) + aggregate_delete_failures("sandbox", &failures) } /// Stop a sandbox while retaining its persistent workspace. @@ -4423,22 +4445,32 @@ pub async fn provider_profile_delete( tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; + let mut failures = Vec::new(); for id in ids { - let response = client + let response = match client .delete_provider_profile(DeleteProviderProfileRequest { id: id.clone(), workspace: workspace.to_string(), }) .await - .into_diagnostic()? - .into_inner(); + { + Ok(response) => response.into_inner(), + Err(status) => { + eprintln!( + "{} Failed to delete provider profile {id}: {status}", + "!".red().bold() + ); + failures.push(id.clone()); + continue; + } + }; if response.deleted { println!("{} Deleted provider profile {id}", "✓".green().bold()); } else { println!("{} Provider profile {id} not found", "!".yellow()); } } - Ok(()) + aggregate_delete_failures("provider profile", &failures) } pub async fn provider_refresh_status( @@ -5070,21 +5102,32 @@ pub async fn provider_delete( tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; + let mut failures = Vec::new(); for name in names { - let response = client + let response = match client .delete_provider(DeleteProviderRequest { name: name.clone(), workspace: workspace.to_string(), }) .await - .into_diagnostic()?; + { + Ok(response) => response, + Err(status) => { + eprintln!( + "{} Failed to delete provider {name}: {status}", + "!".red().bold() + ); + failures.push(name.clone()); + continue; + } + }; if response.into_inner().deleted { println!("{} Deleted provider {name}", "✓".green().bold()); } else { println!("{} Provider {name} not found", "!".yellow()); } } - Ok(()) + aggregate_delete_failures("provider", &failures) } // --------------------------------------------------------------------------- diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index d198c1e37a..46e3b903c0 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -51,9 +51,11 @@ struct ProviderState { provider_update_requests: Arc>>, deny_provider_reads: Arc, delete_provider_requests: Arc>>, + delete_provider_profile_requests: Arc>>, fail_configure_refresh_message: Arc>>, fail_rotate_refresh_message: Arc>>, fail_delete_provider_message: Arc>>, + fail_delete_provider_profile_message: Arc>>, sandbox_providers: Arc>>>, sandbox_provider_requests: Arc>>, global_settings: Arc>>, @@ -888,6 +890,20 @@ impl OpenShell for TestOpenShell { request: tonic::Request, ) -> Result, 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 }, @@ -1202,6 +1218,62 @@ async fn install_test_profile(ts: &TestServer, id: &str, credential_key: &str) { ); } +#[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; diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index e712e5291e..51e2159a5c 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -46,6 +46,7 @@ use tonic::{Response, Status}; struct SandboxState { deleted_names: Arc>>>, create_requests: Arc>>, + fail_delete_sandbox_message: Arc>>, vm_error_after_started: Arc, vm_error_with_observed_exit: Arc, vm_slow_progress_before_ready: Arc, @@ -209,7 +210,11 @@ impl OpenShell for TestOpenShell { .deleted_names .lock() .await - .push(vec![request.name]); + .push(vec![request.name.clone()]); + let delete_failure = self.state.fail_delete_sandbox_message.lock().await.take(); + if let Some(message) = delete_failure { + return Err(Status::internal(message)); + } Ok(Response::new(DeleteSandboxResponse { deleted: true })) } @@ -1225,6 +1230,42 @@ fn test_config() -> run::SandboxCreateConfig<'static> { } } +#[tokio::test] +async fn sandbox_delete_continues_after_entry_failure() { + let server = run_server().await; + let tls = test_tls(&server); + *server + .openshell + .state + .fail_delete_sandbox_message + .lock() + .await = Some("simulated sandbox delete failure".to_string()); + + let err = run::sandbox_delete( + &server.endpoint, + &["failing-sandbox".to_string(), "later-sandbox".to_string()], + false, + "default", + &tls, + "openshell", + ) + .await + .expect_err("sandbox delete should report aggregate failure"); + + let msg = err.to_string(); + assert!( + msg.contains("failed to delete 1 sandbox: failing-sandbox"), + "unexpected error: {msg}" + ); + assert_eq!( + deleted_names(&server).await, + vec![ + vec!["failing-sandbox".to_string()], + vec!["later-sandbox".to_string()] + ] + ); +} + #[tokio::test] async fn sandbox_create_keeps_command_sessions_by_default() { let server = run_server().await; diff --git a/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index ad162eca12..6146ab8d39 100644 --- a/docs/sandboxes/manage-providers.mdx +++ b/docs/sandboxes/manage-providers.mdx @@ -122,6 +122,16 @@ read-only. The target ID must match the profile ID in the file. Update accepts one file at a time and rejects stale resource versions. Updated profile policy applies to all provider instances of that type on the next sandbox config sync. +Delete one or more custom provider profiles by ID: + +```shell +openshell provider profile delete custom-api custom-alt +``` + +When a multi-profile delete fails for one entry, the CLI reports that profile's +failure and continues with the remaining IDs. The command exits with an error +after it attempts every requested deletion if any entry failed. + Static credentials require a built-in or imported provider profile. Profiles normally define at least one endpoint. An endpointless profile requires an @@ -277,6 +287,16 @@ Delete a provider: openshell provider delete my-claude ``` +Delete multiple providers by listing their names in one command: + +```shell +openshell provider delete my-claude my-github +``` + +When a multi-provider delete fails for one entry, the CLI reports that +provider's failure and continues with the remaining names. The command exits +with an error after it attempts every requested deletion if any entry failed. + ## Attach Providers to Sandboxes Pass one or more `--provider` flags when creating a sandbox: diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index d8abacce23..a28d653ead 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -557,6 +557,16 @@ Deleting a sandbox stops all processes, releases resources, and purges injected openshell sandbox delete my-sandbox ``` +Delete multiple sandboxes by listing their names in one command: + +```shell +openshell sandbox delete sandbox-a sandbox-b +``` + +When a multi-sandbox delete fails for one entry, the CLI reports that sandbox's +failure and continues with the remaining names. The command exits with an error +after it attempts every requested deletion if any entry failed. + ## Sandbox Lifecycle Every sandbox moves through a defined set of phases: From cc4ded20886053a8b4b3eabbbad3c70f88971be8 Mon Sep 17 00:00:00 2001 From: Dhiraj Bokde Date: Wed, 2 Sep 2026 00:23:39 +0000 Subject: [PATCH 080/505] feat(helm): split gateway and workspace charts (#2643) * feat(helm): split gateway and workspace charts Signed-off-by: Dhiraj Bokde * fix(helm): preserve split chart upgrade compatibility Keep workspace manifests valid after value validation and default legacy reused values to the combined resource topology. * fix(ci): preserve VM runtime for E2E The Rust cache restores target/ after VM runtime artifacts are staged, overwriting target/vm-runtime-compressed before openshell-driver-vm is built. Stage the compressed runtime outside target and pass that location through OPENSHELL_VM_RUNTIME_COMPRESSED_DIR so build.rs can embed the supervisor. Also locate the Helm split-ownership test repository root from the script path rather than git rev-parse. The test runs in a container where the GitHub checkout can be owned by a different UID and rejected as dubious ownership. Signed-off-by: Dhiraj Bokde * fix(ci): install yq for Helm ownership test The split-chart ownership regression uses yq to inspect rendered YAML, but the Helm CI container installs only tools declared in mise. Declare and lock yq so mise install --locked provides the test dependency. Signed-off-by: Dhiraj Bokde --------- Signed-off-by: Dhiraj Bokde --- .../skills/debug-openshell-cluster/SKILL.md | 20 ++++++ .github/actions/release-helm-oci/action.yml | 63 +++++++++++------- architecture/compute-runtimes.md | 11 ++++ deploy/helm/openshell-workspace/Chart.yaml | 9 +++ deploy/helm/openshell-workspace/README.md | 47 +++++++++++++ .../helm/openshell-workspace/README.md.gotmpl | 33 ++++++++++ .../templates/_helpers.tpl | 44 +++++++++++++ .../templates/networkpolicy.yaml | 30 +++++++++ .../openshell-workspace/templates/role.yaml | 39 +++++++++++ .../templates/rolebinding.yaml | 19 ++++++ .../templates/serviceaccount.yaml | 17 +++++ .../tests/workspace_test.yaml | 61 +++++++++++++++++ deploy/helm/openshell-workspace/values.yaml | 31 +++++++++ deploy/helm/openshell/README.md | 9 +++ deploy/helm/openshell/README.md.gotmpl | 8 +++ deploy/helm/openshell/templates/_helpers.tpl | 13 ++++ .../openshell/templates/networkpolicy.yaml | 2 +- deploy/helm/openshell/templates/role.yaml | 2 +- .../helm/openshell/templates/rolebinding.yaml | 2 +- .../openshell/templates/serviceaccount.yaml | 4 +- .../tests/sandbox_namespace_test.yaml | 18 +++++ .../tests/sandbox_service_account_test.yaml | 10 +++ deploy/helm/openshell/values.yaml | 8 +++ deploy/helm/test-split-ownership.sh | 66 +++++++++++++++++++ docs/kubernetes/setup.mdx | 31 +++++++++ mise.lock | 28 ++++++++ mise.toml | 1 + tasks/helm.toml | 29 +++++--- 28 files changed, 620 insertions(+), 35 deletions(-) create mode 100644 deploy/helm/openshell-workspace/Chart.yaml create mode 100644 deploy/helm/openshell-workspace/README.md create mode 100644 deploy/helm/openshell-workspace/README.md.gotmpl create mode 100644 deploy/helm/openshell-workspace/templates/_helpers.tpl create mode 100644 deploy/helm/openshell-workspace/templates/networkpolicy.yaml create mode 100644 deploy/helm/openshell-workspace/templates/role.yaml create mode 100644 deploy/helm/openshell-workspace/templates/rolebinding.yaml create mode 100644 deploy/helm/openshell-workspace/templates/serviceaccount.yaml create mode 100644 deploy/helm/openshell-workspace/tests/workspace_test.yaml create mode 100644 deploy/helm/openshell-workspace/values.yaml create mode 100755 deploy/helm/test-split-ownership.sh diff --git a/.agents/skills/debug-openshell-cluster/SKILL.md b/.agents/skills/debug-openshell-cluster/SKILL.md index b73d152fc7..47588b1aeb 100644 --- a/.agents/skills/debug-openshell-cluster/SKILL.md +++ b/.agents/skills/debug-openshell-cluster/SKILL.md @@ -476,6 +476,26 @@ helm -n openshell get values openshell | grep sandboxNamespace Then inspect sandbox resources in that namespace. +For a split release, the gateway values should have +`workspaceResources.enabled=false`, and the target namespace should contain a +separate `openshell-workspace` release: + +```bash +helm -n openshell get values openshell | grep -A2 workspaceResources +helm -n status openshell-workspace +kubectl -n get serviceaccount,role,rolebinding,networkpolicy \ + -l app.kubernetes.io/instance=openshell-workspace +kubectl auth can-i create sandboxes.agents.x-k8s.io \ + --namespace \ + --as system:serviceaccount:openshell:openshell +``` + +If the gateway cannot create or watch sandboxes, verify the workspace +RoleBinding subject matches the gateway ServiceAccount name and namespace. +If SSH relay connections fail, verify the workspace NetworkPolicy selects the +gateway's actual `app.kubernetes.io/name` and +`app.kubernetes.io/instance` labels. + Check the configured sandbox service account when TokenReview bootstrap or sandbox registration fails. Helm creates a dedicated sandbox service account by default and writes it to `[openshell.drivers.kubernetes].service_account_name`; diff --git a/.github/actions/release-helm-oci/action.yml b/.github/actions/release-helm-oci/action.yml index d20691ad0f..46649e0cb0 100644 --- a/.github/actions/release-helm-oci/action.yml +++ b/.github/actions/release-helm-oci/action.yml @@ -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 diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index aa97fbcb78..9f5b9230bc 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -283,6 +283,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. diff --git a/deploy/helm/openshell-workspace/Chart.yaml b/deploy/helm/openshell-workspace/Chart.yaml new file mode 100644 index 0000000000..03a3919d46 --- /dev/null +++ b/deploy/helm/openshell-workspace/Chart.yaml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: v2 +name: openshell-workspace +description: Namespace-scoped prerequisites for OpenShell Kubernetes sandboxes +type: application +version: 0.0.0 +appVersion: "0.0.0" diff --git a/deploy/helm/openshell-workspace/README.md b/deploy/helm/openshell-workspace/README.md new file mode 100644 index 0000000000..96ae4ca6e4 --- /dev/null +++ b/deploy/helm/openshell-workspace/README.md @@ -0,0 +1,47 @@ +# OpenShell Workspace Helm Chart + + + +> **Experimental** - the shared-gateway, multi-namespace deployment path is +> under active design. + +This chart installs the namespace-scoped ServiceAccount, RBAC, and NetworkPolicy +needed for OpenShell Kubernetes sandboxes. Install it once in every +platform-managed workspace namespace. It does not create a namespace or deploy +an OpenShell gateway. + +Install the gateway chart with `workspaceResources.enabled=false`, then install +this chart with the gateway ServiceAccount identity. Configure the gateway's +Kubernetes driver in `operator` workspace mode when it serves more than one +pre-provisioned workspace namespace: + +```shell +helm install openshell-workspace ./deploy/helm/openshell-workspace \ + --namespace app-a \ + --set gateway.serviceAccount.name=openshell \ + --set gateway.serviceAccount.namespace=openshell +``` + +Keep `sandboxServiceAccount.name` aligned with the gateway chart's +`sandboxServiceAccount.name`. The defaults for both charts are +`openshell-sandbox`. + +## Values + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| fullnameOverride | string | `""` | Override the full generated resource name. | +| gateway.networkPolicy.podSelector | object | `{"app.kubernetes.io/instance":"openshell","app.kubernetes.io/name":"openshell"}` | Labels selecting gateway pods allowed to reach sandbox SSH. | +| gateway.serviceAccount.name | string | `"openshell"` | Name of the shared gateway ServiceAccount. | +| gateway.serviceAccount.namespace | string | `"openshell"` | Namespace containing the shared gateway ServiceAccount. | +| nameOverride | string | `""` | Override the chart name used in generated resource names. | +| networkPolicy.enabled | bool | `true` | Restrict sandbox SSH ingress to the shared gateway pods. | +| sandboxServiceAccount.annotations | object | `{}` | Annotations added to the generated sandbox ServiceAccount. | +| sandboxServiceAccount.create | bool | `true` | Create the ServiceAccount assigned to sandbox pods. | +| sandboxServiceAccount.name | string | `"openshell-sandbox"` | Sandbox ServiceAccount name. | + +---------------------------------------------- +Autogenerated from chart metadata using [helm-docs v1.14.2](https://github.com/norwoodj/helm-docs/releases/v1.14.2) diff --git a/deploy/helm/openshell-workspace/README.md.gotmpl b/deploy/helm/openshell-workspace/README.md.gotmpl new file mode 100644 index 0000000000..a233eef165 --- /dev/null +++ b/deploy/helm/openshell-workspace/README.md.gotmpl @@ -0,0 +1,33 @@ +# OpenShell Workspace Helm Chart + + + +> **Experimental** - the shared-gateway, multi-namespace deployment path is +> under active design. + +This chart installs the namespace-scoped ServiceAccount, RBAC, and NetworkPolicy +needed for OpenShell Kubernetes sandboxes. Install it once in every +platform-managed workspace namespace. It does not create a namespace or deploy +an OpenShell gateway. + +Install the gateway chart with `workspaceResources.enabled=false`, then install +this chart with the gateway ServiceAccount identity. Configure the gateway's +Kubernetes driver in `operator` workspace mode when it serves more than one +pre-provisioned workspace namespace: + +```shell +helm install openshell-workspace ./deploy/helm/openshell-workspace \ + --namespace app-a \ + --set gateway.serviceAccount.name=openshell \ + --set gateway.serviceAccount.namespace=openshell +``` + +Keep `sandboxServiceAccount.name` aligned with the gateway chart's +`sandboxServiceAccount.name`. The defaults for both charts are +`openshell-sandbox`. + +{{ template "chart.valuesSection" . }} +{{ template "helm-docs.versionFooter" . }} diff --git a/deploy/helm/openshell-workspace/templates/_helpers.tpl b/deploy/helm/openshell-workspace/templates/_helpers.tpl new file mode 100644 index 0000000000..eb948b9907 --- /dev/null +++ b/deploy/helm/openshell-workspace/templates/_helpers.tpl @@ -0,0 +1,44 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "openshell-workspace.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "openshell-workspace.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Common labels. +*/}} +{{- define "openshell-workspace.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +app.kubernetes.io/name: {{ include "openshell-workspace.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Validate required cross-namespace gateway identity values. +*/}} +{{- define "openshell-workspace.validateValues" -}} +{{- $gatewayServiceAccountName := required "gateway.serviceAccount.name is required" .Values.gateway.serviceAccount.name -}} +{{- $gatewayServiceAccountNamespace := required "gateway.serviceAccount.namespace is required" .Values.gateway.serviceAccount.namespace -}} +{{- $sandboxServiceAccountName := required "sandboxServiceAccount.name is required" .Values.sandboxServiceAccount.name -}} +{{- end }} diff --git a/deploy/helm/openshell-workspace/templates/networkpolicy.yaml b/deploy/helm/openshell-workspace/templates/networkpolicy.yaml new file mode 100644 index 0000000000..f88cf84d04 --- /dev/null +++ b/deploy/helm/openshell-workspace/templates/networkpolicy.yaml @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +{{- include "openshell-workspace.validateValues" . }} +{{- if .Values.networkPolicy.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "openshell-workspace.fullname" . }}-sandbox-ssh + namespace: {{ .Release.Namespace }} + labels: + {{- include "openshell-workspace.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + openshell.ai/managed-by: openshell + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Values.gateway.serviceAccount.namespace }} + podSelector: + matchLabels: + {{- toYaml .Values.gateway.networkPolicy.podSelector | nindent 14 }} + ports: + - protocol: TCP + port: 2222 +{{- end }} diff --git a/deploy/helm/openshell-workspace/templates/role.yaml b/deploy/helm/openshell-workspace/templates/role.yaml new file mode 100644 index 0000000000..45b3beb831 --- /dev/null +++ b/deploy/helm/openshell-workspace/templates/role.yaml @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +{{- include "openshell-workspace.validateValues" . }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "openshell-workspace.fullname" . }}-sandbox + namespace: {{ .Release.Namespace }} + labels: + {{- include "openshell-workspace.labels" . | nindent 4 }} +rules: + - apiGroups: + - agents.x-k8s.io + resources: + - sandboxes + - sandboxes/status + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - "" + resources: + - events + verbs: + - get + - list + - watch + - apiGroups: + - "" + resources: + - pods + verbs: + - get diff --git a/deploy/helm/openshell-workspace/templates/rolebinding.yaml b/deploy/helm/openshell-workspace/templates/rolebinding.yaml new file mode 100644 index 0000000000..669ec47628 --- /dev/null +++ b/deploy/helm/openshell-workspace/templates/rolebinding.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +{{- include "openshell-workspace.validateValues" . }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "openshell-workspace.fullname" . }}-sandbox + namespace: {{ .Release.Namespace }} + labels: + {{- include "openshell-workspace.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "openshell-workspace.fullname" . }}-sandbox +subjects: + - kind: ServiceAccount + name: {{ .Values.gateway.serviceAccount.name }} + namespace: {{ .Values.gateway.serviceAccount.namespace }} diff --git a/deploy/helm/openshell-workspace/templates/serviceaccount.yaml b/deploy/helm/openshell-workspace/templates/serviceaccount.yaml new file mode 100644 index 0000000000..20bb263b54 --- /dev/null +++ b/deploy/helm/openshell-workspace/templates/serviceaccount.yaml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +{{- include "openshell-workspace.validateValues" . }} +{{- if .Values.sandboxServiceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Values.sandboxServiceAccount.name }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "openshell-workspace.labels" . | nindent 4 }} + {{- with .Values.sandboxServiceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/deploy/helm/openshell-workspace/tests/workspace_test.yaml b/deploy/helm/openshell-workspace/tests/workspace_test.yaml new file mode 100644 index 0000000000..2f71920eaf --- /dev/null +++ b/deploy/helm/openshell-workspace/tests/workspace_test.yaml @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +suite: workspace namespace resources +templates: + - templates/serviceaccount.yaml + - templates/role.yaml + - templates/rolebinding.yaml + - templates/networkpolicy.yaml +release: + name: tenant-a + namespace: app-a + +tests: + - it: creates the workspace role in the release namespace + template: templates/role.yaml + asserts: + - hasDocuments: + count: 1 + - equal: + path: metadata.namespace + value: app-a + + - it: binds the shared gateway service account + template: templates/rolebinding.yaml + set: + gateway.serviceAccount.name: shared-gateway + gateway.serviceAccount.namespace: openshell-system + asserts: + - equal: + path: metadata.namespace + value: app-a + - equal: + path: subjects[0].name + value: shared-gateway + - equal: + path: subjects[0].namespace + value: openshell-system + + - it: selects gateway pods in the gateway namespace + template: templates/networkpolicy.yaml + set: + gateway.serviceAccount.namespace: openshell-system + gateway.networkPolicy.podSelector: + app.kubernetes.io/name: openshell + app.kubernetes.io/instance: central + asserts: + - equal: + path: spec.ingress[0].from[0].namespaceSelector.matchLabels["kubernetes.io/metadata.name"] + value: openshell-system + - equal: + path: spec.ingress[0].from[0].podSelector.matchLabels["app.kubernetes.io/instance"] + value: central + + - it: supports a pre-existing sandbox service account + template: templates/serviceaccount.yaml + set: + sandboxServiceAccount.create: false + asserts: + - hasDocuments: + count: 0 diff --git a/deploy/helm/openshell-workspace/values.yaml b/deploy/helm/openshell-workspace/values.yaml new file mode 100644 index 0000000000..2c52b0460d --- /dev/null +++ b/deploy/helm/openshell-workspace/values.yaml @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# -- Override the chart name used in generated resource names. +nameOverride: "" +# -- Override the full generated resource name. +fullnameOverride: "" + +gateway: + serviceAccount: + # -- Name of the shared gateway ServiceAccount. + name: openshell + # -- Namespace containing the shared gateway ServiceAccount. + namespace: openshell + networkPolicy: + # -- Labels selecting gateway pods allowed to reach sandbox SSH. + podSelector: + app.kubernetes.io/name: openshell + app.kubernetes.io/instance: openshell + +sandboxServiceAccount: + # -- Create the ServiceAccount assigned to sandbox pods. + create: true + # -- Sandbox ServiceAccount name. + name: openshell-sandbox + # -- Annotations added to the generated sandbox ServiceAccount. + annotations: {} + +networkPolicy: + # -- Restrict sandbox SSH ingress to the shared gateway pods. + enabled: true diff --git a/deploy/helm/openshell/README.md b/deploy/helm/openshell/README.md index 9d1b9cd7dd..7dbd7964d0 100644 --- a/deploy/helm/openshell/README.md +++ b/deploy/helm/openshell/README.md @@ -9,6 +9,14 @@ Edit README.md.gotmpl and values.yaml, then run `mise run helm:docs`. This chart deploys the OpenShell gateway into a Kubernetes cluster. It is published as an OCI artifact to GHCR at `oci://ghcr.io/nvidia/openshell/helm-chart`. +By default, this chart also creates the namespace-scoped resources needed by +sandboxes. For a shared-gateway deployment, install it with +`workspaceResources.enabled=false`, then install the +`deploy/helm/openshell-workspace` chart in every pre-provisioned workspace +namespace. The gateway and workspace releases can then be upgraded and removed +independently. Use Kubernetes `operator` workspace mode when one gateway serves +multiple pre-provisioned workspace namespaces. + ## Prerequisites The Kubernetes Agent Sandbox CRDs and controller must be installed on the cluster before deploying OpenShell. Install them with: @@ -306,6 +314,7 @@ discovery endpoint or its TLS CA. | upstreamProxy.url | string | `""` | HTTP proxy URL in http://host:port form. HTTPS-to-proxy is not supported. | | workload.allowMultiReplicaStatefulSet | bool | `false` | Allow replicaCount > 1 while rendering a StatefulSet. Prefer workload.kind=deployment for external database-backed multi-replica gateways; this override exists for operators who explicitly require StatefulSet identity or storage semantics. | | workload.kind | string | `"statefulset"` | Gateway workload controller kind. Use `statefulset` for the default SQLite database, or `deployment` when server.externalDbSecret points at an external database. | +| workspaceResources.enabled | bool | `true` | Create the sandbox ServiceAccount, Role, RoleBinding, and NetworkPolicy from this chart. Disable for a gateway-only release. | ---------------------------------------------- Autogenerated from chart metadata using [helm-docs v1.14.2](https://github.com/norwoodj/helm-docs/releases/v1.14.2) diff --git a/deploy/helm/openshell/README.md.gotmpl b/deploy/helm/openshell/README.md.gotmpl index 73ebb39c88..cf8677741e 100644 --- a/deploy/helm/openshell/README.md.gotmpl +++ b/deploy/helm/openshell/README.md.gotmpl @@ -9,6 +9,14 @@ Edit README.md.gotmpl and values.yaml, then run `mise run helm:docs`. This chart deploys the OpenShell gateway into a Kubernetes cluster. It is published as an OCI artifact to GHCR at `oci://ghcr.io/nvidia/openshell/helm-chart`. +By default, this chart also creates the namespace-scoped resources needed by +sandboxes. For a shared-gateway deployment, install it with +`workspaceResources.enabled=false`, then install the +`deploy/helm/openshell-workspace` chart in every pre-provisioned workspace +namespace. The gateway and workspace releases can then be upgraded and removed +independently. Use Kubernetes `operator` workspace mode when one gateway serves +multiple pre-provisioned workspace namespaces. + ## Prerequisites The Kubernetes Agent Sandbox CRDs and controller must be installed on the cluster before deploying OpenShell. Install them with: diff --git a/deploy/helm/openshell/templates/_helpers.tpl b/deploy/helm/openshell/templates/_helpers.tpl index 5b67c018c4..3d9f2f3e0b 100644 --- a/deploy/helm/openshell/templates/_helpers.tpl +++ b/deploy/helm/openshell/templates/_helpers.tpl @@ -70,6 +70,19 @@ Create the name of the service account assigned to sandbox pods {{- end }} {{- end }} +{{/* +Whether this chart owns workspace-scoped resources. Missing legacy values +default to enabled so upgrades with --reuse-values preserve the old topology. +*/}} +{{- define "openshell.workspaceResourcesEnabled" -}} +{{- $workspaceResources := .Values.workspaceResources | default dict -}} +{{- $enabled := true -}} +{{- if hasKey $workspaceResources "enabled" -}} +{{- $enabled = get $workspaceResources "enabled" -}} +{{- end -}} +{{- if $enabled -}}true{{- end -}} +{{- end }} + {{/* Gateway image reference. Uses image.tag when set; falls back to .Chart.AppVersion so a released chart automatically pulls the matching image without extra overrides. diff --git a/deploy/helm/openshell/templates/networkpolicy.yaml b/deploy/helm/openshell/templates/networkpolicy.yaml index e85571e5f5..c9e4a760e3 100644 --- a/deploy/helm/openshell/templates/networkpolicy.yaml +++ b/deploy/helm/openshell/templates/networkpolicy.yaml @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -{{- if .Values.networkPolicy.enabled }} +{{- if and (include "openshell.workspaceResourcesEnabled" .) .Values.networkPolicy.enabled }} # NetworkPolicy restricting SSH ingress on sandbox pods to the gateway pod. # Sandbox pods are dynamically created by the server and labelled with # openshell.ai/managed-by=openshell. This policy ensures only the gateway diff --git a/deploy/helm/openshell/templates/role.yaml b/deploy/helm/openshell/templates/role.yaml index 8def13f310..dfd6423615 100644 --- a/deploy/helm/openshell/templates/role.yaml +++ b/deploy/helm/openshell/templates/role.yaml @@ -1,5 +1,5 @@ {{- $workspaceMode := .Values.server.drivers.kubernetes.workspaceMode | default "shared" -}} -{{- if eq $workspaceMode "shared" }} +{{- if and (eq $workspaceMode "shared") (include "openshell.workspaceResourcesEnabled" .) }} # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 apiVersion: rbac.authorization.k8s.io/v1 diff --git a/deploy/helm/openshell/templates/rolebinding.yaml b/deploy/helm/openshell/templates/rolebinding.yaml index 381473a58b..32f11644bf 100644 --- a/deploy/helm/openshell/templates/rolebinding.yaml +++ b/deploy/helm/openshell/templates/rolebinding.yaml @@ -1,5 +1,5 @@ {{- $workspaceMode := .Values.server.drivers.kubernetes.workspaceMode | default "shared" -}} -{{- if eq $workspaceMode "shared" }} +{{- if and (eq $workspaceMode "shared") (include "openshell.workspaceResourcesEnabled" .) }} # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 apiVersion: rbac.authorization.k8s.io/v1 diff --git a/deploy/helm/openshell/templates/serviceaccount.yaml b/deploy/helm/openshell/templates/serviceaccount.yaml index a98ad5363e..1a9245d4dc 100644 --- a/deploy/helm/openshell/templates/serviceaccount.yaml +++ b/deploy/helm/openshell/templates/serviceaccount.yaml @@ -13,10 +13,10 @@ metadata: {{- toYaml . | nindent 4 }} {{- end }} {{- end }} -{{- if and .Values.serviceAccount.create .Values.sandboxServiceAccount.create }} +{{- if and .Values.serviceAccount.create (include "openshell.workspaceResourcesEnabled" .) .Values.sandboxServiceAccount.create }} --- {{- end }} -{{- if .Values.sandboxServiceAccount.create }} +{{- if and (include "openshell.workspaceResourcesEnabled" .) .Values.sandboxServiceAccount.create }} apiVersion: v1 kind: ServiceAccount metadata: diff --git a/deploy/helm/openshell/tests/sandbox_namespace_test.yaml b/deploy/helm/openshell/tests/sandbox_namespace_test.yaml index ee89fce53d..864e3a8512 100644 --- a/deploy/helm/openshell/tests/sandbox_namespace_test.yaml +++ b/deploy/helm/openshell/tests/sandbox_namespace_test.yaml @@ -75,3 +75,21 @@ tests: path: metadata.namespace value: other-ns documentIndex: 1 + + - it: omits workspace resources in gateway-only mode + set: + workspaceResources.enabled: false + networkPolicy.enabled: true + asserts: + - hasDocuments: + count: 1 + template: templates/gateway-config.yaml + - hasDocuments: + count: 0 + template: templates/networkpolicy.yaml + - hasDocuments: + count: 0 + template: templates/role.yaml + - hasDocuments: + count: 0 + template: templates/rolebinding.yaml diff --git a/deploy/helm/openshell/tests/sandbox_service_account_test.yaml b/deploy/helm/openshell/tests/sandbox_service_account_test.yaml index c426415823..c9f10868fe 100644 --- a/deploy/helm/openshell/tests/sandbox_service_account_test.yaml +++ b/deploy/helm/openshell/tests/sandbox_service_account_test.yaml @@ -29,3 +29,13 @@ tests: asserts: - hasDocuments: count: 1 + + - it: renders only the gateway service account in gateway-only mode + set: + workspaceResources.enabled: false + asserts: + - hasDocuments: + count: 1 + - equal: + path: metadata.name + value: openshell diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index 25236eb474..8f5b6fa51a 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -102,6 +102,14 @@ sandboxServiceAccount: # -- Existing service account name for sandbox pods when sandboxServiceAccount.create is false. name: "" +# Namespace-scoped resources needed to run sandboxes. Disable this when the +# gateway and workspace prerequisites are managed as separate Helm releases +# using the openshell-workspace chart. +workspaceResources: + # -- Create the sandbox ServiceAccount, Role, RoleBinding, and NetworkPolicy + # from this chart. Disable for a gateway-only release. + enabled: true + # -- Extra annotations to add to the gateway pod. podAnnotations: {} # -- Extra labels to add to the gateway pod. diff --git a/deploy/helm/test-split-ownership.sh b/deploy/helm/test-split-ownership.sh new file mode 100755 index 0000000000..30fd7360d4 --- /dev/null +++ b/deploy/helm/test-split-ownership.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" +work_dir="$(mktemp -d)" +trap 'rm -rf "${work_dir}"' EXIT + +helm template openshell "${repo_root}/deploy/helm/openshell" \ + --namespace openshell \ + --set agentSandbox.preflight.enabled=false \ + --set workspaceResources.enabled=false \ + >"${work_dir}/gateway.yaml" + +if yq ea -e \ + 'select(.kind == "Role" and .metadata.name == "openshell-sandbox")' \ + "${work_dir}/gateway.yaml" >/dev/null 2>&1; then + echo "gateway chart rendered workspace resources despite workspaceResources.enabled=false" >&2 + exit 1 +fi + +helm template openshell-workspace "${repo_root}/deploy/helm/openshell-workspace" \ + --namespace app-a \ + --set gateway.serviceAccount.name=openshell \ + --set gateway.serviceAccount.namespace=openshell \ + >"${work_dir}/workspace.yaml" + +invalid_workspace_docs="$( + yq ea -N -r \ + 'select(. != null and (.apiVersion == null or .kind == null)) | document_index' \ + "${work_dir}/workspace.yaml" +)" +if [[ -n "${invalid_workspace_docs}" ]]; then + echo "workspace chart rendered documents without apiVersion or kind: ${invalid_workspace_docs}" >&2 + exit 1 +fi + +helm template openshell "${repo_root}/deploy/helm/openshell" \ + --namespace openshell \ + --set agentSandbox.preflight.enabled=false \ + --set-json workspaceResources=null \ + >"${work_dir}/legacy-reuse-values.yaml" + +yq ea -e \ + 'select(.kind == "Role" and .metadata.name == "openshell-sandbox") | + .apiVersion == "rbac.authorization.k8s.io/v1"' \ + "${work_dir}/legacy-reuse-values.yaml" >/dev/null + +yq ea -N -r \ + 'select(.kind != null) | [.apiVersion, .kind, (.metadata.namespace // "openshell"), .metadata.name] | @tsv' \ + "${work_dir}/gateway.yaml" | sort -u >"${work_dir}/gateway.objects" +yq ea -N -r \ + 'select(.kind != null) | [.apiVersion, .kind, (.metadata.namespace // "app-a"), .metadata.name] | @tsv' \ + "${work_dir}/workspace.yaml" | sort -u >"${work_dir}/workspace.objects" + +comm -12 "${work_dir}/gateway.objects" "${work_dir}/workspace.objects" \ + >"${work_dir}/overlap.objects" +if [[ -s "${work_dir}/overlap.objects" ]]; then + echo "gateway and workspace charts claim the same Kubernetes objects:" >&2 + cat "${work_dir}/overlap.objects" >&2 + exit 1 +fi + +echo "gateway and workspace chart object ownership is disjoint" diff --git a/docs/kubernetes/setup.mdx b/docs/kubernetes/setup.mdx index 221f935eb6..fb7881af2d 100644 --- a/docs/kubernetes/setup.mdx +++ b/docs/kubernetes/setup.mdx @@ -99,6 +99,36 @@ helm upgrade --install openshell \ The chart automatically generates PKI secrets on first install using pre-install Helm hooks. No manual secret creation is required. +### Split gateway and workspace releases + +For a platform-managed namespace, install the gateway without namespace-scoped +sandbox resources, then install the workspace chart in the sandbox namespace: + +```shell +helm upgrade --install openshell \ + oci://ghcr.io/nvidia/openshell/helm-chart \ + --version \ + --namespace openshell \ + --set workspaceResources.enabled=false \ + --set server.sandboxNamespace=app-a + +helm upgrade --install openshell-workspace \ + oci://ghcr.io/nvidia/openshell/openshell-workspace \ + --version \ + --namespace app-a \ + --set gateway.serviceAccount.name=openshell \ + --set gateway.serviceAccount.namespace=openshell +``` + +The workspace chart does not create the namespace or deploy a gateway. It owns +only the sandbox ServiceAccount, Role, RoleBinding, and NetworkPolicy in its +release namespace. For one pre-provisioned namespace, keep +`server.drivers.kubernetes.workspaceMode=shared` and set +`server.sandboxNamespace=app-a`. To map multiple workspaces to separately +provisioned namespaces, use `workspaceMode=operator`, configure exactly one of +`operatorNamespaceLabel` or `operatorNamespaceFile`, and install the workspace +chart in every allowlisted namespace. + ## Wait for the gateway to be ready ```shell @@ -163,6 +193,7 @@ The most commonly changed values are: | `workload.kind` | Gateway workload controller. Use `statefulset` for SQLite or `deployment` with `server.externalDbSecret`. | | `workload.allowMultiReplicaStatefulSet` | Allow `replicaCount > 1` with `workload.kind=statefulset`. Prefer Deployment for external database-backed multi-replica gateways. | | `server.sandboxNamespace` | Namespace where sandbox pods are created. Defaults to the Helm release namespace when left empty. | +| `workspaceResources.enabled` | Create namespace-scoped sandbox prerequisites from the gateway chart. Disable when installing the workspace chart separately. | | `server.externalDbSecret` | Secret containing a PostgreSQL connection URI in the `uri` key. Use when the database is managed outside the chart. | | `server.telemetryEnabled` | Enable anonymous OpenShell telemetry from the gateway and its sandbox supervisors. Set to `false` to opt out. | | `server.sandboxImage` | Default sandbox image used when a sandbox does not specify one. | diff --git a/mise.lock b/mise.lock index 23ce8b24e0..3a57cbf0e0 100644 --- a/mise.lock +++ b/mise.lock @@ -438,6 +438,34 @@ url = "https://github.com/astral-sh/uv/releases/download/0.10.12/uv-x86_64-pc-wi url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/377491992" provenance = "github-attestations" +[[tools.yq]] +version = "4.53.6" +backend = "aqua:mikefarah/yq" + +[tools.yq."platforms.linux-arm64"] +checksum = "sha256:88a1016bc1d657375a35864e4f44b6f333df8ff97b559f51bba0adcb2169df09" +url = "https://github.com/mikefarah/yq/releases/download/v4.53.6/yq_linux_arm64" +url_api = "https://api.github.com/repos/mikefarah/yq/releases/assets/522028007" +provenance = "cosign" + +[tools.yq."platforms.linux-x64"] +checksum = "sha256:c5f056448f973ae7d39b5401949648a78f2dc1947d6a8eb65be60d5c504b9385" +url = "https://github.com/mikefarah/yq/releases/download/v4.53.6/yq_linux_amd64" +url_api = "https://api.github.com/repos/mikefarah/yq/releases/assets/522028022" +provenance = "cosign" + +[tools.yq."platforms.macos-arm64"] +checksum = "sha256:cceb0b8d71ea5294334121f8429f33f92b920e7217d904a2f9f35443968ac424" +url = "https://github.com/mikefarah/yq/releases/download/v4.53.6/yq_darwin_arm64" +url_api = "https://api.github.com/repos/mikefarah/yq/releases/assets/522028033" +provenance = "cosign" + +[tools.yq."platforms.windows-x64"] +checksum = "sha256:ece3dd8bb50d39f93610506273ea262feb91e5c486bbddbb10abf91b2a6c0f14" +url = "https://github.com/mikefarah/yq/releases/download/v4.53.6/yq_windows_amd64.exe" +url_api = "https://api.github.com/repos/mikefarah/yq/releases/assets/522027974" +provenance = "cosign" + [[tools.zig]] version = "0.14.1" backend = "core:zig" diff --git a/mise.toml b/mise.toml index ec643ed08e..bd36408b80 100644 --- a/mise.toml +++ b/mise.toml @@ -33,6 +33,7 @@ go = "1.26" buf = "1.72.0" helm = { version = "4.2.0", version_prefix = "v" } helm-docs = "1.14.2" +yq = "4.53.6" skaffold = { version = "2.20.0", os = ["linux", "macos"], version_prefix = "v" } # Keep k3d out of Linux CI images until upstream ships a release rebuilt with # patched Go/container dependencies. Linux Kubernetes E2E uses kind or an diff --git a/tasks/helm.toml b/tasks/helm.toml index f7db8f9c57..525e9c2e51 100644 --- a/tasks/helm.toml +++ b/tasks/helm.toml @@ -4,27 +4,36 @@ # Helm chart tasks ["helm:docs"] -description = "Generate the openshell Helm chart README from Chart.yaml, values.yaml, and README.md.gotmpl" -run = "helm-docs --chart-search-root deploy/helm/openshell" +description = "Generate the OpenShell gateway and workspace Helm chart READMEs" +run = """ + helm-docs --chart-search-root deploy/helm/openshell + helm-docs --chart-search-root deploy/helm/openshell-workspace +""" ["helm:docs:check"] description = "Verify the openshell Helm chart README is generated and up to date" run = """ set -e - tmp="$(mktemp)" - trap 'rm -f "$tmp"' EXIT + gateway_tmp="$(mktemp)" + workspace_tmp="$(mktemp)" + trap 'rm -f "$gateway_tmp" "$workspace_tmp"' EXIT - helm-docs --chart-search-root deploy/helm/openshell --dry-run > "$tmp" - if ! diff -u deploy/helm/openshell/README.md "$tmp"; then + helm-docs --chart-search-root deploy/helm/openshell --dry-run > "$gateway_tmp" + if ! diff -u deploy/helm/openshell/README.md "$gateway_tmp"; then echo "Helm chart README is out of sync. Run: mise run helm:docs" >&2 exit 1 fi + helm-docs --chart-search-root deploy/helm/openshell-workspace --dry-run > "$workspace_tmp" + if ! diff -u deploy/helm/openshell-workspace/README.md "$workspace_tmp"; then + echo "Workspace Helm chart README is out of sync. Run: mise run helm:docs" >&2 + exit 1 + fi """ run_windows = "echo Skipping helm:docs:check: Helm validation is not part of the native Windows lane." hide = true ["helm:lint"] -description = "Lint the openshell Helm chart (defaults + all CI configuration variants)" +description = "Lint the OpenShell gateway and workspace Helm charts" run = """ set -e helm dependency build deploy/helm/openshell @@ -37,12 +46,14 @@ run = """ echo "values files: deploy/helm/openshell/values.yaml, $f" helm lint deploy/helm/openshell -f "$f" --set agentSandbox.preflight.enabled=false done + echo "--- helm lint: workspace defaults ---" + helm lint deploy/helm/openshell-workspace echo "All variants passed." """ run_windows = "echo Skipping helm:lint: Helm validation is not part of the native Windows lane." ["helm:test"] -description = "Run Helm chart unit tests" +description = "Run gateway and workspace Helm chart unit tests" run = """ set -e if ! helm plugin list | grep -q unittest; then @@ -50,6 +61,8 @@ run = """ fi helm dependency build deploy/helm/openshell helm unittest deploy/helm/openshell + helm unittest deploy/helm/openshell-workspace + deploy/helm/test-split-ownership.sh """ run_windows = "echo Skipping helm:test: Helm validation is not part of the native Windows lane." From 3c30abcc6a65b0f9b15b35a693c8add29886f8ff Mon Sep 17 00:00:00 2001 From: "John T. Myers" <9696606+johntmyers@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:47:29 +0000 Subject: [PATCH 081/505] fix(tui): expose workspace switching from providers (#3115) Closes #3112 Reuse the dashboard workspace cycle action from the providers pane, advertise the shortcut, and cover the state transition and rendered hint. Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- .agents/skills/tui-development/SKILL.md | 4 +-- crates/openshell-tui/src/app.rs | 29 ++++++++++++++++ crates/openshell-tui/src/ui/mod.rs | 46 +++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/.agents/skills/tui-development/SKILL.md b/.agents/skills/tui-development/SKILL.md index 5d61af6ecd..f570f283f0 100644 --- a/.agents/skills/tui-development/SKILL.md +++ b/.agents/skills/tui-development/SKILL.md @@ -384,7 +384,7 @@ All actions are accessible via keyboard shortcuts displayed in the nav bar. The `[Tab] Switch Panel [Enter] Select [j/k] Navigate │ [:] Command [q] Quit` **Dashboard (Providers focus):** -`[Tab] Switch Panel [h/l] Switch Tab [j/k] Navigate [Enter] Detail [c] Create [u] Update [d] Delete │ [:] Command [q] Quit` +`[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` @@ -535,7 +535,7 @@ On launch, before the event loop starts: ### 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 diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 13a00aaca3..cf7464fcd8 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -1407,6 +1407,9 @@ impl App { self.input_mode = InputMode::Command; self.command_input.clear(); } + KeyCode::Char('w') => { + self.cycle_workspace(); + } KeyCode::Char('j') | KeyCode::Down => { if self.provider_count > 0 && self.provider_selected < self.provider_count - 1 { self.provider_selected += 1; @@ -3825,6 +3828,32 @@ mod tests { assert_eq!(gateway.source_label(), "unknown"); } + #[tokio::test] + async fn providers_workspace_shortcut_cycles_scope_and_resets_selections() { + let mut app = test_app(); + app.screen = Screen::Dashboard; + app.focus = Focus::Providers; + app.workspace_names = vec!["default".to_string(), "team-b".to_string()]; + app.provider_selected = 3; + app.sandbox_selected = 4; + + app.handle_key(key(KeyCode::Char('w'))); + + assert_eq!(app.current_workspace, "team-b"); + assert!(!app.all_workspaces); + assert_eq!(app.provider_selected, 0); + assert_eq!(app.sandbox_selected, 0); + assert!(app.pending_workspace_refresh); + + app.handle_key(key(KeyCode::Char('w'))); + assert!(app.all_workspaces); + assert_eq!(app.workspace_display(), "all"); + + app.handle_key(key(KeyCode::Char('w'))); + assert!(!app.all_workspaces); + assert_eq!(app.current_workspace, "default"); + } + // -- selected_sandbox_workspace ---------------------------------------- #[test] diff --git a/crates/openshell-tui/src/ui/mod.rs b/crates/openshell-tui/src/ui/mod.rs index 7ad1589186..01171fdb5d 100644 --- a/crates/openshell-tui/src/ui/mod.rs +++ b/crates/openshell-tui/src/ui/mod.rs @@ -223,6 +223,9 @@ fn draw_nav_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { Span::styled(" ", t.text), Span::styled("[c/u/d]", t.key_hint), Span::styled(" Create/Update/Delete", t.text), + Span::styled(" ", t.text), + Span::styled("[w]", t.key_hint), + Span::styled(" Workspace", t.text), Span::styled(" | ", t.border), Span::styled("[:]", t.muted), Span::styled(" Command ", t.muted), @@ -662,3 +665,46 @@ pub fn centered_popup(percent_x: u16, height: u16, area: Rect) -> Rect { ]) .split(vert[1])[1] } + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::auth::EdgeAuthInterceptor; + use openshell_core::proto::open_shell_client::OpenShellClient; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + fn test_app() -> App { + let channel = tonic::transport::Endpoint::from_static("http://127.0.0.1:1").connect_lazy(); + let client = OpenShellClient::with_interceptor(channel, EdgeAuthInterceptor::noop()); + let mut app = App::new( + client, + "test".to_string(), + "http://127.0.0.1:1".to_string(), + "default".to_string(), + Theme::dark(), + ); + app.screen = Screen::Dashboard; + app.focus = Focus::Providers; + app + } + + #[tokio::test] + async fn providers_navigation_advertises_workspace_shortcut() { + let app = test_app(); + let mut terminal = Terminal::new(TestBackend::new(180, 1)).unwrap(); + + terminal + .draw(|frame| draw_nav_bar(frame, &app, frame.size())) + .unwrap(); + + let text: String = terminal + .backend() + .buffer() + .content() + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect(); + assert!(text.contains("[w] Workspace"), "nav bar was: {text:?}"); + } +} From 2aa6a5f4200fb7b5e155491f223f4f9a9512d9df Mon Sep 17 00:00:00 2001 From: "John T. Myers" <9696606+johntmyers@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:48:01 +0000 Subject: [PATCH 082/505] chore(gator): adopt authoritative provider profiles (#3108) Signed-off-by: John Myers Co-authored-by: John Myers --- scripts/agents/gator/agent.yaml | 2 +- scripts/agents/gator/skills/gator-gate/SKILL.md | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/scripts/agents/gator/agent.yaml b/scripts/agents/gator/agent.yaml index 363cea207f..dc225669c9 100644 --- a/scripts/agents/gator/agent.yaml +++ b/scripts/agents/gator/agent.yaml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 id: gator -payload_version: 7 +payload_version: 8 display_name: Gator Gate Agent description: Validate and monitor OpenShell GitHub issues and pull requests through the gator state machine. diff --git a/scripts/agents/gator/skills/gator-gate/SKILL.md b/scripts/agents/gator/skills/gator-gate/SKILL.md index 99ab8304f5..d66d5029ea 100644 --- a/scripts/agents/gator/skills/gator-gate/SKILL.md +++ b/scripts/agents/gator/skills/gator-gate/SKILL.md @@ -531,9 +531,9 @@ Validate small and concentrated work when it has clear motivation and one of the Documentation changes from non-maintainers must not reorder ToC items, change fundamental hierarchy, or restructure docs without a clear maintainer-approved reason. -### Provider V2 and Credential Support +### Provider Profiles and Credential Support -Provider V2 work is a supported high-traction area, but require all of the following: +Provider-profile work is a supported high-traction area, but requires all of the following: - Clear UX path for how users configure and use the provider feature in OpenShell - Clear statement of why the change is important @@ -541,7 +541,13 @@ Provider V2 work is a supported high-traction area, but require all of the follo - Security boundary analysis for credential handling - Explanation of whether secrets remain hidden from the sandbox agent -Provider additions and updates must use providers v2 through provider profiles. Treat any new or modified legacy `ProviderDiscoverySpec` entries as a blocking review finding unless a maintainer explicitly requests the legacy path. Do not ask contributors to update both systems for compatibility; the provider profile is the source of truth for new provider network policy, credentials, discovery, and refresh metadata. +Provider profiles are the authoritative provider model. Provider additions and +updates must define their network policy, credentials, discovery, refresh +metadata, and user-facing semantics through a built-in or imported profile. +Treat any new or modified legacy `ProviderDiscoverySpec` entries as a blocking +review finding unless a maintainer explicitly requests the legacy path. Do not +ask contributors to update both systems for compatibility or to enable a +gateway feature switch; profile-backed behavior is always active. Be skeptical of changes that expose raw credentials to agents or weaken the credential proxy model, even if the user story is clear. From 74960ebfaeec4673885089ed995fad902459749f Mon Sep 17 00:00:00 2001 From: grs Date: Wed, 2 Sep 2026 00:55:29 +0000 Subject: [PATCH 083/505] feat(server): add sandbox templates (#2833) * feat(server): add sandbox workload templates Signed-off-by: Gordon Sim * feat(go-sdk): add sandbox workload template support Signed-off-by: Gordon Sim * feat(rust-sdk): add sandbox workload template support Signed-off-by: Gordon Sim * feat(python-sdk): add sandbox workload template support Signed-off-by: Gordon Sim * feat(typescript-sdk): add sandbox workload template support Signed-off-by: Gordon Sim * docs(agents): document sandbox workload templates Signed-off-by: Gordon Sim * fix(cli): support default GPU requests in sandbox templates Signed-off-by: Gordon Sim * fix(server): cap sandbox templates per workspace Signed-off-by: Gordon Sim * docs(architecture): document sandbox workload template boundaries Signed-off-by: Gordon Sim * feat(cli+sdk): expose sandbox workload template provenance Signed-off-by: Gordon Sim * fix(cli): include sandbox template annotations in output Signed-off-by: Gordon Sim * feat(sandbox): add label selectors to template listing Signed-off-by: Gordon Sim * test(e2e): cover sandbox template failure paths Signed-off-by: Gordon Sim * fix(server): preserve command and ttl when creating sandbox from template Signed-off-by: Gordon Sim * test(server): add field coverage test for template merge Signed-off-by: Gordon Sim * fix(go-sdk): add pagination support to fake client Signed-off-by: Gordon Sim * fix(cli): warn on env vars that looks like secrets Signed-off-by: Gordon Sim * fix(sdk-ts): propagate sandbox workspace through lifecycle calls Signed-off-by: Gordon Sim * fix(docs): update workspace management docs Signed-off-by: Gordon Sim * fix(ts-sdk): support command and tty when creating from template Signed-off-by: Gordon Sim * fix(go-sdk): support command and tty when creating from template Signed-off-by: Gordon Sim * test(python-sdk): verify command and tty handling when creating from template Signed-off-by: Gordon Sim * fix(go-sdk): update docs and ClientInterface Signed-off-by: Gordon Sim * fix(server): validate sandbox create specs before I/O Signed-off-by: Gordon Sim * fix(go-sdk): guard empty DNS-1123 label validation Signed-off-by: Gordon Sim * fix(python-sdk): allow empty template builder mappings Signed-off-by: Gordon Sim * fix(cli): align template GPU JSON default output Signed-off-by: Gordon Sim --------- Signed-off-by: Gordon Sim --- .agents/skills/openshell-cli/SKILL.md | 44 +- .agents/skills/openshell-cli/cli-reference.md | 65 +- architecture/compute-runtimes.md | 10 + architecture/gateway.md | 44 +- architecture/sandbox-limits.md | 9 + architecture/sandbox.md | 6 + crates/openshell-cli/src/main.rs | 428 ++ crates/openshell-cli/src/run.rs | 788 +++- .../tests/ensure_providers_integration.rs | 2 + crates/openshell-cli/tests/helpers/mod.rs | 89 + .../openshell-cli/tests/mtls_integration.rs | 2 + .../tests/provider_commands_integration.rs | 3 + .../sandbox_create_lifecycle_integration.rs | 476 ++- .../sandbox_name_fallback_integration.rs | 2 + crates/openshell-core/src/metadata.rs | 48 +- crates/openshell-core/src/telemetry.rs | 2 + .../src/proto_json.rs | 1 + .../src/runtime.rs | 1 + crates/openshell-sdk/README.md | 52 +- crates/openshell-sdk/src/client.rs | 209 +- crates/openshell-sdk/src/lib.rs | 9 +- crates/openshell-sdk/src/raw.rs | 14 +- crates/openshell-sdk/src/types.rs | 71 + crates/openshell-sdk/tests/client_mock.rs | 269 +- crates/openshell-server/src/compute/mod.rs | 8 +- crates/openshell-server/src/grpc/mod.rs | 76 +- crates/openshell-server/src/grpc/provider.rs | 2 + crates/openshell-server/src/grpc/sandbox.rs | 1536 +++++++- .../openshell-server/src/grpc/validation.rs | 63 +- crates/openshell-server/src/grpc/workspace.rs | 73 +- .../openshell-server/src/persistence/mod.rs | 33 + .../src/persistence/postgres.rs | 67 + .../src/persistence/sqlite.rs | 61 + .../openshell-server/src/persistence/tests.rs | 4 + crates/openshell-server/tests/common/mod.rs | 28 + .../tests/supervisor_relay_integration.rs | 28 + crates/openshell-tui/src/lib.rs | 1 + docs/sandboxes/manage-sandboxes.mdx | 71 + docs/sandboxes/manage-workspaces.mdx | 17 +- e2e/rust/Cargo.toml | 5 + e2e/rust/tests/sandbox_templates.rs | 275 ++ proto/openshell.proto | 149 +- python/openshell/__init__.py | 4 + python/openshell/openshell_test.py | 4 + python/openshell/sandbox.py | 248 +- python/openshell/sandbox_test.py | 402 ++ sdk/go/docs/src/SUMMARY.md | 1 + sdk/go/docs/src/api/client.md | 7 +- sdk/go/docs/src/api/fake.md | 11 +- sdk/go/docs/src/api/overview.md | 5 +- sdk/go/docs/src/api/sandbox-templates.md | 127 + sdk/go/docs/src/api/sandboxes.md | 26 + sdk/go/docs/src/introduction.md | 2 +- sdk/go/openshell/v1/client.go | 43 +- sdk/go/openshell/v1/fake/fake.go | 53 +- sdk/go/openshell/v1/fake/sandbox.go | 134 +- sdk/go/openshell/v1/fake/sandbox_template.go | 301 ++ .../v1/fake/sandbox_template_test.go | 481 +++ sdk/go/openshell/v1/fake/sandbox_test.go | 41 +- .../v1/internal/converter/coverage_test.go | 56 + .../v1/internal/converter/sandbox.go | 236 +- .../v1/internal/converter/sandbox_test.go | 158 + sdk/go/openshell/v1/sandbox.go | 6 + sdk/go/openshell/v1/sandbox_client.go | 41 + sdk/go/openshell/v1/sandbox_client_test.go | 66 + sdk/go/openshell/v1/sandbox_template.go | 45 + .../openshell/v1/sandbox_template_client.go | 94 + .../v1/sandbox_template_client_test.go | 265 ++ sdk/go/openshell/v1/types/sandbox.go | 84 +- sdk/go/proto/openshellv1/openshell.pb.go | 3444 +++++++++++------ sdk/go/proto/openshellv1/openshell_grpc.pb.go | 160 + sdk/typescript/README.md | 43 + sdk/typescript/src/client.test.ts | 366 +- sdk/typescript/src/client.ts | 269 +- sdk/typescript/src/index.ts | 12 +- 75 files changed, 10793 insertions(+), 1583 deletions(-) create mode 100644 e2e/rust/tests/sandbox_templates.rs create mode 100644 sdk/go/docs/src/api/sandbox-templates.md create mode 100644 sdk/go/openshell/v1/fake/sandbox_template.go create mode 100644 sdk/go/openshell/v1/fake/sandbox_template_test.go create mode 100644 sdk/go/openshell/v1/sandbox_template.go create mode 100644 sdk/go/openshell/v1/sandbox_template_client.go create mode 100644 sdk/go/openshell/v1/sandbox_template_client_test.go diff --git a/.agents/skills/openshell-cli/SKILL.md b/.agents/skills/openshell-cli/SKILL.md index c0be7fc730..f268527956 100644 --- a/.agents/skills/openshell-cli/SKILL.md +++ b/.agents/skills/openshell-cli/SKILL.md @@ -242,6 +242,7 @@ Key flags: - `--gpu [COUNT]`: Request the driver's default GPU selection or a specific GPU count - `--cpu`, `--memory`: Set per-sandbox compute sizing. Docker/Podman apply limits; Kubernetes applies matching requests and limits. - `--driver-config-json`: Pass experimental driver-specific sandbox configuration +- `--template NAME`: Create from a named sandbox workload template. Conflicts with inline workload flags such as `--from`, `--gpu`, `--cpu`, `--memory`, `--env`, and `--driver-config-json`. - `--label KEY=VALUE`: Add labels for later selection (repeatable) - `--env KEY=VALUE`: Set non-secret sandbox environment variables (repeatable); use `--provider` for credentials - `--tty`: Allocate a retained PTY for the canonical main process @@ -262,6 +263,45 @@ Do not combine `--upload` with a trailing main command. Uploads currently finish after the canonical process starts; create a scratch sandbox and use `sandbox exec`, or build the files into the image. +Create from a reusable workload template when several sandboxes should share +image, environment, sizing, or driver-specific configuration: + +```bash +openshell sandbox template create gpu-kata \ + --image ghcr.io/nvidia/openshell-community/sandboxes/python:latest \ + --cpu 2 \ + --memory 4Gi \ + --gpu 1 \ + --driver-config-json '{"kubernetes":{"pod":{"node_selector":{"pool":"gpu"}}}}' + +openshell sandbox create --name my-sandbox --template gpu-kata --provider my-github -- claude +``` + +Direct `sandbox create --driver-config-json` remains valid for one-off +creates. Put driver config on a template only when it should be reused. + +### Manage sandbox workload templates + +```bash +openshell sandbox template create gpu-kata \ + --image ghcr.io/nvidia/openshell-community/sandboxes/python:latest \ + --cpu 2 \ + --memory 4Gi \ + --gpu 1 \ + --label team=runtime \ + --env FEATURE_FLAG=on +openshell sandbox template list +openshell sandbox template list --label-selector team=runtime +openshell sandbox template list --all-workspaces --output json +openshell sandbox template get gpu-kata +openshell sandbox template delete gpu-kata +``` + +Template `--image` accepts an OCI image reference. If omitted, the gateway +applies its default sandbox image when creating a sandbox from the template. +Create-time policy, providers, labels, uploads, forwarding, editor launch, and +the initial command stay on `sandbox create`. + ### List and inspect sandboxes ```bash @@ -772,7 +812,7 @@ The CLI help is always authoritative. If the help output contradicts this skill, ```bash $ openshell sandbox --help -# Shows: create, get, list, stop, start, delete, exec, connect, upload, download, ssh-config, provider +# Shows: create, get, list, stop, start, delete, exec, connect, upload, download, ssh-config, provider, template $ openshell sandbox upload --help # Shows: positional arguments (name, path, dest), usage examples @@ -793,6 +833,8 @@ $ openshell sandbox upload --help | Create sandbox with tool | `openshell sandbox create -- claude` | | Create sandbox with GPUs | `openshell sandbox create --gpu 1` | | Create with custom policy | `openshell sandbox create --policy ./p.yaml` | +| Create from template | `openshell sandbox create --template gpu-kata` | +| Create workload template | `openshell sandbox template create gpu-kata --image python:3.12` | | Connect to sandbox | `openshell sandbox connect ` | | Stop sandbox compute | `openshell sandbox stop [name]` | | Start sandbox compute | `openshell sandbox start [name]` | diff --git a/.agents/skills/openshell-cli/cli-reference.md b/.agents/skills/openshell-cli/cli-reference.md index a3dc91c958..2f1065c234 100644 --- a/.agents/skills/openshell-cli/cli-reference.md +++ b/.agents/skills/openshell-cli/cli-reference.md @@ -60,10 +60,15 @@ openshell │ ├── upload [dest] │ ├── download [dest] │ ├── ssh-config [name] -│ └── provider -│ ├── list [name] [-o table|yaml|json] -│ ├── attach -│ └── detach +│ ├── provider +│ │ ├── list [name] [-o table|yaml|json] +│ │ ├── attach +│ │ └── detach +│ └── template +│ ├── create [opts] +│ ├── get +│ ├── list [opts] +│ └── delete ... ├── forward │ ├── start [name] [-d] │ ├── stop [name] @@ -243,6 +248,7 @@ 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 ` | Experimental driver-keyed configuration object | +| `--template ` | Create from a named sandbox workload template | | `--provider ` | Provider to attach (repeatable) | | `--policy ` | 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 | @@ -261,6 +267,57 @@ 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. +`--template` uses the named template workload, so it conflicts with inline +workload flags: `--from`, `--gpu`, `--cpu`, `--memory`, `--env`, and +`--driver-config-json`. Direct `sandbox create --driver-config-json` remains +valid when `--template` is not set. + +### `openshell sandbox template create NAME [OPTIONS]` + +Create a reusable sandbox workload template. Templates hold image, +environment, resource, startup, and driver-specific configuration for later +`sandbox create --template NAME` calls. + +| Flag | Description | +|------|-------------| +| `--image ` | OCI image reference; when omitted, the gateway default image is applied at sandbox create time | +| `--env ` | Set a non-secret template workload environment variable (repeatable) | +| `--cpu ` | CPU limit for sandboxes created from the template | +| `--memory ` | Memory limit for sandboxes created from the template | +| `--gpu [COUNT]` | Request the driver's default GPU selection or a specific GPU count for sandboxes created from the template | +| `--driver-config-json ` | Experimental driver-keyed configuration object owned by the template | +| `--ready-within ` | Target startup readiness duration, for example `30s`, `5m`, or `1h` | +| `--max-burst ` | Maximum startup burst associated with this template | +| `--label ` | Attach a template label (repeatable) | +| `--annotation ` | Attach a template annotation (repeatable) | +| `--output table|yaml|json` | Output format | + +### `openshell sandbox template get NAME` + +Show a sandbox workload template. + +| Flag | Description | +|------|-------------| +| `--output table|yaml|json` | Output format | + +### `openshell sandbox template list` + +List sandbox workload templates. + +| Flag | Default | Description | +|------|---------|-------------| +| `--limit ` | 100 | Maximum templates | +| `--offset ` | 0 | Pagination offset | +| `--label-selector ` | empty | Filter templates by labels | +| `--names` | false | Print only template names | +| `--all-workspaces` | false | List templates across all workspaces; requires platform-admin permissions | +| `--output table|yaml|json` | `table` | Output format | + +### `openshell sandbox template delete NAME...` + +Delete one or more sandbox workload templates by name. Existing sandboxes +created from a template are not deleted. + ### `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. diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index 9f5b9230bc..e1e731a0ce 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -258,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 diff --git a/architecture/gateway.md b/architecture/gateway.md index 0430d95159..f86411511b 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -344,23 +344,33 @@ 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. +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 diff --git a/architecture/sandbox-limits.md b/architecture/sandbox-limits.md index 49f1ab7ce9..9635bc1c30 100644 --- a/architecture/sandbox-limits.md +++ b/architecture/sandbox-limits.md @@ -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 diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 6365ed0631..6e4e020536 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -259,6 +259,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 diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 7c042296f4..befac54759 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1351,6 +1351,10 @@ enum SandboxCommands { #[arg(long, add = ArgValueCompleter::new(completers::complete_sandbox_names))] name: Option, + /// Create the sandbox from a named sandbox template. + #[arg(long, conflicts_with_all = ["from", "gpu", "cpu", "memory", "driver_config_json", "envs"])] + template: Option, + /// Sandbox source: a community sandbox name (e.g., `ollama`), a path /// to a Dockerfile or directory containing one, or a full container /// image reference (e.g., `myregistry.com/img:tag`). @@ -1693,6 +1697,10 @@ enum SandboxCommands { /// Manage providers attached to a sandbox. #[command(subcommand)] Provider(SandboxProviderCommands), + + /// Manage reusable sandbox workload templates. + #[command(subcommand)] + Template(SandboxTemplateCommands), } #[derive(Subcommand, Debug)] @@ -1734,6 +1742,121 @@ enum SandboxProviderCommands { }, } +#[derive(Subcommand, Debug)] +// `Create` carries several optional strings and repeated key-value flags. This +// enum is only used for clap parsing, so boxing fields would add friction +// without a meaningful runtime win. +#[allow(clippy::large_enum_variant)] +enum SandboxTemplateCommands { + /// Create a reusable sandbox workload template. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Create { + /// Template name. + name: String, + + /// Container image for sandboxes created from this template. + /// When omitted, the gateway's default sandbox image is used at create time. + #[arg(long)] + image: Option, + + /// CPU limit for sandboxes created from this template (for example: 500m, 1, 2.5). + #[arg(long)] + cpu: Option, + + /// Memory limit for sandboxes created from this template (for example: 512Mi, 4Gi, 8G). + #[arg(long)] + memory: Option, + + /// Request GPU resources for sandboxes created from this template. + /// + /// Omit COUNT for the driver's default GPU selection, or pass COUNT + /// to request a specific number of GPUs. + #[arg(long, num_args = 0..=1, value_name = "COUNT", default_missing_value = "", value_parser = parse_gpu_request)] + gpu: Option, + + /// Experimental driver-keyed JSON object for driver-specific sandbox settings. + #[arg(long, value_name = "JSON")] + driver_config_json: Option, + + /// Target startup readiness duration for this template (for example: 30s, 5m, 1h). + #[arg(long, value_name = "DURATION")] + ready_within: Option, + + /// Maximum startup burst associated with this template. + #[arg(long, value_name = "COUNT", value_parser = clap::value_parser!(u32).range(1..))] + max_burst: Option, + + /// Attach labels to the template (key=value format, repeatable). + #[arg(long = "label", value_name = "KEY=VALUE")] + labels: Vec, + + /// Attach annotations to the template (key=value format, repeatable). + #[arg(long = "annotation", value_name = "KEY=VALUE")] + annotations: Vec, + + /// Set a non-secret environment variable in sandboxes created from this template. + /// Do not use this option for API keys, tokens, or other secrets; create + /// a provider and attach it when creating sandboxes instead. Repeatable. + #[arg(long = "env", value_name = "KEY=VALUE")] + envs: Vec, + + /// Suppress warnings when --env values look like credentials. + #[arg(long = "no-credential-warnings")] + no_credential_warnings: bool, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, + }, + + /// Fetch a sandbox workload template by name. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Get { + /// Template name. + name: String, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, + }, + + /// List sandbox workload templates. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + List { + /// Maximum number of templates to return. + #[arg(long, default_value_t = 100)] + limit: u32, + + /// Offset into the template list. + #[arg(long, default_value_t = 0)] + offset: u32, + + /// Filter templates by labels, e.g. env=prod,team=runtime. + #[arg(long)] + label_selector: Option, + + /// Print only template names (one per line). + #[arg(long, conflicts_with = "output")] + names: bool, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table, conflicts_with = "names")] + output: OutputFormat, + + /// List templates across all workspaces (overrides --workspace). + #[arg(long)] + all_workspaces: bool, + }, + + /// Delete sandbox workload templates. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Delete { + /// Template names. + #[arg(required = true, num_args = 1.., value_name = "NAME")] + names: Vec, + }, +} + #[derive(Subcommand, Debug)] enum DraftCommands { /// Show network rules for a sandbox. @@ -3064,6 +3187,7 @@ async fn run_async() -> Result<()> { match command { SandboxCommands::Create { name, + template, from, upload, no_git_ignore, @@ -3157,6 +3281,7 @@ async fn run_async() -> Result<()> { &ctx.name, run::SandboxCreateConfig { name: name.as_deref(), + template: template.as_deref(), from: from.as_deref(), uploads: &upload_specs, keep, @@ -3393,6 +3518,89 @@ async fn run_async() -> Result<()> { .await?; } }, + SandboxCommands::Template(command) => match command { + SandboxTemplateCommands::Create { + name, + image, + cpu, + memory, + gpu, + driver_config_json, + ready_within, + max_burst, + labels, + annotations, + envs, + no_credential_warnings, + output, + } => { + let labels = run::parse_key_value_pairs(&labels, "--label")?; + let annotations = + run::parse_key_value_pairs(&annotations, "--annotation")?; + let environment = run::parse_env_pairs(&envs)?; + run::warn_credential_env_vars(&environment, no_credential_warnings); + let gpu_requirements: Option = + gpu.map(Into::into); + run::sandbox_template_create( + endpoint, + &name, + image.as_deref(), + cpu.as_deref(), + memory.as_deref(), + gpu_requirements, + driver_config_json.as_deref(), + ready_within.as_deref(), + max_burst, + labels, + annotations, + environment, + output.as_str(), + &cli.workspace, + &tls, + ) + .await?; + } + SandboxTemplateCommands::Get { name, output } => { + run::sandbox_template_get( + endpoint, + &name, + output.as_str(), + &cli.workspace, + &tls, + ) + .await?; + } + SandboxTemplateCommands::List { + limit, + offset, + label_selector, + names, + output, + all_workspaces, + } => { + run::sandbox_template_list( + endpoint, + limit, + offset, + label_selector.as_deref(), + names, + output.as_str(), + &cli.workspace, + all_workspaces, + &tls, + ) + .await?; + } + SandboxTemplateCommands::Delete { names } => { + run::sandbox_template_delete( + endpoint, + &names, + &cli.workspace, + &tls, + ) + .await?; + } + }, } } } @@ -5611,6 +5819,226 @@ mod tests { } } + #[test] + fn sandbox_create_template_flag_parses() { + let cli = Cli::try_parse_from([ + "openshell", + "sandbox", + "create", + "--template", + "gpu-kata", + "--provider", + "github", + ]) + .expect("sandbox create template flag should parse"); + + match cli.command { + Some(Commands::Sandbox { + command: + Some(SandboxCommands::Create { + template, + providers, + .. + }), + .. + }) => { + assert_eq!(template.as_deref(), Some("gpu-kata")); + assert_eq!(providers, vec!["github".to_string()]); + } + other => panic!("expected SandboxCommands::Create, got: {other:?}"), + } + } + + #[test] + fn sandbox_create_template_conflicts_with_inline_workload_flags() { + for (label, extra_args) in [ + ("--from", &["--from", "python:3.12"][..]), + ("--gpu", &["--gpu"][..]), + ("--cpu", &["--cpu", "1"][..]), + ("--memory", &["--memory", "2Gi"][..]), + ("--env", &["--env", "FOO=bar"][..]), + ( + "--driver-config-json", + &["--driver-config-json", r#"{"kubernetes":{}}"#][..], + ), + ] { + let args = ["openshell", "sandbox", "create", "--template", "base"] + .into_iter() + .chain(extra_args.iter().copied()); + let result = Cli::try_parse_from(args); + assert!(result.is_err(), "--template should conflict with {label}"); + } + } + + #[test] + fn sandbox_template_create_parses_workload_flags() { + let json = r#"{"kubernetes":{"pod":{"node_selector":{"pool":"gpu"}}}}"#; + let cli = Cli::try_parse_from([ + "openshell", + "sandbox", + "template", + "create", + "gpu-kata", + "--image", + "registry.example.com/agent:latest", + "--cpu", + "2", + "--memory", + "4Gi", + "--gpu", + "1", + "--driver-config-json", + json, + "--ready-within", + "5m", + "--max-burst", + "3", + "--label", + "team=runtime", + "--annotation", + "owner=platform", + "--env", + "FEATURE_FLAG=on", + "--no-credential-warnings", + "--output", + "json", + ]) + .expect("sandbox template create should parse"); + + match cli.command { + Some(Commands::Sandbox { + command: + Some(SandboxCommands::Template(SandboxTemplateCommands::Create { + name, + image, + cpu, + memory, + gpu, + driver_config_json, + ready_within, + max_burst, + labels, + annotations, + envs, + no_credential_warnings, + output, + })), + .. + }) => { + assert_eq!(name, "gpu-kata"); + assert_eq!(image.as_deref(), Some("registry.example.com/agent:latest")); + assert_eq!(cpu.as_deref(), Some("2")); + assert_eq!(memory.as_deref(), Some("4Gi")); + assert_eq!(gpu, Some(GpuCliRequest::Count(1))); + assert_eq!(driver_config_json.as_deref(), Some(json)); + assert_eq!(ready_within.as_deref(), Some("5m")); + assert_eq!(max_burst, Some(3)); + assert_eq!(labels, vec!["team=runtime".to_string()]); + assert_eq!(annotations, vec!["owner=platform".to_string()]); + assert_eq!(envs, vec!["FEATURE_FLAG=on".to_string()]); + assert!(no_credential_warnings); + assert!(matches!(output, OutputFormat::Json)); + } + other => panic!("expected SandboxTemplateCommands::Create, got: {other:?}"), + } + } + + #[test] + fn sandbox_template_list_parses_names_and_all_workspaces() { + let cli = Cli::try_parse_from([ + "openshell", + "sandbox", + "template", + "list", + "--names", + "--all-workspaces", + "--label-selector", + "team=runtime", + "--limit", + "25", + "--offset", + "5", + ]) + .expect("sandbox template list should parse"); + + match cli.command { + Some(Commands::Sandbox { + command: + Some(SandboxCommands::Template(SandboxTemplateCommands::List { + limit, + offset, + label_selector, + names, + all_workspaces, + .. + })), + .. + }) => { + assert_eq!(limit, 25); + assert_eq!(offset, 5); + assert_eq!(label_selector.as_deref(), Some("team=runtime")); + assert!(names); + assert!(all_workspaces); + } + other => panic!("expected SandboxTemplateCommands::List, got: {other:?}"), + } + } + + #[test] + fn sandbox_template_list_names_conflicts_with_output() { + let result = Cli::try_parse_from([ + "openshell", + "sandbox", + "template", + "list", + "--names", + "--output", + "json", + ]); + assert!(result.is_err()); + } + + #[test] + fn sandbox_template_create_image_is_optional() { + let cli = Cli::try_parse_from(["openshell", "sandbox", "template", "create", "base"]) + .expect("sandbox template create without --image should parse"); + + match cli.command { + Some(Commands::Sandbox { + command: + Some(SandboxCommands::Template(SandboxTemplateCommands::Create { image, .. })), + .. + }) => { + assert_eq!(image, None); + } + other => panic!("expected SandboxTemplateCommands::Create, got: {other:?}"), + } + } + + #[test] + fn sandbox_template_create_gpu_parses_driver_default() { + let cli = Cli::try_parse_from([ + "openshell", + "sandbox", + "template", + "create", + "gpu-kata", + "--gpu", + ]) + .expect("sandbox template create --gpu should parse"); + + match cli.command { + Some(Commands::Sandbox { + command: + Some(SandboxCommands::Template(SandboxTemplateCommands::Create { gpu, .. })), + .. + }) => { + assert_eq!(gpu, Some(GpuCliRequest::DriverDefault)); + } + other => panic!("expected SandboxTemplateCommands::Create, got: {other:?}"), + } + } + #[test] fn sandbox_create_gpu_parses_driver_default() { let cli = Cli::try_parse_from(["openshell", "sandbox", "create", "--gpu"]) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 36cdaf01ba..619a8bbd22 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -37,26 +37,28 @@ use openshell_core::proto::ProviderProfileCategory; use openshell_core::proto::{ ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, AttachSandboxProviderRequest, ClearDraftChunksRequest, ConfigureProviderRefreshRequest, CreateProviderRequest, - CreateSandboxRequest, CreateSshSessionRequest, DeleteInferenceRouteRequest, - DeleteProviderProfileRequest, DeleteProviderRefreshRequest, DeleteProviderRequest, - DeleteSandboxRequest, DeleteServiceRequest, DetachSandboxProviderRequest, ExecSandboxRequest, - ExposeServiceRequest, GetCurrentUserRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, - GetGatewayConfigRequest, GetInferenceRouteRequest, GetProviderProfileRequest, - GetProviderRefreshStatusRequest, GetProviderRequest, GetSandboxConfigRequest, - GetSandboxConfigResponse, GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, - GetSandboxRequest, GetServiceRequest, GpuResourceRequirements, ImportProviderProfilesRequest, - LintProviderProfilesRequest, ListProviderProfilesRequest, ListProvidersRequest, - ListSandboxPoliciesRequest, ListSandboxProvidersRequest, ListSandboxesRequest, + CreateSandboxRequest, CreateSandboxTemplateRequest, CreateSshSessionRequest, + DeleteInferenceRouteRequest, DeleteProviderProfileRequest, DeleteProviderRefreshRequest, + DeleteProviderRequest, DeleteSandboxRequest, DeleteSandboxTemplateRequest, + DeleteServiceRequest, DetachSandboxProviderRequest, ExecSandboxRequest, ExposeServiceRequest, + GetCurrentUserRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, GetGatewayConfigRequest, + GetInferenceRouteRequest, GetProviderProfileRequest, GetProviderRefreshStatusRequest, + GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxLogsRequest, + GetSandboxPolicyStatusRequest, GetSandboxRequest, GetSandboxTemplateRequest, GetServiceRequest, + GpuResourceRequirements, ImportProviderProfilesRequest, LintProviderProfilesRequest, + ListProviderProfilesRequest, ListProvidersRequest, ListSandboxPoliciesRequest, + ListSandboxProvidersRequest, ListSandboxTemplatesRequest, ListSandboxesRequest, ListServicesRequest, PolicySource, PolicyStatus, Provider, ProviderCredentialRefreshRecoveryAction, ProviderCredentialRefreshStatus, ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrantType, ProviderProfile, ProviderProfileDiagnostic, ProviderProfileImportItem, RejectDraftChunkRequest, ResourceRequirements, RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox, - SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, ServiceEndpointResponse, - SetInferenceRouteRequest, SettingScope, StartSandboxRequest, StopSandboxRequest, - TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest, - UpdateProviderProfilesRequest, UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, - tcp_forward_init, + SandboxPhase, SandboxPolicy, SandboxResources, SandboxServiceLevel, SandboxSpec, + SandboxStartup, SandboxTemplate, SandboxWorkloadConfig, SandboxWorkloadTemplate, + SandboxWorkloadTemplateSpec, ServiceEndpointResponse, SetInferenceRouteRequest, SettingScope, + StartSandboxRequest, StopSandboxRequest, TcpForwardFrame, TcpForwardInit, TcpRelayTarget, + UpdateConfigRequest, UpdateProviderProfilesRequest, UpdateProviderRequest, WatchSandboxRequest, + exec_sandbox_event, tcp_forward_init, }; use openshell_core::settings; use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; @@ -400,6 +402,7 @@ async fn finalize_sandbox_create_session( #[derive(Debug)] pub struct SandboxCreateConfig<'a> { pub name: Option<&'a str>, + pub template: Option<&'a str>, pub from: Option<&'a str>, pub uploads: &'a [(String, Option, bool)], pub keep: bool, @@ -425,6 +428,7 @@ impl Default for SandboxCreateConfig<'_> { fn default() -> Self { Self { name: None, + template: None, from: None, uploads: &[], keep: false, @@ -458,6 +462,7 @@ pub async fn sandbox_create( ) -> Result { let SandboxCreateConfig { name, + template, from, uploads, keep, @@ -511,23 +516,42 @@ pub async fn sandbox_create( let effective_server = server.to_string(); let effective_tls = tls.clone(); + if template.is_some() + && (from.is_some() + || gpu_requirements.is_some() + || cpu.is_some() + || memory.is_some() + || driver_config_json.is_some() + || !environment.is_empty()) + { + return Err(miette::miette!( + "--template cannot be combined with inline workload flags" + )); + } + // Resolve the --from flag into a container image reference, building from - // a Dockerfile first if necessary. - let image: Option = match from { - Some(val) => { - let resolved = resolve_from(val)?; - match resolved { - ResolvedSource::Image(img) => Some(img), - ResolvedSource::Dockerfile { - dockerfile, - context, - } => { - let tag = build_from_dockerfile(&dockerfile, &context, gateway_name).await?; - Some(tag) + // a Dockerfile first if necessary. Template creates resolve workload shape + // on the gateway and skip local image handling. + let image: Option = if template.is_some() { + None + } else { + match from { + Some(val) => { + let resolved = resolve_from(val)?; + match resolved { + ResolvedSource::Image(img) => Some(img), + ResolvedSource::Dockerfile { + dockerfile, + context, + } => { + let tag = + build_from_dockerfile(&dockerfile, &context, gateway_name).await?; + Some(tag) + } } } + None => None, } - None => None, }; let inferred_types: Vec = inferred_provider_type(command).into_iter().collect(); let configured_providers = ensure_required_providers( @@ -540,12 +564,21 @@ pub async fn sandbox_create( .await?; let policy = load_sandbox_policy(policy)?; - let resource_limits = build_sandbox_resource_limits(cpu, memory)?; - let driver_config = driver_config_json - .map(parse_driver_config_json) - .transpose()?; + let resource_limits = if template.is_none() { + build_sandbox_resource_limits(cpu, memory)? + } else { + None + }; + let driver_config = if template.is_none() { + driver_config_json + .map(parse_driver_config_json) + .transpose()? + } else { + None + }; - let template = if image.is_some() || resource_limits.is_some() || driver_config.is_some() { + let inline_template = if image.is_some() || resource_limits.is_some() || driver_config.is_some() + { Some(SandboxTemplate { image: image.unwrap_or_default(), resources: resource_limits, @@ -582,10 +615,14 @@ pub async fn sandbox_create( let request = CreateSandboxRequest { spec: Some(SandboxSpec { resource_requirements, - environment, + environment: if template.is_none() { + environment + } else { + HashMap::new() + }, policy, providers: configured_providers, - template, + template: inline_template, command: main_command, tty: main_terminal, ..SandboxSpec::default() @@ -595,6 +632,7 @@ pub async fn sandbox_create( annotations, workspace: workspace.to_string(), await_main_process_attachment, + workload_template_name: template.unwrap_or_default().to_string(), }; let response = match client.create_sandbox(request).await { @@ -1441,6 +1479,15 @@ pub async fn sandbox_get( } } + if let Some(provenance) = &sandbox.created_from_workload_template { + println!( + " {} {}@{}", + "Workload template:".dimmed(), + provenance.name, + provenance.resource_version + ); + } + let policy_from_global = config.policy_source == PolicySource::Global as i32; println!( " {} {}", @@ -2226,6 +2273,16 @@ fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value { || serde_json::json!({}), |m| serde_json::json!(m.annotations), ); + let created_from_workload_template = + sandbox + .created_from_workload_template + .as_ref() + .map(|provenance| { + serde_json::json!({ + "name": provenance.name, + "resource_version": provenance.resource_version, + }) + }); serde_json::json!({ "id": sandbox.object_id(), "name": sandbox.object_name(), @@ -2237,6 +2294,7 @@ fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value { "phase": phase_name(sandbox.phase()), "current_policy_version": sandbox.current_policy_version(), "exit_code": sandbox.status.as_ref().and_then(|status| status.exit_code), + "created_from_workload_template": created_from_workload_template, }) } @@ -2496,6 +2554,583 @@ fn format_provider_attachment_table(providers: &[Provider], color: bool) -> Stri output } +#[allow(clippy::too_many_arguments, clippy::implicit_hasher)] +pub async fn sandbox_template_create( + server: &str, + name: &str, + image: Option<&str>, + cpu: Option<&str>, + memory: Option<&str>, + gpu_requirements: Option, + driver_config_json: Option<&str>, + ready_within: Option<&str>, + max_burst: Option, + labels: HashMap, + annotations: HashMap, + environment: HashMap, + output: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let resources = if cpu.is_some() || memory.is_some() || gpu_requirements.is_some() { + Some(SandboxResources { + cpu: cpu + .map(validate_cpu_quantity) + .transpose()? + .unwrap_or_default(), + memory: memory + .map(validate_memory_quantity) + .transpose()? + .unwrap_or_default(), + gpu: gpu_requirements, + }) + } else { + None + }; + let driver_config = driver_config_json + .map(parse_driver_config_json) + .transpose()?; + let desired_service_level = build_template_service_level(ready_within, max_burst)?; + + let mut client = grpc_client(server, tls).await?; + let response = client + .create_sandbox_template(CreateSandboxTemplateRequest { + template: Some(SandboxWorkloadTemplate { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: name.to_string(), + created_at_ms: 0, + labels, + resource_version: 0, + annotations, + workspace: String::new(), + deletion_timestamp_ms: 0, + }), + spec: Some(SandboxWorkloadTemplateSpec { + workload: Some(SandboxWorkloadConfig { + image: image.unwrap_or_default().to_string(), + environment, + resources, + }), + driver_config, + desired_service_level, + }), + }), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()?; + + let template = response + .into_inner() + .template + .ok_or_else(|| miette!("sandbox template missing from response"))?; + if crate::output::print_output_single(output, &template, sandbox_template_to_json)? { + return Ok(()); + } + println!( + "{} Created sandbox template {}", + "✓".green().bold(), + template.object_name().bold() + ); + Ok(()) +} + +fn build_template_service_level( + ready_within: Option<&str>, + max_burst: Option, +) -> Result> { + if ready_within.is_none() && max_burst.is_none() { + return Ok(None); + } + let ready_within = ready_within + .map(parse_duration_to_ms) + .transpose()? + .map(|ms| { + if ms <= 0 { + Err(miette!("--ready-within must be greater than zero")) + } else { + Ok(duration_ms_to_proto(ms)) + } + }) + .transpose()?; + Ok(Some(SandboxServiceLevel { + startup: Some(SandboxStartup { + ready_within, + max_burst: max_burst.unwrap_or_default(), + }), + })) +} + +fn duration_ms_to_proto(ms: i64) -> prost_types::Duration { + prost_types::Duration { + seconds: ms / 1_000, + nanos: i32::try_from((ms % 1_000) * 1_000_000) + .expect("duration millisecond remainder fits in protobuf nanos"), + } +} + +pub async fn sandbox_template_get( + server: &str, + name: &str, + output: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let response = client + .get_sandbox_template(GetSandboxTemplateRequest { + name: name.to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()?; + let template = response + .into_inner() + .template + .ok_or_else(|| miette!("sandbox template missing from response"))?; + + if crate::output::print_output_single(output, &template, sandbox_template_to_json)? { + return Ok(()); + } + + print_sandbox_template_detail(&template); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +pub async fn sandbox_template_list( + server: &str, + limit: u32, + offset: u32, + label_selector: Option<&str>, + names_only: bool, + output: &str, + workspace: &str, + all_workspaces: bool, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let response = client + .list_sandbox_templates(ListSandboxTemplatesRequest { + limit, + offset, + workspace: if all_workspaces { + String::new() + } else { + workspace.to_string() + }, + all_workspaces, + label_selector: label_selector.unwrap_or_default().to_string(), + }) + .await + .into_diagnostic()?; + let templates = response.into_inner().templates; + + if crate::output::print_output_collection(output, &templates, sandbox_template_to_json)? { + return Ok(()); + } + + if templates.is_empty() { + if !names_only { + println!("No sandbox templates found."); + } + return Ok(()); + } + + if names_only { + for template in &templates { + if all_workspaces { + println!("{}/{}", template.object_workspace(), template.object_name()); + } else { + println!("{}", template.object_name()); + } + } + return Ok(()); + } + + print_sandbox_template_table(&templates, all_workspaces); + Ok(()) +} + +pub async fn sandbox_template_delete( + server: &str, + names: &[String], + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + for name in names { + let response = client + .delete_sandbox_template(DeleteSandboxTemplateRequest { + name: name.clone(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()?; + if response.into_inner().deleted { + println!("{} Deleted sandbox template {name}", "✓".green().bold()); + } else { + println!("Sandbox template {name} not found."); + } + } + Ok(()) +} + +fn sandbox_template_to_json(template: &SandboxWorkloadTemplate) -> serde_json::Value { + let mut obj = serde_json::Map::new(); + obj.insert("id".to_string(), serde_json::json!(template.object_id())); + obj.insert( + "name".to_string(), + serde_json::json!(template.object_name()), + ); + obj.insert( + "workspace".to_string(), + serde_json::json!(template.object_workspace()), + ); + + if let Some(metadata) = &template.metadata { + if metadata.resource_version != 0 { + obj.insert( + "resource_version".to_string(), + serde_json::json!(metadata.resource_version), + ); + } + if metadata.created_at_ms != 0 { + obj.insert( + "created_at".to_string(), + serde_json::json!(format_epoch_ms(metadata.created_at_ms)), + ); + } + if !metadata.labels.is_empty() { + obj.insert("labels".to_string(), serde_json::json!(metadata.labels)); + } + if !metadata.annotations.is_empty() { + obj.insert( + "annotations".to_string(), + serde_json::json!(metadata.annotations), + ); + } + } + + if let Some(spec) = &template.spec { + if let Some(workload) = &spec.workload { + obj.insert("image".to_string(), serde_json::json!(workload.image)); + if !workload.environment.is_empty() { + obj.insert( + "environment".to_string(), + serde_json::json!(workload.environment), + ); + } + if let Some(resources) = &workload.resources { + let mut resources_json = serde_json::Map::new(); + if !resources.cpu.is_empty() { + resources_json.insert("cpu".to_string(), serde_json::json!(resources.cpu)); + } + if !resources.memory.is_empty() { + resources_json + .insert("memory".to_string(), serde_json::json!(resources.memory)); + } + if let Some(gpu) = &resources.gpu { + let value = gpu + .count + .map_or_else(|| serde_json::json!("default"), serde_json::Value::from); + resources_json.insert("gpu".to_string(), value); + } + if !resources_json.is_empty() { + obj.insert( + "resources".to_string(), + serde_json::Value::Object(resources_json), + ); + } + } + } + if let Some(driver_config) = &spec.driver_config { + obj.insert( + "driver_config".to_string(), + openshell_core::proto_struct::struct_to_json_value(driver_config), + ); + } + if let Some(service_level) = &spec.desired_service_level + && let Some(startup) = &service_level.startup + { + let mut startup_json = serde_json::Map::new(); + if let Some(ready_within) = &startup.ready_within { + startup_json.insert( + "ready_within_ms".to_string(), + serde_json::json!(duration_to_ms(ready_within)), + ); + } + if startup.max_burst != 0 { + startup_json.insert( + "max_burst".to_string(), + serde_json::json!(startup.max_burst), + ); + } + if !startup_json.is_empty() { + obj.insert( + "startup".to_string(), + serde_json::Value::Object(startup_json), + ); + } + } + } + + serde_json::Value::Object(obj) +} + +fn print_sandbox_template_detail(template: &SandboxWorkloadTemplate) { + println!("{}", "Sandbox template:".cyan().bold()); + println!(); + println!(" {} {}", "Name:".dimmed(), template.object_name()); + println!( + " {} {}", + "Workspace:".dimmed(), + template.object_workspace() + ); + if let Some(metadata) = &template.metadata { + println!(" {} {}", "Id:".dimmed(), metadata.id); + println!( + " {} {}", + "Resource version:".dimmed(), + metadata.resource_version + ); + if metadata.created_at_ms != 0 { + println!( + " {} {}", + "Created:".dimmed(), + format_epoch_ms(metadata.created_at_ms) + ); + } + let labels = labels_display(&metadata.labels); + println!( + " {} {}", + "Labels:".dimmed(), + non_empty_or(&labels, "") + ); + } + if let Some(spec) = &template.spec + && let Some(workload) = &spec.workload + { + println!( + " {} {}", + "Image:".dimmed(), + non_empty_or(&workload.image, "") + ); + println!( + " {} {}", + "Environment:".dimmed(), + workload.environment.len() + ); + if let Some(resources) = &workload.resources { + println!( + " {} {}", + "CPU:".dimmed(), + non_empty_or(&resources.cpu, "") + ); + println!( + " {} {}", + "Memory:".dimmed(), + non_empty_or(&resources.memory, "") + ); + println!( + " {} {}", + "GPU:".dimmed(), + template_resources_gpu_display(resources).unwrap_or_else(|| "".to_string()) + ); + } + } + if let Some(startup) = template_startup(template) { + println!( + " {} {}", + "Ready within:".dimmed(), + startup + .ready_within + .as_ref() + .map_or_else(|| "".to_string(), duration_display) + ); + println!( + " {} {}", + "Max burst:".dimmed(), + if startup.max_burst == 0 { + "".to_string() + } else { + startup.max_burst.to_string() + } + ); + } +} + +fn print_sandbox_template_table(templates: &[SandboxWorkloadTemplate], show_workspace: bool) { + let name_width = templates + .iter() + .map(|template| template.object_name().len()) + .max() + .unwrap_or(4) + .max(4); + let workspace_width = if show_workspace { + templates + .iter() + .map(|template| template.object_workspace().len()) + .max() + .unwrap_or(9) + .max(9) + } else { + 0 + }; + let image_width = templates + .iter() + .map(|template| template_image(template).len()) + .max() + .unwrap_or(5) + .clamp(5, 48); + + if show_workspace { + println!( + "{: String { + template + .spec + .as_ref() + .and_then(|spec| spec.workload.as_ref()) + .map_or_else( + || "".to_string(), + |workload| non_empty_or(&workload.image, "").to_string(), + ) +} + +fn template_resources(template: &SandboxWorkloadTemplate) -> Option<&SandboxResources> { + template + .spec + .as_ref() + .and_then(|spec| spec.workload.as_ref()) + .and_then(|workload| workload.resources.as_ref()) +} + +fn template_resources_gpu_display(resources: &SandboxResources) -> Option { + if let Some(gpu) = &resources.gpu { + return Some( + gpu.count + .map_or_else(|| "default".to_string(), |count| count.to_string()), + ); + } + None +} + +fn template_startup(template: &SandboxWorkloadTemplate) -> Option<&SandboxStartup> { + template + .spec + .as_ref() + .and_then(|spec| spec.desired_service_level.as_ref()) + .and_then(|service_level| service_level.startup.as_ref()) +} + +fn duration_to_ms(duration: &prost_types::Duration) -> i64 { + duration.seconds.saturating_mul(1_000) + i64::from(duration.nanos / 1_000_000) +} + +fn duration_display(duration: &prost_types::Duration) -> String { + let total_ms = duration_to_ms(duration); + if total_ms % 3_600_000 == 0 { + format!("{}h", total_ms / 3_600_000) + } else if total_ms % 60_000 == 0 { + format!("{}m", total_ms / 60_000) + } else if total_ms % 1_000 == 0 { + format!("{}s", total_ms / 1_000) + } else { + format!("{total_ms}ms") + } +} + +fn labels_display(labels: &HashMap) -> String { + let mut pairs = labels + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>(); + pairs.sort(); + pairs.join(", ") +} + /// Delete a sandbox by name, or all sandboxes when `all` is true. pub async fn sandbox_delete( server: &str, @@ -7675,8 +8310,10 @@ mod tests { ProviderCredentialRefreshStatus, ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrant, ProviderProfile, ProviderProfileCredential, ResourceRequirements, Sandbox, SandboxCondition, SandboxPhase, SandboxPolicy, - SandboxPolicyRevision, SandboxStatus, ServiceEndpoint, ServiceEndpointResponse, - WorkspaceMember, WorkspaceRole, datamodel::v1::ObjectMeta, + SandboxPolicyRevision, SandboxResources, SandboxStatus, SandboxWorkloadConfig, + SandboxWorkloadTemplate, SandboxWorkloadTemplateProvenance, SandboxWorkloadTemplateSpec, + ServiceEndpoint, ServiceEndpointResponse, WorkspaceMember, WorkspaceRole, + datamodel::v1::ObjectMeta, }; #[test] @@ -9139,6 +9776,74 @@ mod tests { assert_eq!(json["labels"]["env"], "prod"); } + #[test] + fn sandbox_template_to_json_includes_metadata_labels_and_annotations() { + let template = SandboxWorkloadTemplate { + metadata: Some(ObjectMeta { + id: "template-123".to_string(), + name: "gpu-kata".to_string(), + labels: std::collections::HashMap::from([( + "team".to_string(), + "runtime".to_string(), + )]), + annotations: std::collections::HashMap::from([( + "owner".to_string(), + "platform".to_string(), + )]), + workspace: "default".to_string(), + ..Default::default() + }), + ..Default::default() + }; + + let json = super::sandbox_template_to_json(&template); + + assert_eq!(json["labels"]["team"], "runtime"); + assert_eq!(json["annotations"]["owner"], "platform"); + } + + #[test] + fn sandbox_template_to_json_formats_default_gpu_like_display_output() { + let template = SandboxWorkloadTemplate { + spec: Some(SandboxWorkloadTemplateSpec { + workload: Some(SandboxWorkloadConfig { + resources: Some(SandboxResources { + gpu: Some(GpuResourceRequirements { count: None }), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }; + + let json = super::sandbox_template_to_json(&template); + + assert_eq!(json["resources"]["gpu"], "default"); + } + + #[test] + fn sandbox_template_to_json_preserves_explicit_gpu_count_as_number() { + let template = SandboxWorkloadTemplate { + spec: Some(SandboxWorkloadTemplateSpec { + workload: Some(SandboxWorkloadConfig { + resources: Some(SandboxResources { + gpu: Some(GpuResourceRequirements { count: Some(2) }), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }; + + let json = super::sandbox_template_to_json(&template); + + assert_eq!(json["resources"]["gpu"], 2); + } + #[test] fn provider_to_json_omits_zero_metadata_fields() { let metadata = ObjectMeta { @@ -9237,6 +9942,10 @@ mod tests { created_at_ms: 1_609_459_200_000, ..Default::default() }), + created_from_workload_template: Some(SandboxWorkloadTemplateProvenance { + name: "gpu-kata".to_string(), + resource_version: "7".to_string(), + }), ..Default::default() }; sandbox.set_phase(SandboxPhase::Ready as i32); @@ -9256,6 +9965,11 @@ mod tests { assert_eq!(json["policy_source"], "global"); assert_eq!(json["revision"], 3); assert!(json["policy"].is_null()); + assert_eq!(json["created_from_workload_template"]["name"], "gpu-kata"); + assert_eq!( + json["created_from_workload_template"]["resource_version"], + "7" + ); } #[test] diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 743cfec42c..2a4801b143 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -154,6 +154,8 @@ impl OpenShell for TestOpenShell { Ok(Response::new(ListSandboxesResponse::default())) } + unimplemented_sandbox_template_rpcs!(); + async fn list_sandbox_providers( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/helpers/mod.rs b/crates/openshell-cli/tests/helpers/mod.rs index a58e750b91..c4e9b4b75a 100644 --- a/crates/openshell-cli/tests/helpers/mod.rs +++ b/crates/openshell-cli/tests/helpers/mod.rs @@ -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, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = Result< + tonic::Response, + 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, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = Result< + tonic::Response, + 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, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = Result< + tonic::Response, + 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, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = Result< + tonic::Response, + 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, }; diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index e4735ac4c1..12c838baf1 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -113,6 +113,8 @@ impl OpenShell for TestOpenShell { )) } + unimplemented_sandbox_template_rpcs!(); + async fn list_sandbox_providers( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 46e3b903c0..39452b6de5 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -185,6 +185,7 @@ impl OpenShell for TestOpenShell { }), spec: None, status: None, + ..Sandbox::default() }), })) } @@ -196,6 +197,8 @@ impl OpenShell for TestOpenShell { Ok(Response::new(ListSandboxesResponse::default())) } + unimplemented_sandbox_template_rpcs!(); + async fn list_sandbox_providers( &self, request: tonic::Request, diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 51e2159a5c..7be771c442 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -14,20 +14,22 @@ use openshell_cli::tls::TlsOptions; use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer}; use openshell_core::proto::{ AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateProviderRequest, - CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, - DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DetachSandboxProviderRequest, DetachSandboxProviderResponse, - ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, - ExecSandboxInput, ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest, - GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest, - GetSandboxConfigResponse, GetSandboxProviderEnvironmentRequest, - GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GpuResourceRequirements, - HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, - ListSandboxProvidersRequest, ListSandboxProvidersResponse, ListSandboxesRequest, - ListSandboxesResponse, PlatformEvent, ProviderResponse, RevokeSshSessionRequest, + CreateSandboxRequest, CreateSandboxTemplateRequest, CreateSshSessionRequest, + CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, + DeleteSandboxResponse, DeleteSandboxTemplateRequest, DetachSandboxProviderRequest, + DetachSandboxProviderResponse, ExchangeProviderSubjectTokenRequest, + ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, + GatewayMessage, GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRequest, + GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxProviderEnvironmentRequest, + GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxTemplateRequest, + GpuResourceRequirements, HealthRequest, HealthResponse, ListProvidersRequest, + ListProvidersResponse, ListSandboxProvidersRequest, ListSandboxProvidersResponse, + ListSandboxTemplatesRequest, ListSandboxTemplatesResponse, ListSandboxesRequest, + ListSandboxesResponse, PlatformEvent, Provider, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, Sandbox, SandboxCondition, SandboxLogLine, SandboxPhase, - SandboxResponse, SandboxStatus, SandboxStreamEvent, ServiceStatus, SettingValue, - SupervisorMessage, UpdateProviderRequest, WatchSandboxRequest, sandbox_stream_event, + SandboxResponse, SandboxStatus, SandboxStreamEvent, SandboxTemplateResponse, + SandboxWorkloadTemplate, ServiceStatus, SettingValue, SupervisorMessage, UpdateProviderRequest, + WatchSandboxRequest, sandbox_stream_event, }; use std::collections::HashMap; use std::fs; @@ -56,6 +58,11 @@ struct SandboxState { ssh_session_requests: Arc, global_settings: Arc>>, gateway_config_requests: Arc, + providers: Arc>>, + template_create_requests: Arc>>, + template_get_requests: Arc>>, + template_list_requests: Arc>>, + template_delete_requests: Arc>>, } #[derive(Clone, Default)] @@ -180,6 +187,95 @@ impl OpenShell for TestOpenShell { Ok(Response::new(ListSandboxesResponse::default())) } + async fn create_sandbox_template( + &self, + request: tonic::Request, + ) -> Result, Status> { + let request = request.into_inner(); + let mut template = request.template.clone().unwrap_or_default(); + let name = template + .metadata + .as_ref() + .map_or_else(|| "template".to_string(), |metadata| metadata.name.clone()); + template.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: format!("template-{name}"), + name, + created_at_ms: 0, + labels: template + .metadata + .as_ref() + .map(|metadata| metadata.labels.clone()) + .unwrap_or_default(), + resource_version: 1, + annotations: HashMap::new(), + workspace: request.workspace.clone(), + deletion_timestamp_ms: 0, + }); + self.state + .template_create_requests + .lock() + .await + .push(request); + Ok(Response::new(SandboxTemplateResponse { + template: Some(template), + })) + } + + async fn get_sandbox_template( + &self, + request: tonic::Request, + ) -> Result, Status> { + let request = request.into_inner(); + self.state + .template_get_requests + .lock() + .await + .push(request.clone()); + Ok(Response::new(SandboxTemplateResponse { + template: Some(SandboxWorkloadTemplate { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: format!("template-{}", request.name), + name: request.name, + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 1, + annotations: HashMap::new(), + workspace: request.workspace, + deletion_timestamp_ms: 0, + }), + spec: None, + }), + })) + } + + async fn list_sandbox_templates( + &self, + request: tonic::Request, + ) -> Result, Status> { + self.state + .template_list_requests + .lock() + .await + .push(request.into_inner()); + Ok(Response::new(ListSandboxTemplatesResponse { + templates: Vec::new(), + })) + } + + async fn delete_sandbox_template( + &self, + request: tonic::Request, + ) -> Result, Status> { + self.state + .template_delete_requests + .lock() + .await + .push(request.into_inner()); + Ok(Response::new( + openshell_core::proto::DeleteSandboxTemplateResponse { deleted: true }, + )) + } + async fn list_sandbox_providers( &self, _request: tonic::Request, @@ -337,7 +433,9 @@ impl OpenShell for TestOpenShell { &self, _request: tonic::Request, ) -> Result, Status> { - Ok(Response::new(ListProvidersResponse::default())) + Ok(Response::new(ListProvidersResponse { + providers: self.state.providers.lock().await.clone(), + })) } async fn list_provider_profiles( @@ -1209,6 +1307,63 @@ async fn create_requests(server: &TestServer) -> Vec { server.openshell.state.create_requests.lock().await.clone() } +async fn template_create_requests(server: &TestServer) -> Vec { + server + .openshell + .state + .template_create_requests + .lock() + .await + .clone() +} + +async fn template_list_requests(server: &TestServer) -> Vec { + server + .openshell + .state + .template_list_requests + .lock() + .await + .clone() +} + +async fn template_delete_requests(server: &TestServer) -> Vec { + server + .openshell + .state + .template_delete_requests + .lock() + .await + .clone() +} + +async fn add_provider(server: &TestServer, name: &str, provider_type: &str) { + server + .openshell + .state + .providers + .lock() + .await + .push(Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: format!("provider-{name}"), + name: name.to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: provider_type.to_string(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), + }); +} + fn test_tls(server: &TestServer) -> TlsOptions { server.tls.with_gateway_name("openshell") } @@ -1529,6 +1684,201 @@ async fn sandbox_create_sends_driver_config_json() { ); } +#[tokio::test] +async fn sandbox_create_with_template_sends_workload_template_name() { + let server = run_server().await; + add_provider(&server, "github", "github").await; + let fake_ssh_dir = tempfile::tempdir().unwrap(); + let xdg_dir = tempfile::tempdir().unwrap(); + let _env = test_env(&fake_ssh_dir, &xdg_dir); + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("from-template"), + template: Some("gpu-kata"), + providers: &["github".to_string()], + command: &["echo".into(), "OK".into()], + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("sandbox create should succeed"); + + let requests = create_requests(&server).await; + let request = requests.first().expect("create request should be recorded"); + assert_eq!(request.workload_template_name, "gpu-kata"); + let spec = request + .spec + .as_ref() + .expect("governance spec should be sent"); + assert_eq!(spec.providers, vec!["github".to_string()]); + assert!(spec.template.is_none()); + assert!(spec.environment.is_empty()); + assert!(spec.resource_requirements.is_none()); +} + +#[tokio::test] +async fn sandbox_template_create_sends_workload_template_resource() { + let server = run_server().await; + let fake_ssh_dir = tempfile::tempdir().unwrap(); + let xdg_dir = tempfile::tempdir().unwrap(); + let _env = test_env(&fake_ssh_dir, &xdg_dir); + let tls = test_tls(&server); + + run::sandbox_template_create( + &server.endpoint, + "gpu-kata", + Some("registry.example.com/agent:latest"), + Some("2"), + Some("4Gi"), + Some(GpuResourceRequirements { count: Some(1) }), + Some(r#"{"kubernetes":{"pod":{"node_selector":{"pool":"gpu"}}}}"#), + Some("5m"), + Some(3), + HashMap::from([("team".to_string(), "runtime".to_string())]), + HashMap::from([("owner".to_string(), "platform".to_string())]), + HashMap::from([("FEATURE_FLAG".to_string(), "on".to_string())]), + "table", + "default", + &tls, + ) + .await + .expect("template create should succeed"); + + let requests = template_create_requests(&server).await; + let request = requests + .first() + .expect("template create request should be recorded"); + assert_eq!(request.workspace, "default"); + let template = request.template.as_ref().expect("template should be sent"); + let metadata = template.metadata.as_ref().expect("metadata should be sent"); + assert_eq!(metadata.name, "gpu-kata"); + assert_eq!(metadata.labels.get("team"), Some(&"runtime".to_string())); + assert_eq!( + metadata.annotations.get("owner"), + Some(&"platform".to_string()) + ); + + let spec = template.spec.as_ref().expect("spec should be sent"); + let workload = spec.workload.as_ref().expect("workload should be sent"); + assert_eq!(workload.image, "registry.example.com/agent:latest"); + assert_eq!( + workload.environment.get("FEATURE_FLAG"), + Some(&"on".to_string()) + ); + let resources = workload + .resources + .as_ref() + .expect("resources should be sent"); + assert_eq!(resources.cpu, "2"); + assert_eq!(resources.memory, "4Gi"); + assert_eq!(resources.gpu.as_ref().and_then(|gpu| gpu.count), Some(1)); + assert!(spec.driver_config.is_some()); + let startup = spec + .desired_service_level + .as_ref() + .and_then(|service_level| service_level.startup.as_ref()) + .expect("startup service level should be sent"); + assert_eq!(startup.max_burst, 3); + assert_eq!( + startup + .ready_within + .as_ref() + .map(|duration| duration.seconds), + Some(300) + ); +} + +#[tokio::test] +async fn sandbox_template_list_and_delete_send_workspace_requests() { + let server = run_server().await; + let fake_ssh_dir = tempfile::tempdir().unwrap(); + let xdg_dir = tempfile::tempdir().unwrap(); + let _env = test_env(&fake_ssh_dir, &xdg_dir); + let tls = test_tls(&server); + + run::sandbox_template_list( + &server.endpoint, + 25, + 5, + Some("team=runtime"), + false, + "table", + "default", + false, + &tls, + ) + .await + .expect("template list should succeed"); + run::sandbox_template_delete(&server.endpoint, &["gpu-kata".to_string()], "default", &tls) + .await + .expect("template delete should succeed"); + + let list_requests = template_list_requests(&server).await; + let list_request = list_requests + .first() + .expect("template list request should be recorded"); + assert_eq!(list_request.limit, 25); + assert_eq!(list_request.offset, 5); + assert_eq!(list_request.label_selector, "team=runtime"); + assert_eq!(list_request.workspace, "default"); + assert!(!list_request.all_workspaces); + + let delete_requests = template_delete_requests(&server).await; + let delete_request = delete_requests + .first() + .expect("template delete request should be recorded"); + assert_eq!(delete_request.name, "gpu-kata"); + assert_eq!(delete_request.workspace, "default"); +} + +#[tokio::test] +async fn sandbox_template_create_allows_omitted_image() { + let server = run_server().await; + let fake_ssh_dir = tempfile::tempdir().unwrap(); + let xdg_dir = tempfile::tempdir().unwrap(); + let _env = test_env(&fake_ssh_dir, &xdg_dir); + let tls = test_tls(&server); + + run::sandbox_template_create( + &server.endpoint, + "base", + None, + None, + None, + None, + None, + None, + None, + HashMap::new(), + HashMap::new(), + HashMap::new(), + "table", + "default", + &tls, + ) + .await + .expect("template create without image should succeed"); + + let requests = template_create_requests(&server).await; + let request = requests + .first() + .expect("template create request should be recorded"); + let workload = request + .template + .as_ref() + .and_then(|template| template.spec.as_ref()) + .and_then(|spec| spec.workload.as_ref()) + .expect("workload should be sent"); + assert_eq!(workload.image, ""); +} + #[tokio::test] async fn sandbox_create_sends_gpu_default_request() { let server = run_server().await; @@ -2258,6 +2608,44 @@ async fn run_cli_sandbox_create( .unwrap() } +async fn run_cli_sandbox_template_create( + server: &TestServer, + name: &str, + extra_args: &[&str], +) -> std::process::Output { + let xdg_dir = tempfile::tempdir().unwrap(); + let tls_dir = xdg_dir.path().join("openshell/gateways/openshell/mtls"); + fs::create_dir_all(&tls_dir).unwrap(); + for filename in ["ca.crt", "tls.crt", "tls.key"] { + fs::copy(server.dir.path().join(filename), tls_dir.join(filename)).unwrap(); + } + + let mut cmd = tokio::process::Command::new(env!("CARGO_BIN_EXE_openshell")); + for (key, _) in std::env::vars().filter(|(k, _)| k.starts_with("OPENSHELL_")) { + cmd.env_remove(&key); + } + cmd.args([ + "--gateway", + "openshell", + "--gateway-endpoint", + &server.endpoint, + "sandbox", + "template", + "create", + name, + "--image", + "registry.example.com/agent:latest", + "--output=json", + ]) + .args(extra_args) + .env("XDG_CONFIG_HOME", xdg_dir.path()) + .env("HOME", xdg_dir.path()) + .env("OPENSHELL_PROVISION_TIMEOUT", "5") + .output() + .await + .unwrap() +} + #[tokio::test] async fn sandbox_create_json_stdout_is_parseable() { let server = run_server().await; @@ -2287,3 +2675,63 @@ async fn sandbox_create_yaml_stdout_is_parseable() { serde_yml::from_str::(&stdout) .unwrap_or_else(|err| panic!("stdout should contain only YAML: {err}\n{stdout}")); } + +#[tokio::test] +async fn sandbox_template_create_warns_for_credential_env_vars() { + let server = run_server().await; + + let result = run_cli_sandbox_template_create( + &server, + "credential-env", + &["--env", "OPENAI_API_KEY=plain-secret"], + ) + .await; + + assert!( + result.status.success(), + "sandbox template create failed:\n{}", + String::from_utf8_lossy(&result.stderr) + ); + let stderr = String::from_utf8_lossy(&result.stderr); + assert!( + stderr.contains("OPENAI_API_KEY looks like a credential"), + "template create should warn for credential-looking --env values: {stderr}" + ); + assert!( + stderr.contains("To hide it from the agent, use a provider instead"), + "warning should point users toward providers: {stderr}" + ); + + let requests = template_create_requests(&server).await; + assert_eq!(requests.len(), 1); +} + +#[tokio::test] +async fn sandbox_template_create_suppresses_credential_env_warnings() { + let server = run_server().await; + + let result = run_cli_sandbox_template_create( + &server, + "credential-env-suppressed", + &[ + "--env", + "OPENAI_API_KEY=plain-secret", + "--no-credential-warnings", + ], + ) + .await; + + assert!( + result.status.success(), + "sandbox template create failed:\n{}", + String::from_utf8_lossy(&result.stderr) + ); + let stderr = String::from_utf8_lossy(&result.stderr); + assert!( + !stderr.contains("OPENAI_API_KEY looks like a credential"), + "template create should suppress credential-looking --env warnings: {stderr}" + ); + + let requests = template_create_requests(&server).await; + assert_eq!(requests.len(), 1); +} diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index ee72728aa9..5b62c7c15c 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -138,6 +138,8 @@ impl OpenShell for TestOpenShell { Ok(Response::new(ListSandboxesResponse::default())) } + unimplemented_sandbox_template_rpcs!(); + async fn list_sandbox_providers( &self, _request: tonic::Request, diff --git a/crates/openshell-core/src/metadata.rs b/crates/openshell-core/src/metadata.rs index 8794c11d5d..f885812c4e 100644 --- a/crates/openshell-core/src/metadata.rs +++ b/crates/openshell-core/src/metadata.rs @@ -6,8 +6,9 @@ //! These traits provide uniform access to `ObjectMeta` fields across all resource types. use crate::proto::{ - InferenceRoute, ObjectForTest, Provider, Sandbox, SandboxStatus, ServiceEndpoint, SshSession, - StoredProviderCredentialRefreshState, StoredProviderProfile, Workspace, WorkspaceMember, + InferenceRoute, ObjectForTest, Provider, Sandbox, SandboxStatus, SandboxWorkloadTemplate, + ServiceEndpoint, SshSession, StoredProviderCredentialRefreshState, StoredProviderProfile, + Workspace, WorkspaceMember, }; use std::collections::HashMap; @@ -104,6 +105,49 @@ impl Sandbox { } } +// Implementations for SandboxWorkloadTemplate +impl ObjectId for SandboxWorkloadTemplate { + fn object_id(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.id.as_str()) + } +} + +impl ObjectName for SandboxWorkloadTemplate { + fn object_name(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.name.as_str()) + } +} + +impl ObjectLabels for SandboxWorkloadTemplate { + fn object_labels(&self) -> Option> { + self.metadata.as_ref().map(|m| m.labels.clone()) + } +} + +impl SetResourceVersion for SandboxWorkloadTemplate { + fn set_resource_version(&mut self, version: u64) { + if let Some(meta) = self.metadata.as_mut() { + meta.resource_version = version; + } + } +} + +impl GetResourceVersion for SandboxWorkloadTemplate { + fn get_resource_version(&self) -> u64 { + self.metadata.as_ref().map_or(0, |m| m.resource_version) + } +} + +impl ObjectWorkspace for SandboxWorkloadTemplate { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + + fn requires_workspace() -> bool { + true + } +} + // Implementations for Workspace impl ObjectId for Workspace { fn object_id(&self) -> &str { diff --git a/crates/openshell-core/src/telemetry.rs b/crates/openshell-core/src/telemetry.rs index 780e5c7920..63aa5f9181 100644 --- a/crates/openshell-core/src/telemetry.rs +++ b/crates/openshell-core/src/telemetry.rs @@ -145,6 +145,7 @@ impl PolicyDecisionOperation { pub enum SandboxTemplateSource { Default, Image, + WorkloadTemplate, Undefined, } @@ -154,6 +155,7 @@ impl SandboxTemplateSource { match self { Self::Default => "default", Self::Image => "image", + Self::WorkloadTemplate => "workload_template", Self::Undefined => "undefined", } } diff --git a/crates/openshell-gateway-interceptors/src/proto_json.rs b/crates/openshell-gateway-interceptors/src/proto_json.rs index b0d6b0f53d..d7ea5a241c 100644 --- a/crates/openshell-gateway-interceptors/src/proto_json.rs +++ b/crates/openshell-gateway-interceptors/src/proto_json.rs @@ -317,6 +317,7 @@ mod tests { annotations: HashMap::new(), workspace: String::new(), await_main_process_attachment: false, + workload_template_name: String::new(), }; let bytes = request.encode_to_vec(); let json = codec diff --git a/crates/openshell-gateway-interceptors/src/runtime.rs b/crates/openshell-gateway-interceptors/src/runtime.rs index 02afddab75..cd0f59ea3c 100644 --- a/crates/openshell-gateway-interceptors/src/runtime.rs +++ b/crates/openshell-gateway-interceptors/src/runtime.rs @@ -1076,6 +1076,7 @@ mod tests { annotations: HashMap::new(), workspace: String::new(), await_main_process_attachment: false, + workload_template_name: String::new(), }; let bytes = request.encode_to_vec(); diff --git a/crates/openshell-sdk/README.md b/crates/openshell-sdk/README.md index 93afc5359e..cb42e12dc1 100644 --- a/crates/openshell-sdk/README.md +++ b/crates/openshell-sdk/README.md @@ -9,7 +9,8 @@ gateway-name resolution. ## Two layers - `OpenShellClient` — the curated, sandbox-focused surface: health, sandbox - CRUD, readiness/deletion waits, and non-streaming exec. + CRUD, reusable sandbox template CRUD, readiness/deletion waits, and + non-streaming exec. - `raw` — direct access to the generated tonic clients for RPCs the curated surface doesn't yet cover (inference, providers, policy, logs, settings, SSH, forwarding). @@ -44,10 +45,53 @@ mTLS (client certificates) is not supported. `OpenShellClient::connect(ClientConfig)` returns a connected client exposing `health`, `create_sandbox`, `get_sandbox`, `list_sandboxes`, `delete_sandbox`, +`create_sandbox_from_template`, `create_sandbox_template`, +`get_sandbox_template`, `list_sandbox_templates`, `delete_sandbox_template`, `wait_ready`, `wait_deleted`, and `exec`. Curated types (`SandboxSpec`, -`SandboxRef`, `Health`, `ListOptions`, `ExecOptions`, `SandboxPhase`) use -SDK-shaped enums rather than raw proto integers. Failures map to a typed -`SdkError` with a discriminable kind. +`SandboxRef`, `Health`, `ListOptions`, `SandboxTemplateListOptions`, +`ExecOptions`, `SandboxPhase`) use SDK-shaped enums rather than raw proto +integers where practical. Reusable template resources are exposed as +`SandboxWorkloadTemplate` proto aliases so callers can populate the full +portable workload shape and driver config. Failures map to a typed `SdkError` +with a discriminable kind. + +```rust +use openshell_sdk::{ + ClientConfig, OpenShellClient, SandboxTemplateCreateSpec, + SandboxWorkloadConfig, SandboxWorkloadTemplate, SandboxWorkloadTemplateSpec, +}; + +# async fn run() -> Result<(), openshell_sdk::SdkError> { +let client = OpenShellClient::connect(ClientConfig::new("http://127.0.0.1:8080")).await?; +client + .create_sandbox_template(SandboxWorkloadTemplate { + metadata: Some(openshell_sdk::raw::proto::datamodel::v1::ObjectMeta { + name: "python".to_string(), + ..Default::default() + }), + spec: Some(SandboxWorkloadTemplateSpec { + workload: Some(SandboxWorkloadConfig { + image: "ghcr.io/nvidia/openshell-community/sandboxes/python:latest".to_string(), + ..Default::default() + }), + ..Default::default() + }), + }) + .await?; + +let _sandbox = client + .create_sandbox_from_template(SandboxTemplateCreateSpec { + template_name: "python".to_string(), + policy: Some(openshell_sdk::raw::proto::SandboxPolicy { + version: 1, + ..Default::default() + }), + ..Default::default() + }) + .await?; +# Ok(()) +# } +``` ## Modules diff --git a/crates/openshell-sdk/src/client.rs b/crates/openshell-sdk/src/client.rs index a02b5735ae..b5486812f7 100644 --- a/crates/openshell-sdk/src/client.rs +++ b/crates/openshell-sdk/src/client.rs @@ -16,7 +16,7 @@ use crate::refresh::{RefreshedToken, TokenSource}; use crate::transport; use crate::types::{ ExecOptions, ExecResult, Health, ListOptions, SandboxPhase, SandboxRef, SandboxSpec, - WorkspaceRef, + SandboxTemplateCreateSpec, SandboxTemplateListOptions, SandboxWorkloadTemplate, WorkspaceRef, }; use futures::StreamExt; use openshell_core::proto; @@ -163,6 +163,86 @@ impl OpenShellClient { sandbox_from_response(response.sandbox) } + /// Create a new sandbox from a workspace-scoped workload template name. + pub async fn create_sandbox_from_template( + &self, + spec: SandboxTemplateCreateSpec, + ) -> Result { + let request = create_sandbox_from_template_request(spec); + let response = self + .unary(|mut grpc| { + let request = request.clone(); + async move { grpc.create_sandbox(request).await } + }) + .await?; + sandbox_from_response(response.sandbox) + } + + /// Create a reusable sandbox template in the default workspace. + pub async fn create_sandbox_template( + &self, + template: SandboxWorkloadTemplate, + ) -> Result { + let response = self + .unary(|mut grpc| { + let request = proto::CreateSandboxTemplateRequest { + template: Some(template.clone()), + workspace: String::new(), + }; + async move { grpc.create_sandbox_template(request).await } + }) + .await?; + sandbox_template_from_response(response.template) + } + + /// Fetch a reusable sandbox template by name from the default workspace. + pub async fn get_sandbox_template(&self, name: &str) -> Result { + let response = self + .unary(|mut grpc| { + let request = proto::GetSandboxTemplateRequest { + name: name.to_string(), + workspace: String::new(), + }; + async move { grpc.get_sandbox_template(request).await } + }) + .await?; + sandbox_template_from_response(response.template) + } + + /// List reusable sandbox templates in the default workspace or across all workspaces. + pub async fn list_sandbox_templates( + &self, + opts: SandboxTemplateListOptions, + ) -> Result> { + let response = self + .unary(|mut grpc| { + let request = proto::ListSandboxTemplatesRequest { + limit: opts.limit, + offset: opts.offset, + workspace: String::new(), + all_workspaces: opts.all_workspaces, + label_selector: opts.label_selector.clone(), + }; + async move { grpc.list_sandbox_templates(request).await } + }) + .await?; + Ok(response.templates) + } + + /// Delete a reusable sandbox template by name from the default workspace. + pub async fn delete_sandbox_template(&self, name: &str) -> Result { + let response = self + .unary(|mut grpc| { + let request = proto::DeleteSandboxTemplateRequest { + name: name.to_string(), + workspace: String::new(), + }; + async move { grpc.delete_sandbox_template(request).await } + }) + .await?; + Ok(response.deleted) + } + /// Fetch a sandbox by name. pub async fn get_sandbox(&self, name: &str) -> Result { let response = self @@ -567,6 +647,96 @@ impl WorkspaceScopedClient { sandbox_from_response(response.sandbox) } + /// Create a new sandbox from a template in this workspace. + pub async fn create_sandbox_from_template( + &self, + spec: SandboxTemplateCreateSpec, + ) -> Result { + let mut request = create_sandbox_from_template_request(spec); + request.workspace = self.workspace.clone(); + let response = self + .client + .unary(|mut grpc| { + let request = request.clone(); + async move { grpc.create_sandbox(request).await } + }) + .await?; + sandbox_from_response(response.sandbox) + } + + /// Create a reusable sandbox template in this workspace. + pub async fn create_sandbox_template( + &self, + template: SandboxWorkloadTemplate, + ) -> Result { + let response = self + .client + .unary(|mut grpc| { + let request = proto::CreateSandboxTemplateRequest { + template: Some(template.clone()), + workspace: self.workspace.clone(), + }; + async move { grpc.create_sandbox_template(request).await } + }) + .await?; + sandbox_template_from_response(response.template) + } + + /// Fetch a reusable sandbox template by name in this workspace. + pub async fn get_sandbox_template(&self, name: &str) -> Result { + let response = self + .client + .unary(|mut grpc| { + let request = proto::GetSandboxTemplateRequest { + name: name.to_string(), + workspace: self.workspace.clone(), + }; + async move { grpc.get_sandbox_template(request).await } + }) + .await?; + sandbox_template_from_response(response.template) + } + + /// List reusable sandbox templates in this workspace, or across all workspaces. + pub async fn list_sandbox_templates( + &self, + opts: SandboxTemplateListOptions, + ) -> Result> { + let response = self + .client + .unary(|mut grpc| { + let request = proto::ListSandboxTemplatesRequest { + limit: opts.limit, + offset: opts.offset, + workspace: if opts.all_workspaces { + String::new() + } else { + self.workspace.clone() + }, + all_workspaces: opts.all_workspaces, + label_selector: opts.label_selector.clone(), + }; + async move { grpc.list_sandbox_templates(request).await } + }) + .await?; + Ok(response.templates) + } + + /// Delete a reusable sandbox template by name in this workspace. + pub async fn delete_sandbox_template(&self, name: &str) -> Result { + let response = self + .client + .unary(|mut grpc| { + let request = proto::DeleteSandboxTemplateRequest { + name: name.to_string(), + workspace: self.workspace.clone(), + }; + async move { grpc.delete_sandbox_template(request).await } + }) + .await?; + Ok(response.deleted) + } + /// Fetch a sandbox by name in this workspace. pub async fn get_sandbox(&self, name: &str) -> Result { let response = self @@ -837,6 +1007,36 @@ fn create_sandbox_request(spec: SandboxSpec) -> proto::CreateSandboxRequest { annotations: HashMap::new(), workspace: String::new(), await_main_process_attachment: false, + workload_template_name: String::new(), + } +} + +fn create_sandbox_from_template_request( + spec: SandboxTemplateCreateSpec, +) -> proto::CreateSandboxRequest { + let SandboxTemplateCreateSpec { + name, + template_name, + labels, + providers, + command, + tty, + policy, + } = spec; + proto::CreateSandboxRequest { + spec: Some(proto::SandboxSpec { + providers, + command, + tty, + policy, + ..proto::SandboxSpec::default() + }), + name: name.unwrap_or_default(), + labels, + annotations: HashMap::new(), + workspace: String::new(), + workload_template_name: template_name, + await_main_process_attachment: false, } } @@ -846,6 +1046,13 @@ fn sandbox_from_response(sandbox: Option) -> Result .ok_or_else(|| SdkError::invalid_config("sandbox missing from gateway response")) } +fn sandbox_template_from_response( + template: Option, +) -> Result { + template + .ok_or_else(|| SdkError::invalid_config("sandbox template missing from gateway response")) +} + fn map_status(status: tonic::Status) -> SdkError { let message = status.message().to_string(); match status.code() { diff --git a/crates/openshell-sdk/src/lib.rs b/crates/openshell-sdk/src/lib.rs index dbf2524a2a..985c7ecc05 100644 --- a/crates/openshell-sdk/src/lib.rs +++ b/crates/openshell-sdk/src/lib.rs @@ -6,7 +6,8 @@ //! Two layers: //! //! - [`OpenShellClient`] — the high-level sandbox-focused MVP surface: -//! health, sandbox CRUD, readiness/deletion waits, non-streaming exec. +//! health, sandbox CRUD, reusable sandbox templates, readiness/deletion +//! waits, and non-streaming exec. //! - [`raw`] — direct access to the generated tonic clients for RPCs the //! curated surface doesn't yet cover (inference, providers, policy, logs, //! settings, SSH, forwarding). @@ -46,6 +47,8 @@ pub use config::{AuthConfig, ClientConfig}; pub use error::SdkError; pub use refresh::{Refresh, RefreshError, RefreshedToken, TokenSource}; pub use types::{ - ExecOptions, ExecResult, Health, ListOptions, SandboxPhase, SandboxRef, SandboxSpec, - ServiceStatus, WorkspaceRef, + ExecOptions, ExecResult, Health, ListOptions, SandboxPhase, SandboxRef, SandboxResources, + SandboxServiceLevel, SandboxSpec, SandboxStartup, SandboxTemplateCreateSpec, + SandboxTemplateListOptions, SandboxWorkloadConfig, SandboxWorkloadTemplate, + SandboxWorkloadTemplateProvenance, SandboxWorkloadTemplateSpec, ServiceStatus, WorkspaceRef, }; diff --git a/crates/openshell-sdk/src/raw.rs b/crates/openshell-sdk/src/raw.rs index e974b19259..35d91f3325 100644 --- a/crates/openshell-sdk/src/raw.rs +++ b/crates/openshell-sdk/src/raw.rs @@ -22,11 +22,15 @@ pub use openshell_core::proto; pub use openshell_core::proto::inference_client::InferenceClient; pub use openshell_core::proto::open_shell_client::OpenShellClient as GrpcClient; pub use openshell_core::proto::{ - CreateSandboxRequest, CreateWorkspaceRequest, DeleteSandboxRequest, DeleteWorkspaceRequest, - ExecSandboxRequest, GetSandboxRequest, GetWorkspaceRequest, HealthRequest, - ListProvidersRequest, ListSandboxesRequest, ListWorkspacesRequest, Sandbox, - SandboxPhase as ProtoSandboxPhase, SandboxSpec as ProtoSandboxSpec, SandboxTemplate, - ServiceStatus as ProtoServiceStatus, StartSandboxRequest, StopSandboxRequest, Workspace, + CreateSandboxRequest, CreateSandboxTemplateRequest, CreateWorkspaceRequest, + DeleteSandboxRequest, DeleteSandboxTemplateRequest, DeleteWorkspaceRequest, ExecSandboxRequest, + GetSandboxRequest, GetSandboxTemplateRequest, GetWorkspaceRequest, HealthRequest, + ListProvidersRequest, ListSandboxTemplatesRequest, ListSandboxesRequest, ListWorkspacesRequest, + Sandbox, SandboxPhase as ProtoSandboxPhase, SandboxResources, SandboxServiceLevel, + SandboxSpec as ProtoSandboxSpec, SandboxStartup, SandboxTemplate, SandboxTemplateResponse, + SandboxWorkloadConfig, SandboxWorkloadTemplate, SandboxWorkloadTemplateProvenance, + SandboxWorkloadTemplateSpec, ServiceStatus as ProtoServiceStatus, StartSandboxRequest, + StopSandboxRequest, Workspace, }; /// Type alias for the gRPC client wrapped in the SDK's auth interceptor. diff --git a/crates/openshell-sdk/src/types.rs b/crates/openshell-sdk/src/types.rs index 9cb7171fd4..db2944474b 100644 --- a/crates/openshell-sdk/src/types.rs +++ b/crates/openshell-sdk/src/types.rs @@ -117,6 +117,60 @@ pub struct SandboxSpec { pub tty: bool, } +/// Caller intent for creating a sandbox from a named workload template. +#[derive(Clone, Debug, Default)] +pub struct SandboxTemplateCreateSpec { + /// Optional user-supplied sandbox name. When empty the server generates one. + pub name: Option, + /// Workspace-scoped template name to resolve at creation time. + pub template_name: String, + /// Labels attached to the sandbox. + pub labels: HashMap, + /// Provider names to attach. + pub providers: Vec, + /// Exact canonical command. Empty selects the gateway's scratch login shell. + pub command: Vec, + /// Allocate a retained pseudo-terminal for the canonical command. + pub tty: bool, + /// Create-time sandbox policy. The named workload template supplies runtime + /// workload fields; policy remains part of the sandbox's governance spec. + pub policy: Option, +} + +/// Reusable sandbox workload template resource. +/// +/// This is a raw proto alias because template specs intentionally expose the +/// full portable workload shape plus driver-owned config. +pub type SandboxWorkloadTemplate = proto::SandboxWorkloadTemplate; + +/// Desired reusable workload shape for a [`SandboxWorkloadTemplate`]. +pub type SandboxWorkloadTemplateSpec = proto::SandboxWorkloadTemplateSpec; + +/// Portable sandbox workload configuration for template-backed sandboxes. +pub type SandboxWorkloadConfig = proto::SandboxWorkloadConfig; + +/// Portable resource requirements for template-backed sandboxes. +pub type SandboxResources = proto::SandboxResources; + +/// Desired service level for sandboxes created from a template. +pub type SandboxServiceLevel = proto::SandboxServiceLevel; + +/// Startup service-level settings for template-backed sandboxes. +pub type SandboxStartup = proto::SandboxStartup; + +/// Options for listing reusable sandbox templates. +#[derive(Clone, Debug, Default)] +pub struct SandboxTemplateListOptions { + /// Maximum templates to return. `0` defers to the server default. + pub limit: u32, + /// Offset into the result list. + pub offset: u32, + /// Optional label selector in `key=value,key2=value2` form. + pub label_selector: String, + /// List templates across all workspaces. + pub all_workspaces: bool, +} + /// Reference to a sandbox owned by the gateway. #[derive(Clone, Debug)] #[non_exhaustive] @@ -128,12 +182,28 @@ pub struct SandboxRef { pub labels: HashMap, pub resource_version: u64, pub exit_code: Option, + pub created_from_workload_template: Option, +} + +/// Reusable workload template revision used to create a sandbox. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct SandboxWorkloadTemplateProvenance { + pub name: String, + pub resource_version: String, } impl SandboxRef { pub(crate) fn from_proto(sandbox: proto::Sandbox) -> Self { let phase = sandbox.phase().into(); let exit_code = sandbox.status.as_ref().and_then(|status| status.exit_code); + let created_from_workload_template = + sandbox + .created_from_workload_template + .map(|p| SandboxWorkloadTemplateProvenance { + name: p.name, + resource_version: p.resource_version, + }); let meta = sandbox.metadata.unwrap_or_default(); Self { id: meta.id, @@ -143,6 +213,7 @@ impl SandboxRef { labels: meta.labels, resource_version: meta.resource_version, exit_code, + created_from_workload_template, } } } diff --git a/crates/openshell-sdk/tests/client_mock.rs b/crates/openshell-sdk/tests/client_mock.rs index 1cdac7da41..58633ceb17 100644 --- a/crates/openshell-sdk/tests/client_mock.rs +++ b/crates/openshell-sdk/tests/client_mock.rs @@ -12,7 +12,8 @@ use openshell_core::proto; use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer}; use openshell_sdk::{ AuthConfig, ClientConfig, ExecOptions, ListOptions, OpenShellClient, Refresh, RefreshError, - RefreshedToken, SandboxPhase, SandboxSpec, ServiceStatus as SdkServiceStatus, + RefreshedToken, SandboxPhase, SandboxSpec, SandboxTemplateCreateSpec, + SandboxTemplateListOptions, ServiceStatus as SdkServiceStatus, }; use std::collections::HashMap; use std::sync::Arc; @@ -29,6 +30,10 @@ struct MockState { last_get_name: Mutex>, last_get_workspace: Mutex>, last_create: Mutex>, + last_template_create: Mutex>, + last_template_get: Mutex>, + last_template_list: Mutex>, + last_template_delete: Mutex>, last_delete_name: Mutex>, last_delete_workspace: Mutex>, last_stop: Mutex>, @@ -61,6 +66,11 @@ fn sandbox_with_phase_ws( phase: proto::SandboxPhase, workspace: &str, ) -> proto::Sandbox { + let created_from_workload_template = + (name == "from-template").then(|| proto::SandboxWorkloadTemplateProvenance { + name: "python".to_string(), + resource_version: "7".to_string(), + }); proto::Sandbox { metadata: Some(proto::datamodel::v1::ObjectMeta { id: format!("id-{name}"), @@ -77,6 +87,7 @@ fn sandbox_with_phase_ws( phase: phase.into(), ..Default::default() }), + created_from_workload_template, } } @@ -98,6 +109,34 @@ fn workspace_proto(name: &str, phase: proto::datamodel::v1::WorkspacePhase) -> p } } +fn workload_template_proto(name: &str, workspace: &str) -> proto::SandboxWorkloadTemplate { + proto::SandboxWorkloadTemplate { + metadata: Some(proto::datamodel::v1::ObjectMeta { + id: format!("template-{workspace}-{name}"), + name: name.to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + annotations: HashMap::new(), + resource_version: 1, + deletion_timestamp_ms: 0, + workspace: workspace.to_string(), + }), + spec: Some(proto::SandboxWorkloadTemplateSpec { + workload: Some(proto::SandboxWorkloadConfig { + image: format!("ghcr.io/test/{name}:latest"), + environment: HashMap::new(), + resources: Some(proto::SandboxResources { + cpu: "1".to_string(), + memory: "512Mi".to_string(), + ..proto::SandboxResources::default() + }), + }), + driver_config: None, + desired_service_level: None, + }), + } +} + #[tonic::async_trait] impl OpenShell for TestOpenShell { async fn report_main_process_exit( @@ -177,6 +216,62 @@ impl OpenShell for TestOpenShell { })) } + async fn create_sandbox_template( + &self, + request: tonic::Request, + ) -> Result, Status> { + let request = request.into_inner(); + let template = request + .template + .clone() + .ok_or_else(|| Status::invalid_argument("missing template"))?; + *self.state.last_template_create.lock().await = Some(request); + Ok(Response::new(proto::SandboxTemplateResponse { + template: Some(template), + })) + } + + async fn get_sandbox_template( + &self, + request: tonic::Request, + ) -> Result, Status> { + let request = request.into_inner(); + let workspace = if request.workspace.is_empty() { + "default" + } else { + &request.workspace + }; + let template = workload_template_proto(&request.name, workspace); + *self.state.last_template_get.lock().await = Some(request); + Ok(Response::new(proto::SandboxTemplateResponse { + template: Some(template), + })) + } + + async fn list_sandbox_templates( + &self, + request: tonic::Request, + ) -> Result, Status> { + let request = request.into_inner(); + *self.state.last_template_list.lock().await = Some(request); + Ok(Response::new(proto::ListSandboxTemplatesResponse { + templates: vec![ + workload_template_proto("python", "default"), + workload_template_proto("cuda", "gpu"), + ], + })) + } + + async fn delete_sandbox_template( + &self, + request: tonic::Request, + ) -> Result, Status> { + *self.state.last_template_delete.lock().await = Some(request.into_inner()); + Ok(Response::new(proto::DeleteSandboxTemplateResponse { + deleted: true, + })) + } + async fn stop_sandbox( &self, request: tonic::Request, @@ -817,6 +912,92 @@ async fn create_sandbox_passes_spec_through() { ); } +#[tokio::test] +async fn create_sandbox_from_template_passes_template_name() { + let state = Arc::new(MockState::default()); + let endpoint = start_mock(state.clone()).await; + let client = connect(&endpoint).await; + + let sandbox = client + .create_sandbox_from_template(SandboxTemplateCreateSpec { + name: Some("from-template".to_string()), + template_name: "python".to_string(), + providers: vec!["openai".to_string()], + command: vec!["python".to_string(), "-m".to_string(), "agent".to_string()], + tty: false, + policy: Some(proto::SandboxPolicy { + version: 1, + ..Default::default() + }), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(sandbox.name, "from-template"); + + let observed = state.last_create.lock().await.clone().unwrap(); + assert_eq!(observed.name, "from-template"); + assert_eq!(observed.workload_template_name, "python"); + let observed_spec = observed.spec.unwrap(); + assert_eq!(observed_spec.providers, vec!["openai".to_string()]); + assert_eq!(observed_spec.command, vec!["python", "-m", "agent"]); + assert!(!observed_spec.tty); + assert_eq!(observed_spec.policy.unwrap().version, 1); +} + +#[tokio::test] +async fn sandbox_template_crud_uses_default_workspace() { + let state = Arc::new(MockState::default()); + let endpoint = start_mock(state.clone()).await; + let client = connect(&endpoint).await; + + let created = client + .create_sandbox_template(workload_template_proto("python", "")) + .await + .unwrap(); + assert_eq!(created.metadata.as_ref().unwrap().name, "python"); + + let observed_create = state.last_template_create.lock().await.clone().unwrap(); + assert!(observed_create.workspace.is_empty()); + assert_eq!( + observed_create + .template + .as_ref() + .and_then(|template| template.metadata.as_ref()) + .unwrap() + .name, + "python" + ); + + let fetched = client.get_sandbox_template("python").await.unwrap(); + assert_eq!(fetched.metadata.as_ref().unwrap().name, "python"); + let observed_get = state.last_template_get.lock().await.clone().unwrap(); + assert_eq!(observed_get.name, "python"); + assert!(observed_get.workspace.is_empty()); + + let listed = client + .list_sandbox_templates(SandboxTemplateListOptions { + limit: 10, + offset: 2, + label_selector: String::new(), + all_workspaces: true, + }) + .await + .unwrap(); + assert_eq!(listed.len(), 2); + let observed_list = state.last_template_list.lock().await.clone().unwrap(); + assert_eq!(observed_list.limit, 10); + assert_eq!(observed_list.offset, 2); + assert!(observed_list.workspace.is_empty()); + assert!(observed_list.all_workspaces); + + let deleted = client.delete_sandbox_template("python").await.unwrap(); + assert!(deleted); + let observed_delete = state.last_template_delete.lock().await.clone().unwrap(); + assert_eq!(observed_delete.name, "python"); + assert!(observed_delete.workspace.is_empty()); +} + #[tokio::test] async fn get_sandbox_sends_name_and_maps_phase() { let state = Arc::new(MockState { @@ -835,6 +1016,21 @@ async fn get_sandbox_sends_name_and_maps_phase() { assert_eq!(observed.as_deref(), Some("my-box")); } +#[tokio::test] +async fn get_sandbox_preserves_workload_template_provenance() { + let state = Arc::new(MockState::default()); + let endpoint = start_mock(state.clone()).await; + let client = connect(&endpoint).await; + + let sandbox = client.get_sandbox("from-template").await.unwrap(); + + let provenance = sandbox + .created_from_workload_template + .expect("template provenance"); + assert_eq!(provenance.name, "python"); + assert_eq!(provenance.resource_version, "7"); +} + #[tokio::test] async fn list_sandboxes_propagates_filters() { let state = Arc::new(MockState::default()); @@ -1137,6 +1333,33 @@ async fn workspace_scoped_create_passes_workspace() { assert_eq!(observed.workspace, "staging"); } +#[tokio::test] +async fn workspace_scoped_create_from_template_passes_workspace() { + let state = Arc::new(MockState::default()); + let endpoint = start_mock(state.clone()).await; + let client = connect(&endpoint).await; + + let sandbox = client + .workspace("staging") + .create_sandbox_from_template(SandboxTemplateCreateSpec { + name: Some("from-template".to_string()), + template_name: "python".to_string(), + policy: Some(proto::SandboxPolicy { + version: 2, + ..Default::default() + }), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(sandbox.name, "from-template"); + + let observed = state.last_create.lock().await.clone().unwrap(); + assert_eq!(observed.workspace, "staging"); + assert_eq!(observed.workload_template_name, "python"); + assert_eq!(observed.spec.unwrap().policy.unwrap().version, 2); +} + #[tokio::test] async fn workspace_scoped_get_passes_workspace() { let state = Arc::new(MockState { @@ -1169,6 +1392,50 @@ async fn workspace_scoped_list_passes_workspace() { assert!(!observed.all_workspaces); } +#[tokio::test] +async fn workspace_scoped_sandbox_template_crud_passes_workspace() { + let state = Arc::new(MockState::default()); + let endpoint = start_mock(state.clone()).await; + let client = connect(&endpoint).await; + let ws = client.workspace("staging"); + + ws.create_sandbox_template(workload_template_proto("python", "staging")) + .await + .unwrap(); + let observed_create = state.last_template_create.lock().await.clone().unwrap(); + assert_eq!(observed_create.workspace, "staging"); + + ws.get_sandbox_template("python").await.unwrap(); + let observed_get = state.last_template_get.lock().await.clone().unwrap(); + assert_eq!(observed_get.name, "python"); + assert_eq!(observed_get.workspace, "staging"); + + let listed = ws + .list_sandbox_templates(SandboxTemplateListOptions::default()) + .await + .unwrap(); + assert_eq!(listed.len(), 2); + let observed_list = state.last_template_list.lock().await.clone().unwrap(); + assert_eq!(observed_list.workspace, "staging"); + assert!(!observed_list.all_workspaces); + + ws.list_sandbox_templates(SandboxTemplateListOptions { + all_workspaces: true, + ..Default::default() + }) + .await + .unwrap(); + let observed_all = state.last_template_list.lock().await.clone().unwrap(); + assert!(observed_all.workspace.is_empty()); + assert!(observed_all.all_workspaces); + + let deleted = ws.delete_sandbox_template("python").await.unwrap(); + assert!(deleted); + let observed_delete = state.last_template_delete.lock().await.clone().unwrap(); + assert_eq!(observed_delete.name, "python"); + assert_eq!(observed_delete.workspace, "staging"); +} + #[tokio::test] async fn workspace_scoped_delete_passes_workspace() { let state = Arc::new(MockState::default()); diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 053b08de9c..4fdae8c38e 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -35,7 +35,7 @@ use openshell_core::proto::compute::v1::{ }; use openshell_core::proto::{ PlatformEvent, Sandbox, SandboxCondition, SandboxPhase, SandboxSpec, SandboxStatus, - SandboxTemplate, ServiceEndpoint, SshSession, + SandboxTemplate, SandboxWorkloadTemplate, ServiceEndpoint, SshSession, }; use openshell_core::telemetry::TelemetryComputeDriver; use openshell_core::{ObjectLabels, ObjectWorkspace}; @@ -3974,6 +3974,12 @@ impl ObjectType for Sandbox { } } +impl ObjectType for SandboxWorkloadTemplate { + fn object_type() -> &'static str { + "sandbox_workload_template" + } +} + fn compute_error_from_status(status: Status) -> ComputeError { match status.code() { Code::AlreadyExists => ComputeError::AlreadyExists, diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index 957a77cbac..8143e6058e 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -17,29 +17,31 @@ use openshell_core::proto::{ AttachSandboxProviderRequest, AttachSandboxProviderResponse, ClearDraftChunksRequest, ClearDraftChunksResponse, ComputeDriverCapabilities, ComputeDriverInfo, ConfigureProviderRefreshRequest, ConfigureProviderRefreshResponse, CreateProviderRequest, - CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, - CreateWorkspaceRequest, CreateWorkspaceResponse, DeleteProviderProfileRequest, - DeleteProviderProfileResponse, DeleteProviderRefreshRequest, DeleteProviderRefreshResponse, - DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DeleteServiceRequest, DeleteServiceResponse, DeleteWorkspaceRequest, DeleteWorkspaceResponse, - DetachSandboxProviderRequest, DetachSandboxProviderResponse, EditDraftChunkRequest, - EditDraftChunkResponse, ExchangeProviderSubjectTokenRequest, - ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, - ExposeServiceRequest, FinalizeMainProcessExitRequest, FinalizeMainProcessExitResponse, - GatewayMessage, GetCurrentUserRequest, GetCurrentUserResponse, GetDraftHistoryRequest, - GetDraftHistoryResponse, GetDraftPolicyRequest, GetDraftPolicyResponse, + CreateSandboxRequest, CreateSandboxTemplateRequest, CreateSshSessionRequest, + CreateSshSessionResponse, CreateWorkspaceRequest, CreateWorkspaceResponse, + DeleteProviderProfileRequest, DeleteProviderProfileResponse, DeleteProviderRefreshRequest, + DeleteProviderRefreshResponse, DeleteProviderRequest, DeleteProviderResponse, + DeleteSandboxRequest, DeleteSandboxResponse, DeleteSandboxTemplateRequest, + DeleteSandboxTemplateResponse, DeleteServiceRequest, DeleteServiceResponse, + DeleteWorkspaceRequest, DeleteWorkspaceResponse, DetachSandboxProviderRequest, + DetachSandboxProviderResponse, EditDraftChunkRequest, EditDraftChunkResponse, + ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, + ExecSandboxInput, ExecSandboxRequest, ExposeServiceRequest, FinalizeMainProcessExitRequest, + FinalizeMainProcessExitResponse, GatewayMessage, GetCurrentUserRequest, GetCurrentUserResponse, + GetDraftHistoryRequest, GetDraftHistoryResponse, GetDraftPolicyRequest, GetDraftPolicyResponse, GetGatewayConfigRequest, GetGatewayConfigResponse, GetGatewayInfoRequest, GetGatewayInfoResponse, GetProviderProfileRequest, GetProviderRefreshStatusRequest, GetProviderRefreshStatusResponse, GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxLogsRequest, GetSandboxLogsResponse, GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, - GetServiceRequest, GetWorkspaceRequest, GetWorkspaceResponse, HealthRequest, HealthResponse, - ImportProviderProfilesRequest, ImportProviderProfilesResponse, IssueSandboxTokenRequest, - IssueSandboxTokenResponse, LintProviderProfilesRequest, LintProviderProfilesResponse, - ListProviderProfilesRequest, ListProviderProfilesResponse, ListProvidersRequest, - ListProvidersResponse, ListSandboxPoliciesRequest, ListSandboxPoliciesResponse, - ListSandboxProvidersRequest, ListSandboxProvidersResponse, ListSandboxesRequest, + GetSandboxTemplateRequest, GetServiceRequest, GetWorkspaceRequest, GetWorkspaceResponse, + HealthRequest, HealthResponse, ImportProviderProfilesRequest, ImportProviderProfilesResponse, + IssueSandboxTokenRequest, IssueSandboxTokenResponse, LintProviderProfilesRequest, + LintProviderProfilesResponse, ListProviderProfilesRequest, ListProviderProfilesResponse, + ListProvidersRequest, ListProvidersResponse, ListSandboxPoliciesRequest, + ListSandboxPoliciesResponse, ListSandboxProvidersRequest, ListSandboxProvidersResponse, + ListSandboxTemplatesRequest, ListSandboxTemplatesResponse, ListSandboxesRequest, ListSandboxesResponse, ListServicesRequest, ListServicesResponse, ListWorkspaceMembersRequest, ListWorkspaceMembersResponse, ListWorkspacesRequest, ListWorkspacesResponse, ProviderProfileResponse, ProviderResponse, PushSandboxLogsRequest, PushSandboxLogsResponse, @@ -48,12 +50,12 @@ use openshell_core::proto::{ RemoveWorkspaceMemberResponse, ReportMainProcessExitRequest, ReportMainProcessExitResponse, ReportPolicyStatusRequest, ReportPolicyStatusResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, RotateProviderCredentialRequest, RotateProviderCredentialResponse, - SandboxResponse, ServiceEndpointResponse, ServiceStatus, StartSandboxRequest, - StopSandboxRequest, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, - SupervisorMessage, TcpForwardFrame, UndoDraftChunkRequest, UndoDraftChunkResponse, - UpdateConfigRequest, UpdateConfigResponse, UpdateProviderProfilesRequest, - UpdateProviderProfilesResponse, UpdateProviderRequest, WatchSandboxRequest, - open_shell_server::OpenShell, + SandboxResponse, SandboxTemplateResponse, ServiceEndpointResponse, ServiceStatus, + StartSandboxRequest, StopSandboxRequest, SubmitPolicyAnalysisRequest, + SubmitPolicyAnalysisResponse, SupervisorMessage, TcpForwardFrame, UndoDraftChunkRequest, + UndoDraftChunkResponse, UpdateConfigRequest, UpdateConfigResponse, + UpdateProviderProfilesRequest, UpdateProviderProfilesResponse, UpdateProviderRequest, + WatchSandboxRequest, open_shell_server::OpenShell, }; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -299,6 +301,34 @@ impl OpenShell for OpenShellService { sandbox::handle_list_sandboxes(&self.state, request).await } + async fn create_sandbox_template( + &self, + request: Request, + ) -> Result, Status> { + sandbox::handle_create_sandbox_template(&self.state, request).await + } + + async fn get_sandbox_template( + &self, + request: Request, + ) -> Result, Status> { + sandbox::handle_get_sandbox_template(&self.state, request).await + } + + async fn list_sandbox_templates( + &self, + request: Request, + ) -> Result, Status> { + sandbox::handle_list_sandbox_templates(&self.state, request).await + } + + async fn delete_sandbox_template( + &self, + request: Request, + ) -> Result, Status> { + sandbox::handle_delete_sandbox_template(&self.state, request).await + } + async fn list_sandbox_providers( &self, request: Request, diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 35765781c4..764c0bbfde 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -11365,6 +11365,7 @@ mod tests { ..SandboxSpec::default() }), status: None, + ..Sandbox::default() }; sandbox.set_phase(SandboxPhase::Ready as i32); store.put_message(&sandbox).await.unwrap(); @@ -11401,6 +11402,7 @@ mod tests { }), spec: Some(SandboxSpec::default()), status: None, + ..Sandbox::default() }; sandbox.set_phase(SandboxPhase::Ready as i32); store.put_message(&sandbox).await.unwrap(); diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 5364e0541e..d4d08da251 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -16,14 +16,19 @@ use crate::auth::workspace_authz::{ use crate::persistence::{ObjectLabels, ObjectType, WriteCondition, generate_name}; use futures::future; use openshell_core::net::set_tcp_nodelay_best_effort; +use openshell_core::proto::datamodel::v1::ObjectMeta; use openshell_core::proto::{ AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateSandboxRequest, - CreateSshSessionRequest, CreateSshSessionResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DetachSandboxProviderRequest, DetachSandboxProviderResponse, ExecSandboxEvent, ExecSandboxExit, - ExecSandboxInput, ExecSandboxRequest, ExecSandboxStderr, ExecSandboxStdout, GetSandboxRequest, - ListSandboxProvidersRequest, ListSandboxProvidersResponse, ListSandboxesRequest, - ListSandboxesResponse, Provider, RevokeSshSessionRequest, RevokeSshSessionResponse, - SandboxResponse, SandboxStreamEvent, SshRelayTarget, StartSandboxRequest, StopSandboxRequest, + CreateSandboxTemplateRequest, CreateSshSessionRequest, CreateSshSessionResponse, + DeleteSandboxRequest, DeleteSandboxResponse, DeleteSandboxTemplateRequest, + DeleteSandboxTemplateResponse, DetachSandboxProviderRequest, DetachSandboxProviderResponse, + ExecSandboxEvent, ExecSandboxExit, ExecSandboxInput, ExecSandboxRequest, ExecSandboxStderr, + ExecSandboxStdout, GetSandboxRequest, GetSandboxTemplateRequest, ListSandboxProvidersRequest, + ListSandboxProvidersResponse, ListSandboxTemplatesRequest, ListSandboxTemplatesResponse, + ListSandboxesRequest, ListSandboxesResponse, Provider, ResourceRequirements, + RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResources, SandboxResponse, + SandboxSpec, SandboxStreamEvent, SandboxTemplateResponse, SandboxWorkloadTemplate, + SandboxWorkloadTemplateProvenance, SshRelayTarget, StartSandboxRequest, StopSandboxRequest, TcpForwardFrame, TcpForwardInit, TcpRelayTarget, WatchSandboxRequest, relay_open, tcp_forward_init, }; @@ -31,8 +36,9 @@ use openshell_core::proto::{Sandbox, SandboxPhase, SandboxTemplate, SshSession}; use openshell_core::telemetry::{ LifecycleOperation, LifecycleResource, SandboxTemplateSource, TelemetryOutcome, }; -use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; +use openshell_core::{GetResourceVersion, ObjectId, ObjectName, ObjectWorkspace}; use prost::Message; +use prost_types::{Struct, Value, value::Kind}; use std::collections::HashMap; use std::net::IpAddr; use std::pin::Pin; @@ -52,14 +58,16 @@ use super::provider::{ get_provider_record, is_valid_env_key, validate_provider_environment_keys_unique_with_catalog, }; use super::validation::{ - level_matches, source_matches, validate_exec_request_fields, - validate_no_reserved_provider_policy_keys, validate_policy_safety, validate_sandbox_spec, + level_matches, source_matches, validate_dns1123_label, validate_exec_request_fields, + validate_no_reserved_provider_policy_keys, validate_policy_safety, + validate_sandbox_governance_spec, validate_sandbox_spec, }; use super::{MAX_PAGE_SIZE, MAX_PROVIDERS, MAX_ROUTABLE_NAME_LEN, clamp_limit}; use crate::persistence::current_time_ms; const TCP_FORWARD_CHUNK_SIZE: usize = 64 * 1024; const NO_LOGIN_SHELL_ENV: (&str, &str) = ("OPENSHELL_NO_LOGIN_SHELL", "1"); +const MAX_TEMPLATES_PER_WORKSPACE: u32 = 1000; #[derive(Debug)] pub struct WatchSandboxStream { @@ -158,30 +166,69 @@ pub(super) async fn handle_create_sandbox( ) -> Result, Status> { let create_request = request.get_ref().clone(); let result = handle_create_sandbox_inner(state, request).await; + let created_sandbox = result + .as_ref() + .ok() + .and_then(|response| response.get_ref().sandbox.as_ref()); emit_sandbox_create_telemetry( state, &create_request, + created_sandbox, TelemetryOutcome::from_success(result.is_ok()), ); result } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct SandboxCreateTelemetryAttrs { + requested_gpu: bool, + provider_count: u64, + has_custom_policy: bool, + template_source: SandboxTemplateSource, +} + fn emit_sandbox_create_telemetry( state: &Arc, request: &CreateSandboxRequest, + created_sandbox: Option<&Sandbox>, outcome: TelemetryOutcome, ) { let compute_driver = state.compute.telemetry_compute_driver(); + let attrs = sandbox_create_telemetry_attrs(request, created_sandbox); + openshell_core::telemetry::emit_sandbox_create( + outcome, + attrs.requested_gpu, + attrs.provider_count, + attrs.has_custom_policy, + attrs.template_source, + compute_driver, + ); +} + +fn sandbox_create_telemetry_attrs( + request: &CreateSandboxRequest, + created_sandbox: Option<&Sandbox>, +) -> SandboxCreateTelemetryAttrs { + if !request.workload_template_name.trim().is_empty() { + let spec = created_sandbox + .and_then(|sandbox| sandbox.spec.as_ref()) + .or(request.spec.as_ref()); + return SandboxCreateTelemetryAttrs { + requested_gpu: spec.is_some_and(|spec| { + openshell_core::gpu::sandbox_gpu_requested(spec.resource_requirements.as_ref()) + }), + provider_count: spec.map_or(0, |spec| spec.providers.len() as u64), + has_custom_policy: spec.is_some_and(|spec| spec.policy.is_some()), + template_source: SandboxTemplateSource::WorkloadTemplate, + }; + } let Some(spec) = request.spec.as_ref() else { - openshell_core::telemetry::emit_sandbox_create( - outcome, - false, - 0, - false, - SandboxTemplateSource::Undefined, - compute_driver, - ); - return; + return SandboxCreateTelemetryAttrs { + requested_gpu: false, + provider_count: 0, + has_custom_policy: false, + template_source: SandboxTemplateSource::Undefined, + }; }; let template_source = if spec .template @@ -194,14 +241,12 @@ fn emit_sandbox_create_telemetry( }; let gpu_requested = openshell_core::gpu::sandbox_gpu_requested(spec.resource_requirements.as_ref()); - openshell_core::telemetry::emit_sandbox_create( - outcome, - gpu_requested, - spec.providers.len() as u64, - spec.policy.is_some(), + SandboxCreateTelemetryAttrs { + requested_gpu: gpu_requested, + provider_count: spec.providers.len() as u64, + has_custom_policy: spec.policy.is_some(), template_source, - compute_driver, - ); + } } async fn handle_create_sandbox_inner( @@ -211,27 +256,9 @@ async fn handle_create_sandbox_inner( let principal = super::extract_principal(&request)?; let request = request.into_inner(); let await_main_process_attachment = request.await_main_process_attachment; - let mut spec = request - .spec - .ok_or_else(|| Status::invalid_argument("spec is required"))?; - - // Every newly persisted sandbox has one explicit canonical process. This - // portable default also preserves compatibility with callers compiled - // before the main-process field was introduced. - if spec.command.is_empty() { - spec.command = vec!["/bin/bash".to_string(), "-l".to_string()]; - spec.tty = true; - } - - // Validate field sizes before any I/O (fail fast on oversized payloads). - validate_sandbox_spec(&request.name, &spec)?; + let workload_template_name = request.workload_template_name.trim().to_string(); - // Validate labels (keys and values must meet Kubernetes requirements). - for (key, value) in &request.labels { - crate::grpc::validation::validate_label_key(key)?; - crate::grpc::validation::validate_label_value(value)?; - } - crate::grpc::validation::validate_annotations(&request.annotations, "annotations")?; + validate_create_sandbox_request_pre_io(&request, &workload_template_name)?; let authz = authorize_workspace( &state.store, @@ -245,6 +272,42 @@ async fn handle_create_sandbox_inner( .await? .ensure_active()?; + let (mut spec, created_from_workload_template) = if workload_template_name.is_empty() { + let spec = request + .spec + .ok_or_else(|| Status::invalid_argument("spec is required"))?; + (spec, None) + } else { + let governance_spec = request.spec.unwrap_or_default(); + let template = state + .store + .get_message_by_name::(&workspace, &workload_template_name) + .await + .map_err(|e| Status::internal(format!("fetch sandbox template failed: {e}")))? + .ok_or_else(|| Status::not_found("sandbox template not found"))?; + let provenance = SandboxWorkloadTemplateProvenance { + name: template.object_name().to_string(), + resource_version: template.get_resource_version().to_string(), + }; + let mut resolved = sandbox_spec_from_stored_workload_template(&template)?; + resolved.policy = governance_spec.policy; + resolved.providers = governance_spec.providers; + resolved.command = governance_spec.command; + resolved.tty = governance_spec.tty; + (resolved, Some(provenance)) + }; + + // Every newly persisted sandbox has one explicit canonical process. This + // portable default also preserves compatibility with callers compiled + // before the main-process field was introduced. + if spec.command.is_empty() { + spec.command = vec!["/bin/bash".to_string(), "-l".to_string()]; + spec.tty = true; + } + + // Validate field sizes before any create-side effects. + validate_sandbox_spec(&request.name, &spec)?; + let _sandbox_sync_guard = if spec.providers.is_empty() { None } else { @@ -274,7 +337,7 @@ async fn handle_create_sandbox_inner( // Ensure the template always carries the resolved image. let template = spec.template.get_or_insert_with(SandboxTemplate::default); - if template.image.is_empty() { + if template.image.trim().is_empty() { template.image = state.compute.default_image().to_string(); } @@ -302,7 +365,7 @@ async fn handle_create_sandbox_inner( let now_ms = current_time_ms(); let mut sandbox = Sandbox { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + metadata: Some(ObjectMeta { id: id.clone(), name: name.clone(), created_at_ms: now_ms, @@ -314,6 +377,7 @@ async fn handle_create_sandbox_inner( }), spec: Some(spec), status: None, + created_from_workload_template, }; sandbox.set_phase(SandboxPhase::Provisioning as i32); @@ -372,6 +436,135 @@ async fn handle_create_sandbox_inner( })) } +fn validate_create_sandbox_request_pre_io( + request: &CreateSandboxRequest, + workload_template_name: &str, +) -> Result<(), Status> { + // Validate labels (keys and values must meet Kubernetes requirements). + for (key, value) in &request.labels { + crate::grpc::validation::validate_label_key(key)?; + crate::grpc::validation::validate_label_value(value)?; + } + crate::grpc::validation::validate_annotations(&request.annotations, "annotations")?; + + if workload_template_name.is_empty() { + let spec = request + .spec + .as_ref() + .ok_or_else(|| Status::invalid_argument("spec is required"))?; + return validate_sandbox_spec(&request.name, spec); + } + + validate_dns1123_label(workload_template_name, "workload_template_name")?; + if let Some(spec) = request.spec.as_ref() { + validate_template_create_governance_spec(spec)?; + validate_sandbox_governance_spec(&request.name, spec)?; + } else { + validate_sandbox_governance_spec(&request.name, &SandboxSpec::default())?; + } + Ok(()) +} + +fn validate_template_create_governance_spec(spec: &SandboxSpec) -> Result<(), Status> { + if !spec.log_level.is_empty() { + return Err(Status::invalid_argument( + "spec.log_level cannot be set when workload_template_name is set", + )); + } + if !spec.environment.is_empty() { + return Err(Status::invalid_argument( + "spec.environment cannot be set when workload_template_name is set", + )); + } + if spec.template.is_some() { + return Err(Status::invalid_argument( + "spec.template cannot be set when workload_template_name is set", + )); + } + if spec.resource_requirements.is_some() { + return Err(Status::invalid_argument( + "spec.resource_requirements cannot be set when workload_template_name is set", + )); + } + Ok(()) +} + +fn sandbox_spec_from_stored_workload_template( + template: &SandboxWorkloadTemplate, +) -> Result { + sandbox_spec_from_workload_template(template, tonic::Code::Internal) +} + +fn sandbox_spec_from_user_workload_template( + template: &SandboxWorkloadTemplate, +) -> Result { + sandbox_spec_from_workload_template(template, tonic::Code::InvalidArgument) +} + +fn sandbox_spec_from_workload_template( + template: &SandboxWorkloadTemplate, + missing_field_code: tonic::Code, +) -> Result { + let spec = template + .spec + .as_ref() + .ok_or_else(|| Status::new(missing_field_code, "sandbox template spec is required"))?; + let workload = spec + .workload + .as_ref() + .ok_or_else(|| Status::new(missing_field_code, "sandbox template workload is required"))?; + let resources = workload.resources.as_ref(); + Ok(SandboxSpec { + environment: workload.environment.clone(), + template: Some(SandboxTemplate { + image: workload.image.clone(), + resources: resources.and_then(template_resource_struct), + driver_config: spec.driver_config.clone(), + ..SandboxTemplate::default() + }), + resource_requirements: resources.and_then(template_gpu_requirements), + ..SandboxSpec::default() + }) +} + +fn template_gpu_requirements(resources: &SandboxResources) -> Option { + Some(ResourceRequirements { + gpu: Some(resources.gpu?), + }) +} + +fn template_resource_struct(resources: &SandboxResources) -> Option { + let mut limits = std::collections::BTreeMap::new(); + if !resources.cpu.is_empty() { + limits.insert( + "cpu".to_string(), + Value { + kind: Some(Kind::StringValue(resources.cpu.clone())), + }, + ); + } + if !resources.memory.is_empty() { + limits.insert( + "memory".to_string(), + Value { + kind: Some(Kind::StringValue(resources.memory.clone())), + }, + ); + } + if limits.is_empty() { + None + } else { + let mut fields = std::collections::BTreeMap::new(); + fields.insert( + "limits".to_string(), + Value { + kind: Some(Kind::StructValue(Struct { fields: limits })), + }, + ); + Some(Struct { fields }) + } +} + pub(super) async fn handle_get_sandbox( state: &Arc, request: Request, @@ -472,12 +665,106 @@ pub(super) async fn handle_list_sandboxes( Ok(Response::new(ListSandboxesResponse { sandboxes })) } -pub(super) async fn handle_list_sandbox_providers( +pub(super) async fn handle_create_sandbox_template( state: &Arc, - request: Request, -) -> Result, Status> { + request: Request, +) -> Result, Status> { + let principal = super::extract_principal(&request)?; + let req = request.into_inner(); + let template = req + .template + .ok_or_else(|| Status::invalid_argument("template is required"))?; + let metadata = template.metadata.clone().unwrap_or_default(); + let authz = authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &req.workspace, + MinWorkspaceRole::Admin, + ) + .await?; + let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) + .await? + .ensure_active()?; + if !metadata.workspace.is_empty() && metadata.workspace != workspace { + return Err(Status::invalid_argument( + "template.metadata.workspace must match request workspace", + )); + } + if metadata.name.is_empty() { + return Err(Status::invalid_argument( + "template.metadata.name is required", + )); + } + + let mut resolved = template; + resolved.metadata = Some(ObjectMeta { + id: uuid::Uuid::new_v4().to_string(), + name: metadata.name, + created_at_ms: current_time_ms(), + labels: metadata.labels, + resource_version: 0, + annotations: metadata.annotations, + workspace: workspace.clone(), + deletion_timestamp_ms: 0, + }); + validate_sandbox_workload_template(&resolved)?; + + let labels_map = resolved.object_labels(); + let labels_json = if labels_map.as_ref().is_none_or(HashMap::is_empty) { + None + } else { + Some( + serde_json::to_string(&labels_map) + .map_err(|e| Status::internal(format!("failed to serialize labels: {e}")))?, + ) + }; + let write = state + .store + .create_if_workspace_count_below( + SandboxWorkloadTemplate::object_type(), + resolved.object_id(), + resolved.object_name(), + &workspace, + &resolved.encode_to_vec(), + labels_json.as_deref(), + u64::from(MAX_TEMPLATES_PER_WORKSPACE), + ) + .await; + let write = match write { + Ok(Some(write)) => write, + Ok(None) => { + return Err(Status::resource_exhausted(format!( + "workspace has reached the maximum of {MAX_TEMPLATES_PER_WORKSPACE} sandbox templates" + ))); + } + Err(crate::persistence::PersistenceError::UniqueViolation { .. }) => { + return Err(Status::already_exists("sandbox template already exists")); + } + Err(err) => { + return Err(Status::internal(format!( + "persist sandbox template failed: {err}" + ))); + } + }; + if let Some(metadata) = resolved.metadata.as_mut() { + metadata.resource_version = write.resource_version; + } + + Ok(Response::new(SandboxTemplateResponse { + template: Some(resolved), + })) +} + +pub(super) async fn handle_get_sandbox_template( + state: &Arc, + request: Request, +) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); + if req.name.is_empty() { + return Err(Status::invalid_argument("name is required")); + } let authz = authorize_workspace( &state.store, &state.admin_role, @@ -489,35 +776,221 @@ pub(super) async fn handle_list_sandbox_providers( let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) .await? .name; - let sandbox = sandbox_by_name(state, &workspace, &req.sandbox_name).await?; - let providers = providers_for_sandbox(state, &sandbox, &workspace).await?; - Ok(Response::new(ListSandboxProvidersResponse { providers })) + let template = state + .store + .get_message_by_name::(&workspace, &req.name) + .await + .map_err(|e| Status::internal(format!("fetch sandbox template failed: {e}")))? + .ok_or_else(|| Status::not_found("sandbox template not found"))?; + Ok(Response::new(SandboxTemplateResponse { + template: Some(template), + })) } -pub(super) async fn handle_attach_sandbox_provider( +pub(super) async fn handle_list_sandbox_templates( state: &Arc, - request: Request, -) -> Result, Status> { + request: Request, +) -> Result, Status> { let principal = super::extract_principal(&request)?; let request = request.into_inner(); + if request.all_workspaces && !request.workspace.is_empty() { + return Err(Status::invalid_argument( + "all_workspaces and workspace are mutually exclusive", + )); + } + let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE); + let templates = if request.all_workspaces { + require_platform_admin(&state.admin_role, &principal)?; + if request.label_selector.is_empty() { + state + .store + .list_all_messages::(limit, request.offset) + .await + .map_err(|e| Status::internal(format!("list sandbox templates failed: {e}")))? + } else { + crate::grpc::validation::validate_label_selector(&request.label_selector)?; + state + .store + .list_all_messages_with_selector::( + &request.label_selector, + limit, + request.offset, + ) + .await + .map_err(|e| Status::internal(format!("list sandbox templates failed: {e}")))? + } + } else { + let authz = authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &request.workspace, + MinWorkspaceRole::User, + ) + .await?; + let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) + .await? + .name; + if request.label_selector.is_empty() { + state + .store + .list_messages::(&workspace, limit, request.offset) + .await + .map_err(|e| Status::internal(format!("list sandbox templates failed: {e}")))? + } else { + crate::grpc::validation::validate_label_selector(&request.label_selector)?; + state + .store + .list_messages_with_selector::( + &workspace, + &request.label_selector, + limit, + request.offset, + ) + .await + .map_err(|e| { + Status::internal(format!("list sandbox templates with selector failed: {e}")) + })? + } + }; + Ok(Response::new(ListSandboxTemplatesResponse { templates })) +} + +pub(super) async fn handle_delete_sandbox_template( + state: &Arc, + request: Request, +) -> Result, Status> { + let principal = super::extract_principal(&request)?; + let req = request.into_inner(); + if req.name.is_empty() { + return Err(Status::invalid_argument("name is required")); + } let authz = authorize_workspace( &state.store, &state.admin_role, &principal, - &request.workspace, - MinWorkspaceRole::User, + &req.workspace, + MinWorkspaceRole::Admin, ) .await?; let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) .await? - .ensure_active()?; - if request.provider_name.is_empty() { - return Err(Status::invalid_argument("provider_name is required")); + .name; + let deleted = state + .store + .delete_by_name( + SandboxWorkloadTemplate::object_type(), + &workspace, + &req.name, + ) + .await + .map_err(|e| Status::internal(format!("delete sandbox template failed: {e}")))?; + Ok(Response::new(DeleteSandboxTemplateResponse { deleted })) +} + +fn validate_sandbox_workload_template(template: &SandboxWorkloadTemplate) -> Result<(), Status> { + super::validation::validate_object_metadata(template.metadata.as_ref(), "sandbox_template")?; + let name = template.object_name().to_string(); + validate_dns1123_label(&name, "template.metadata.name")?; + validate_sandbox_workload_template_service_level(template)?; + let spec = sandbox_spec_from_user_workload_template(template)?; + validate_sandbox_spec(&name, &spec)?; + Ok(()) +} + +fn validate_sandbox_workload_template_service_level( + template: &SandboxWorkloadTemplate, +) -> Result<(), Status> { + let Some(startup) = template + .spec + .as_ref() + .and_then(|spec| spec.desired_service_level.as_ref()) + .and_then(|service_level| service_level.startup.as_ref()) + else { + return Ok(()); + }; + if let Some(ready_within) = &startup.ready_within { + validate_positive_normalized_duration( + ready_within, + "template.spec.desired_service_level.startup.ready_within", + )?; } + Ok(()) +} - // Validate provider name would not violate sandbox spec constraints if added - // (pre-validation ensures CAS mutations preserve invariants) - if request.provider_name.len() > super::MAX_NAME_LEN { +fn validate_positive_normalized_duration( + duration: &prost_types::Duration, + field: &str, +) -> Result<(), Status> { + const MAX_DURATION_SECONDS: u64 = 315_576_000_000; + if duration.seconds.unsigned_abs() > MAX_DURATION_SECONDS + || duration.nanos.unsigned_abs() >= 1_000_000_000 + { + return Err(Status::invalid_argument(format!( + "{field} must be a valid protobuf Duration" + ))); + } + if (duration.seconds > 0 && duration.nanos < 0) || (duration.seconds < 0 && duration.nanos > 0) + { + return Err(Status::invalid_argument(format!( + "{field} must be a normalized protobuf Duration" + ))); + } + if duration.seconds < 0 || duration.nanos < 0 || (duration.seconds == 0 && duration.nanos == 0) + { + return Err(Status::invalid_argument(format!( + "{field} must be greater than zero" + ))); + } + Ok(()) +} + +pub(super) async fn handle_list_sandbox_providers( + state: &Arc, + request: Request, +) -> Result, Status> { + let principal = super::extract_principal(&request)?; + let req = request.into_inner(); + let authz = authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &req.workspace, + MinWorkspaceRole::User, + ) + .await?; + let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) + .await? + .name; + let sandbox = sandbox_by_name(state, &workspace, &req.sandbox_name).await?; + let providers = providers_for_sandbox(state, &sandbox, &workspace).await?; + Ok(Response::new(ListSandboxProvidersResponse { providers })) +} + +pub(super) async fn handle_attach_sandbox_provider( + state: &Arc, + request: Request, +) -> Result, Status> { + let principal = super::extract_principal(&request)?; + let request = request.into_inner(); + let authz = authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &request.workspace, + MinWorkspaceRole::User, + ) + .await?; + let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) + .await? + .ensure_active()?; + if request.provider_name.is_empty() { + return Err(Status::invalid_argument("provider_name is required")); + } + + // Validate provider name would not violate sandbox spec constraints if added + // (pre-validation ensures CAS mutations preserve invariants) + if request.provider_name.len() > super::MAX_NAME_LEN { return Err(Status::invalid_argument(format!( "provider_name exceeds maximum length ({} > {})", request.provider_name.len(), @@ -1753,7 +2226,7 @@ pub(super) async fn handle_create_ssh_session( 0 }; let session = SshSession { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + metadata: Some(ObjectMeta { id: token.clone(), name: generate_name(), created_at_ms: now_ms, @@ -2532,6 +3005,7 @@ mod tests { }; use crate::provider_profile_sources::ProviderProfileSources; use openshell_core::GatewayProviderProfileSourceConfig; + use openshell_core::proto::GpuResourceRequirements; use openshell_core::proto::datamodel::v1::ObjectMeta; async fn test_server_state_with_user_only_github_profile() -> Arc { @@ -2590,6 +3064,66 @@ mod tests { } } + #[test] + fn sandbox_create_telemetry_uses_resolved_template_gpu_request() { + let request = CreateSandboxRequest { + spec: Some(SandboxSpec { + providers: vec!["github".to_string()], + policy: Some(openshell_core::proto::SandboxPolicy::default()), + ..SandboxSpec::default() + }), + workload_template_name: "gpu-kata".to_string(), + ..CreateSandboxRequest::default() + }; + let created = Sandbox { + spec: Some(SandboxSpec { + providers: vec!["github".to_string()], + policy: Some(openshell_core::proto::SandboxPolicy::default()), + resource_requirements: Some(ResourceRequirements { + gpu: Some(GpuResourceRequirements { count: Some(1) }), + }), + ..SandboxSpec::default() + }), + created_from_workload_template: Some(SandboxWorkloadTemplateProvenance { + name: "gpu-kata".to_string(), + resource_version: "7".to_string(), + }), + ..Sandbox::default() + }; + + assert_eq!( + sandbox_create_telemetry_attrs(&request, Some(&created)), + SandboxCreateTelemetryAttrs { + requested_gpu: true, + provider_count: 1, + has_custom_policy: true, + template_source: SandboxTemplateSource::WorkloadTemplate, + } + ); + } + + #[test] + fn sandbox_create_telemetry_falls_back_to_request_for_unresolved_template() { + let request = CreateSandboxRequest { + spec: Some(SandboxSpec { + providers: vec!["github".to_string()], + ..SandboxSpec::default() + }), + workload_template_name: "missing-template".to_string(), + ..CreateSandboxRequest::default() + }; + + assert_eq!( + sandbox_create_telemetry_attrs(&request, None), + SandboxCreateTelemetryAttrs { + requested_gpu: false, + provider_count: 1, + has_custom_policy: false, + template_source: SandboxTemplateSource::WorkloadTemplate, + } + ); + } + #[test] fn shell_escape_safe_chars_pass_through() { assert_eq!(shell_escape("ls").unwrap(), "ls"); @@ -2899,7 +3433,7 @@ mod tests { workspace: "default".to_string(), deletion_timestamp_ms: 0, }), - spec: Some(openshell_core::proto::SandboxSpec { + spec: Some(SandboxSpec { log_level: "debug".to_string(), policy: Some(openshell_core::proto::SandboxPolicy::default()), providers, @@ -2912,6 +3446,41 @@ mod tests { sandbox } + fn test_workload_template(name: &str) -> SandboxWorkloadTemplate { + SandboxWorkloadTemplate { + metadata: Some(ObjectMeta { + id: String::new(), + name: name.to_string(), + created_at_ms: 0, + labels: HashMap::from([("team".to_string(), "runtime".to_string())]), + resource_version: 0, + annotations: HashMap::new(), + workspace: String::new(), + deletion_timestamp_ms: 0, + }), + spec: Some(openshell_core::proto::SandboxWorkloadTemplateSpec { + workload: Some(openshell_core::proto::SandboxWorkloadConfig { + image: "registry.example.com/agent:latest".to_string(), + environment: HashMap::from([("FEATURE_FLAG".to_string(), "on".to_string())]), + resources: Some(SandboxResources { + cpu: "2".to_string(), + memory: "4Gi".to_string(), + gpu: Some(GpuResourceRequirements { count: Some(1) }), + }), + }), + driver_config: None, + desired_service_level: None, + }), + } + } + + fn proto_string_value(value: &Value) -> Option<&str> { + match value.kind.as_ref() { + Some(Kind::StringValue(value)) => Some(value.as_str()), + _ => None, + } + } + #[tokio::test] #[ignore = "flaky under concurrent test execution"] async fn watch_producer_releases_request_span_when_client_disconnects() { @@ -3471,7 +4040,7 @@ mod tests { &state, authed_request(CreateSandboxRequest { name: "collision".to_string(), - spec: Some(openshell_core::proto::SandboxSpec { + spec: Some(SandboxSpec { providers: vec!["provider-a".to_string(), "provider-b".to_string()], ..Default::default() }), @@ -3479,6 +4048,7 @@ mod tests { annotations: HashMap::new(), workspace: String::new(), await_main_process_attachment: false, + workload_template_name: String::new(), }), ) .await @@ -3498,11 +4068,12 @@ mod tests { &state, authed_request(CreateSandboxRequest { name: "user-catalog".to_string(), - spec: Some(openshell_core::proto::SandboxSpec::default()), + spec: Some(SandboxSpec::default()), labels: HashMap::new(), annotations: HashMap::new(), workspace: String::new(), await_main_process_attachment: false, + workload_template_name: String::new(), }), ) .await @@ -3531,7 +4102,7 @@ mod tests { &state, authed_request(CreateSandboxRequest { name: "reserved-policy-key".to_string(), - spec: Some(openshell_core::proto::SandboxSpec { + spec: Some(SandboxSpec { policy: Some(policy), ..Default::default() }), @@ -3539,6 +4110,7 @@ mod tests { annotations: HashMap::new(), workspace: String::new(), await_main_process_attachment: false, + workload_template_name: String::new(), }), ) .await @@ -3559,11 +4131,12 @@ mod tests { &state, authed_request(CreateSandboxRequest { name: "annotated".to_string(), - spec: Some(openshell_core::proto::SandboxSpec::default()), + spec: Some(SandboxSpec::default()), labels: HashMap::new(), annotations: HashMap::from([(annotation_key.clone(), annotation_value.clone())]), workspace: String::new(), await_main_process_attachment: false, + workload_template_name: String::new(), }), ) .await @@ -3616,7 +4189,7 @@ mod tests { &state, authed_request(CreateSandboxRequest { name: "partial-id".to_string(), - spec: Some(openshell_core::proto::SandboxSpec { + spec: Some(SandboxSpec { policy: Some(policy), ..Default::default() }), @@ -3624,6 +4197,7 @@ mod tests { annotations: HashMap::new(), workspace: String::new(), await_main_process_attachment: false, + workload_template_name: String::new(), }), ) .await @@ -3680,7 +4254,7 @@ mod tests { &state, authed_request(CreateSandboxRequest { name: "kube-partial-id".to_string(), - spec: Some(openshell_core::proto::SandboxSpec { + spec: Some(SandboxSpec { policy: Some(policy), ..Default::default() }), @@ -3688,6 +4262,7 @@ mod tests { annotations: HashMap::new(), workspace: String::new(), await_main_process_attachment: false, + workload_template_name: String::new(), }), ) .await @@ -3714,11 +4289,12 @@ mod tests { &state, authed_request(CreateSandboxRequest { name: "bad-label".to_string(), - spec: Some(openshell_core::proto::SandboxSpec::default()), + spec: Some(SandboxSpec::default()), labels: HashMap::from([("team".to_string(), "x".repeat(512))]), annotations: HashMap::new(), workspace: String::new(), await_main_process_attachment: false, + workload_template_name: String::new(), }), ) .await @@ -3744,7 +4320,7 @@ mod tests { &task_state, authed_request(CreateSandboxRequest { name: "guarded-create".to_string(), - spec: Some(openshell_core::proto::SandboxSpec { + spec: Some(SandboxSpec { providers: vec!["work-github".to_string()], ..Default::default() }), @@ -3752,6 +4328,7 @@ mod tests { annotations: HashMap::new(), workspace: String::new(), await_main_process_attachment: false, + workload_template_name: String::new(), }), ) .await @@ -3776,6 +4353,815 @@ mod tests { ); } + #[tokio::test] + async fn sandbox_template_handlers_create_get_list_and_delete_workspace_resource() { + let state = test_server_state().await; + + let created = handle_create_sandbox_template( + &state, + authed_request(CreateSandboxTemplateRequest { + template: Some(test_workload_template("gpu-kata")), + workspace: "default".to_string(), + }), + ) + .await + .expect("template create should succeed") + .into_inner() + .template + .expect("template response"); + + let metadata = created.metadata.as_ref().expect("metadata"); + assert_eq!(metadata.name, "gpu-kata"); + assert_eq!(metadata.workspace, "default"); + assert!(!metadata.id.is_empty()); + assert_ne!(metadata.resource_version, 0); + + let fetched = handle_get_sandbox_template( + &state, + authed_request(GetSandboxTemplateRequest { + name: "gpu-kata".to_string(), + workspace: "default".to_string(), + }), + ) + .await + .expect("template get should succeed") + .into_inner() + .template + .expect("fetched template"); + assert_eq!(fetched.object_name(), "gpu-kata"); + assert_eq!(fetched.object_workspace(), "default"); + + let listed = handle_list_sandbox_templates( + &state, + authed_request(ListSandboxTemplatesRequest { + limit: 100, + offset: 0, + workspace: "default".to_string(), + all_workspaces: false, + label_selector: String::new(), + }), + ) + .await + .expect("template list should succeed") + .into_inner() + .templates; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].object_name(), "gpu-kata"); + + let deleted = handle_delete_sandbox_template( + &state, + authed_request(DeleteSandboxTemplateRequest { + name: "gpu-kata".to_string(), + workspace: "default".to_string(), + }), + ) + .await + .expect("template delete should succeed") + .into_inner(); + assert!(deleted.deleted); + + let missing = handle_get_sandbox_template( + &state, + authed_request(GetSandboxTemplateRequest { + name: "gpu-kata".to_string(), + workspace: "default".to_string(), + }), + ) + .await + .expect_err("deleted template should not be fetchable"); + assert_eq!(missing.code(), tonic::Code::NotFound); + } + + #[tokio::test] + async fn sandbox_template_list_filters_by_label_selector() { + let state = test_server_state().await; + + let mut gpu = test_workload_template("gpu-kata"); + gpu.metadata + .as_mut() + .expect("metadata") + .labels + .insert("team".to_string(), "runtime".to_string()); + handle_create_sandbox_template( + &state, + authed_request(CreateSandboxTemplateRequest { + template: Some(gpu), + workspace: "default".to_string(), + }), + ) + .await + .expect("gpu template create should succeed"); + + let mut cpu = test_workload_template("cpu-base"); + cpu.metadata + .as_mut() + .expect("metadata") + .labels + .insert("team".to_string(), "batch".to_string()); + handle_create_sandbox_template( + &state, + authed_request(CreateSandboxTemplateRequest { + template: Some(cpu), + workspace: "default".to_string(), + }), + ) + .await + .expect("cpu template create should succeed"); + + let listed = handle_list_sandbox_templates( + &state, + authed_request(ListSandboxTemplatesRequest { + limit: 100, + offset: 0, + workspace: "default".to_string(), + all_workspaces: false, + label_selector: "team=runtime".to_string(), + }), + ) + .await + .expect("template list with label selector should succeed") + .into_inner() + .templates; + + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].object_name(), "gpu-kata"); + } + + #[tokio::test] + async fn sandbox_template_create_rejects_whitespace_name() { + let state = test_server_state().await; + + let err = handle_create_sandbox_template( + &state, + authed_request(CreateSandboxTemplateRequest { + template: Some(test_workload_template(" gpu-kata ")), + workspace: "default".to_string(), + }), + ) + .await + .expect_err("template names must be canonical DNS-1123 labels"); + + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("template.metadata.name")); + + let listed = handle_list_sandbox_templates( + &state, + authed_request(ListSandboxTemplatesRequest { + limit: 100, + offset: 0, + workspace: "default".to_string(), + all_workspaces: false, + label_selector: String::new(), + }), + ) + .await + .expect("template list should succeed") + .into_inner() + .templates; + assert!(listed.is_empty()); + } + + #[tokio::test] + async fn sandbox_template_create_empty_workspace_ignores_metadata_workspace() { + use openshell_core::proto::CreateWorkspaceRequest; + + let state = test_server_state().await; + crate::grpc::workspace::handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "beta".to_string(), + labels: HashMap::new(), + }), + ) + .await + .expect("beta workspace should be created"); + + let mut template = test_workload_template("copied-template"); + template.metadata.as_mut().unwrap().workspace = "beta".to_string(); + + let err = handle_create_sandbox_template( + &state, + authed_request(CreateSandboxTemplateRequest { + template: Some(template), + workspace: String::new(), + }), + ) + .await + .expect_err("empty request workspace must default to default, not metadata workspace"); + + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("template.metadata.workspace")); + + let listed = handle_list_sandbox_templates( + &state, + authed_request(ListSandboxTemplatesRequest { + limit: 100, + offset: 0, + workspace: "beta".to_string(), + all_workspaces: false, + label_selector: String::new(), + }), + ) + .await + .expect("template list should succeed") + .into_inner() + .templates; + assert!(listed.is_empty()); + } + + #[tokio::test] + async fn sandbox_template_create_rejects_missing_spec_as_invalid_argument() { + let state = test_server_state().await; + let mut template = test_workload_template("missing-spec"); + template.spec = None; + + let err = handle_create_sandbox_template( + &state, + authed_request(CreateSandboxTemplateRequest { + template: Some(template), + workspace: "default".to_string(), + }), + ) + .await + .expect_err("template create should reject missing spec"); + + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("sandbox template spec")); + } + + #[test] + fn sandbox_template_validation_allows_positive_ready_within() { + let mut template = test_workload_template("gpu-kata"); + template.metadata.as_mut().unwrap().id = "template-gpu-kata".to_string(); + template.spec.as_mut().unwrap().desired_service_level = + Some(openshell_core::proto::SandboxServiceLevel { + startup: Some(openshell_core::proto::SandboxStartup { + ready_within: Some(prost_types::Duration { + seconds: 1, + nanos: 0, + }), + max_burst: 1, + }), + }); + + validate_sandbox_workload_template(&template).expect("positive ready_within should pass"); + } + + #[test] + fn sandbox_template_validation_rejects_non_positive_ready_within() { + for (duration, expected) in [ + ( + prost_types::Duration { + seconds: 0, + nanos: 0, + }, + "greater than zero", + ), + ( + prost_types::Duration { + seconds: -1, + nanos: 0, + }, + "greater than zero", + ), + ( + prost_types::Duration { + seconds: 0, + nanos: -1, + }, + "greater than zero", + ), + ] { + let mut template = test_workload_template("gpu-kata"); + template.metadata.as_mut().unwrap().id = "template-gpu-kata".to_string(); + template.spec.as_mut().unwrap().desired_service_level = + Some(openshell_core::proto::SandboxServiceLevel { + startup: Some(openshell_core::proto::SandboxStartup { + ready_within: Some(duration), + max_burst: 1, + }), + }); + + let err = validate_sandbox_workload_template(&template) + .expect_err("non-positive ready_within should be rejected"); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains(expected), "{err:?}"); + assert!( + err.message() + .contains("template.spec.desired_service_level.startup.ready_within"), + "{err:?}" + ); + } + } + + #[test] + fn sandbox_template_validation_rejects_malformed_ready_within() { + for (duration, expected) in [ + ( + prost_types::Duration { + seconds: 1, + nanos: -1, + }, + "normalized", + ), + ( + prost_types::Duration { + seconds: 0, + nanos: 1_000_000_000, + }, + "valid protobuf Duration", + ), + ( + prost_types::Duration { + seconds: 315_576_000_001, + nanos: 0, + }, + "valid protobuf Duration", + ), + ] { + let mut template = test_workload_template("gpu-kata"); + template.metadata.as_mut().unwrap().id = "template-gpu-kata".to_string(); + template.spec.as_mut().unwrap().desired_service_level = + Some(openshell_core::proto::SandboxServiceLevel { + startup: Some(openshell_core::proto::SandboxStartup { + ready_within: Some(duration), + max_burst: 1, + }), + }); + + let err = validate_sandbox_workload_template(&template) + .expect_err("malformed ready_within should be rejected"); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains(expected), "{err:?}"); + assert!( + err.message() + .contains("template.spec.desired_service_level.startup.ready_within"), + "{err:?}" + ); + } + } + + #[tokio::test] + async fn sandbox_template_create_rejects_workspace_quota() { + let state = test_server_state().await; + for index in 0..MAX_TEMPLATES_PER_WORKSPACE { + let mut template = test_workload_template(&format!("tmpl-{index}")); + let metadata = template.metadata.as_mut().expect("metadata"); + metadata.id = format!("template-{index}"); + metadata.workspace = "default".to_string(); + state.store.put_message(&template).await.unwrap(); + } + + let err = handle_create_sandbox_template( + &state, + authed_request(CreateSandboxTemplateRequest { + template: Some(test_workload_template("overflow")), + workspace: "default".to_string(), + }), + ) + .await + .expect_err("template create must reject a full workspace"); + + assert_eq!(err.code(), tonic::Code::ResourceExhausted); + assert!(err.message().contains("1000 sandbox templates")); + } + + #[tokio::test] + async fn sandbox_template_create_enforces_workspace_quota_concurrently() { + let state = test_server_state().await; + for index in 0..(MAX_TEMPLATES_PER_WORKSPACE - 1) { + let mut template = test_workload_template(&format!("tmpl-{index}")); + let metadata = template.metadata.as_mut().expect("metadata"); + metadata.id = format!("template-{index}"); + metadata.workspace = "default".to_string(); + state.store.put_message(&template).await.unwrap(); + } + + let mut handles = vec![]; + for index in 0..8 { + let state = Arc::clone(&state); + let handle = tokio::spawn(async move { + handle_create_sandbox_template( + &state, + authed_request(CreateSandboxTemplateRequest { + template: Some(test_workload_template(&format!("overflow-{index}"))), + workspace: "default".to_string(), + }), + ) + .await + }); + handles.push(handle); + } + + let results: Vec<_> = future::join_all(handles) + .await + .into_iter() + .map(|r| r.unwrap()) + .collect(); + + let successes = results.iter().filter(|r| r.is_ok()).count(); + let exhausted = results + .iter() + .filter(|r| { + r.as_ref() + .err() + .is_some_and(|e| e.code() == tonic::Code::ResourceExhausted) + }) + .count(); + + assert_eq!(successes, 1); + assert_eq!(exhausted, 7); + let count = state + .store + .count_in_workspace(SandboxWorkloadTemplate::object_type(), "default") + .await + .unwrap(); + assert_eq!(count, u64::from(MAX_TEMPLATES_PER_WORKSPACE)); + } + + #[test] + fn template_create_sandbox_spec_field_policy_is_exhaustive() { + assert_proto_fields_classified( + "openshell.v1.SandboxSpec", + &["policy", "providers", "command", "tty"], + &[ + "log_level", + "environment", + "template", + "resource_requirements", + ], + ); + } + + fn assert_proto_fields_classified( + message_name: &str, + copied_from_create_request: &[&str], + rejected_template_workload_overrides: &[&str], + ) { + let pool = prost_reflect::DescriptorPool::decode(openshell_core::FILE_DESCRIPTOR_SET) + .expect("decode descriptor set"); + let message = pool + .get_message_by_name(message_name) + .expect("message descriptor"); + let classified: std::collections::HashSet<&str> = copied_from_create_request + .iter() + .chain(rejected_template_workload_overrides.iter()) + .copied() + .collect(); + let actual: std::collections::HashSet = message + .fields() + .map(|field| field.name().to_string()) + .collect(); + + for field in &actual { + assert!( + classified.contains(field.as_str()), + "{message_name}.{field} is not classified for template-backed sandbox creates. \ + Add it to copied_from_create_request when callers own the create-time value, \ + or to rejected_template_workload_overrides when the workload template owns it." + ); + } + + for field in classified { + assert!( + actual.contains(field), + "{message_name}.{field} is classified for template-backed sandbox creates, \ + but the proto field no longer exists" + ); + } + } + + #[tokio::test] + async fn create_sandbox_from_workload_template_resolves_workload_and_preserves_governance() { + let state = test_server_state().await; + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + handle_create_sandbox_template( + &state, + authed_request(CreateSandboxTemplateRequest { + template: Some(test_workload_template("gpu-kata")), + workspace: "default".to_string(), + }), + ) + .await + .expect("template create should succeed"); + + let mut policy = openshell_core::proto::SandboxPolicy { + version: 1, + ..Default::default() + }; + policy.network_policies.insert( + "example".to_string(), + openshell_core::proto::NetworkPolicyRule { + name: "example".to_string(), + ..Default::default() + }, + ); + + let created = handle_create_sandbox( + &state, + authed_request(CreateSandboxRequest { + name: "from-template".to_string(), + spec: Some(SandboxSpec { + providers: vec!["work-github".to_string()], + policy: Some(policy), + command: vec!["echo".to_string(), "template-create".to_string()], + tty: false, + ..Default::default() + }), + labels: HashMap::new(), + annotations: HashMap::new(), + workspace: "default".to_string(), + workload_template_name: "gpu-kata".to_string(), + await_main_process_attachment: false, + }), + ) + .await + .expect("sandbox create from template should succeed") + .into_inner() + .sandbox + .expect("created sandbox"); + + let provenance = created + .created_from_workload_template + .expect("template provenance"); + assert_eq!(provenance.name, "gpu-kata"); + assert!(!provenance.resource_version.is_empty()); + + let spec = created.spec.expect("resolved sandbox spec"); + assert_eq!(spec.providers, vec!["work-github".to_string()]); + assert!(spec.policy.is_some()); + assert_eq!( + spec.command, + vec!["echo".to_string(), "template-create".to_string()] + ); + assert!(!spec.tty); + assert_eq!( + spec.environment.get("FEATURE_FLAG"), + Some(&"on".to_string()) + ); + + let template = spec.template.expect("resolved inline template"); + assert_eq!(template.image, "registry.example.com/agent:latest"); + let limits = template + .resources + .as_ref() + .and_then(|resources| resources.fields.get("limits")) + .and_then(|limits| limits.kind.as_ref()) + .and_then(|kind| match kind { + Kind::StructValue(value) => Some(&value.fields), + _ => None, + }) + .expect("resource limits"); + assert_eq!(limits.get("cpu").and_then(proto_string_value), Some("2")); + assert_eq!( + limits.get("memory").and_then(proto_string_value), + Some("4Gi") + ); + assert_eq!( + spec.resource_requirements + .and_then(|requirements| requirements.gpu) + .and_then(|gpu| gpu.count), + Some(1) + ); + } + + #[tokio::test] + async fn create_sandbox_from_workload_template_defaults_whitespace_image() { + let state = test_server_state().await; + let mut template = test_workload_template("default-image"); + template + .spec + .as_mut() + .and_then(|spec| spec.workload.as_mut()) + .expect("test template workload") + .image = " ".to_string(); + handle_create_sandbox_template( + &state, + authed_request(CreateSandboxTemplateRequest { + template: Some(template), + workspace: "default".to_string(), + }), + ) + .await + .expect("template create should succeed"); + + let created = handle_create_sandbox( + &state, + authed_request(CreateSandboxRequest { + name: "from-template".to_string(), + spec: Some(SandboxSpec::default()), + labels: HashMap::new(), + annotations: HashMap::new(), + workspace: "default".to_string(), + workload_template_name: "default-image".to_string(), + await_main_process_attachment: false, + }), + ) + .await + .expect("sandbox create from template should succeed") + .into_inner() + .sandbox + .expect("created sandbox"); + + let image = created + .spec + .and_then(|spec| spec.template) + .map(|template| template.image) + .expect("resolved template image"); + assert_eq!(image, state.compute.default_image()); + } + + #[tokio::test] + async fn create_sandbox_from_workload_template_preserves_default_gpu_request() { + let state = test_server_state().await; + let mut template = test_workload_template("default-gpu"); + template + .spec + .as_mut() + .and_then(|spec| spec.workload.as_mut()) + .and_then(|workload| workload.resources.as_mut()) + .expect("test template resources") + .gpu = Some(GpuResourceRequirements { count: None }); + handle_create_sandbox_template( + &state, + authed_request(CreateSandboxTemplateRequest { + template: Some(template), + workspace: "default".to_string(), + }), + ) + .await + .expect("template create should succeed"); + + let created = handle_create_sandbox( + &state, + authed_request(CreateSandboxRequest { + name: "from-template".to_string(), + spec: Some(SandboxSpec::default()), + labels: HashMap::new(), + annotations: HashMap::new(), + workspace: "default".to_string(), + workload_template_name: "default-gpu".to_string(), + await_main_process_attachment: false, + }), + ) + .await + .expect("sandbox create from template should succeed") + .into_inner() + .sandbox + .expect("created sandbox"); + + let gpu = created + .spec + .as_ref() + .and_then(|spec| spec.resource_requirements.as_ref()) + .and_then(|requirements| requirements.gpu.as_ref()) + .expect("default GPU request should be preserved"); + assert_eq!(gpu.count, None); + } + + #[tokio::test] + async fn create_sandbox_from_corrupted_workload_template_returns_internal() { + let state = test_server_state().await; + let mut template = test_workload_template("corrupt-template"); + let metadata = template.metadata.as_mut().expect("metadata"); + metadata.id = "template-corrupt-template".to_string(); + metadata.workspace = "default".to_string(); + template.spec = None; + state.store.put_message(&template).await.unwrap(); + + let err = handle_create_sandbox( + &state, + authed_request(CreateSandboxRequest { + name: "from-corrupt".to_string(), + spec: Some(SandboxSpec::default()), + labels: HashMap::new(), + annotations: HashMap::new(), + workspace: "default".to_string(), + workload_template_name: "corrupt-template".to_string(), + await_main_process_attachment: false, + }), + ) + .await + .expect_err("corrupted stored template should fail as server data corruption"); + + assert_eq!(err.code(), tonic::Code::Internal, "{}", err.message()); + assert!(err.message().contains("sandbox template spec")); + } + + #[tokio::test] + async fn create_sandbox_from_workload_template_rejects_inline_workload_overrides() { + let state = test_server_state().await; + handle_create_sandbox_template( + &state, + authed_request(CreateSandboxTemplateRequest { + template: Some(test_workload_template("gpu-kata")), + workspace: "default".to_string(), + }), + ) + .await + .expect("template create should succeed"); + + let err = handle_create_sandbox( + &state, + authed_request(CreateSandboxRequest { + name: "bad-template-create".to_string(), + spec: Some(SandboxSpec { + environment: HashMap::from([("INLINE".to_string(), "blocked".to_string())]), + ..Default::default() + }), + labels: HashMap::new(), + annotations: HashMap::new(), + workspace: "default".to_string(), + workload_template_name: "gpu-kata".to_string(), + await_main_process_attachment: false, + }), + ) + .await + .expect_err("inline workload overrides should be rejected"); + + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("spec.environment")); + } + + #[tokio::test] + async fn create_sandbox_from_workload_template_rejects_malformed_template_name() { + let state = test_server_state().await; + + let err = handle_create_sandbox( + &state, + authed_request(CreateSandboxRequest { + name: "bad-template-create".to_string(), + spec: Some(SandboxSpec::default()), + labels: HashMap::new(), + annotations: HashMap::new(), + workspace: "default".to_string(), + workload_template_name: "Invalid_Template_Name".to_string(), + await_main_process_attachment: false, + }), + ) + .await + .expect_err("malformed template name should be rejected before lookup"); + + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("workload_template_name")); + } + + #[tokio::test] + async fn create_sandbox_from_workload_template_rejects_oversized_governance_before_lookup() { + let state = test_server_state().await; + + let err = handle_create_sandbox( + &state, + authed_request(CreateSandboxRequest { + name: "bad-template-create".to_string(), + spec: Some(SandboxSpec { + providers: (0..=MAX_PROVIDERS).map(|i| format!("p-{i}")).collect(), + ..Default::default() + }), + labels: HashMap::new(), + annotations: HashMap::new(), + workspace: "default".to_string(), + workload_template_name: "missing-template".to_string(), + await_main_process_attachment: false, + }), + ) + .await + .expect_err("oversized governance spec should be rejected before template lookup"); + + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("providers")); + } + + #[tokio::test] + async fn create_sandbox_rejects_oversized_direct_spec_before_workspace_lookup() { + let state = test_server_state().await; + + let err = handle_create_sandbox( + &state, + authed_request(CreateSandboxRequest { + name: "bad-direct-create".to_string(), + spec: Some(SandboxSpec { + providers: (0..=MAX_PROVIDERS).map(|i| format!("p-{i}")).collect(), + ..Default::default() + }), + labels: HashMap::new(), + annotations: HashMap::new(), + workspace: "missing-workspace".to_string(), + workload_template_name: String::new(), + await_main_process_attachment: false, + }), + ) + .await + .expect_err("oversized direct spec should be rejected before workspace lookup"); + + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("providers")); + } + #[tokio::test] async fn attach_sandbox_provider_rejects_credential_key_collisions() { let state = test_server_state().await; @@ -4678,7 +6064,7 @@ mod tests { &state, non_member_request(CreateSandboxRequest { workspace: "no-such-ws".into(), - spec: Some(openshell_core::proto::SandboxSpec::default()), + spec: Some(SandboxSpec::default()), ..Default::default() }), ) diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index eb53c80f29..c20dad7780 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -10,7 +10,7 @@ use openshell_core::proto::{ CredentialHandle, ExecSandboxRequest, Provider, SandboxPolicy as ProtoSandboxPolicy, - SandboxTemplate, + SandboxSpec, SandboxTemplate, }; use prost::Message; use tonic::Status; @@ -150,26 +150,12 @@ pub(super) fn validate_dns1123_label(name: &str, field: &str) -> Result<(), Stat /// Validate field sizes on a `CreateSandboxRequest` before persisting. /// /// Returns `INVALID_ARGUMENT` on the first field that exceeds its limit. -pub(super) fn validate_sandbox_spec( - name: &str, - spec: &openshell_core::proto::SandboxSpec, -) -> Result<(), Status> { +pub(super) fn validate_sandbox_spec(name: &str, spec: &SandboxSpec) -> Result<(), Status> { // --- request.name --- - if !name.is_empty() && name.len() > MAX_ROUTABLE_NAME_LEN { - return Err(Status::invalid_argument(format!( - "name exceeds maximum length ({} > {MAX_ROUTABLE_NAME_LEN})", - name.len() - ))); - } - validate_dns1123_label(name, "name")?; + validate_sandbox_name(name)?; // --- spec.providers --- - if spec.providers.len() > MAX_PROVIDERS { - return Err(Status::invalid_argument(format!( - "providers list exceeds maximum ({} > {MAX_PROVIDERS})", - spec.providers.len() - ))); - } + validate_sandbox_provider_count(spec)?; // --- spec.log_level --- if spec.log_level.len() > MAX_LOG_LEVEL_LEN { @@ -203,6 +189,45 @@ pub(super) fn validate_sandbox_spec( } // --- spec.policy serialized size --- + validate_sandbox_policy_size(spec)?; + + Ok(()) +} + +pub(super) fn validate_sandbox_governance_spec( + name: &str, + spec: &SandboxSpec, +) -> Result<(), Status> { + validate_sandbox_name(name)?; + validate_sandbox_provider_count(spec)?; + if !spec.command.is_empty() { + validate_main_process_command(&spec.command)?; + } + validate_sandbox_policy_size(spec)?; + Ok(()) +} + +fn validate_sandbox_name(name: &str) -> Result<(), Status> { + if !name.is_empty() && name.len() > MAX_ROUTABLE_NAME_LEN { + return Err(Status::invalid_argument(format!( + "name exceeds maximum length ({} > {MAX_ROUTABLE_NAME_LEN})", + name.len() + ))); + } + validate_dns1123_label(name, "name") +} + +fn validate_sandbox_provider_count(spec: &SandboxSpec) -> Result<(), Status> { + if spec.providers.len() > MAX_PROVIDERS { + return Err(Status::invalid_argument(format!( + "providers list exceeds maximum ({} > {MAX_PROVIDERS})", + spec.providers.len() + ))); + } + Ok(()) +} + +fn validate_sandbox_policy_size(spec: &SandboxSpec) -> Result<(), Status> { if let Some(ref policy) = spec.policy { let size = policy.encoded_len(); if size > MAX_POLICY_SIZE { @@ -244,7 +269,7 @@ fn validate_main_process_command(command: &[String]) -> Result<(), Status> { Ok(()) } -fn validate_gpu_request_fields(spec: &openshell_core::proto::SandboxSpec) -> Result<(), Status> { +fn validate_gpu_request_fields(spec: &SandboxSpec) -> Result<(), Status> { if openshell_core::gpu::sandbox_gpu_count(spec.resource_requirements.as_ref()) == Some(0) { return Err(Status::invalid_argument("gpu count must be greater than 0")); } diff --git a/crates/openshell-server/src/grpc/workspace.rs b/crates/openshell-server/src/grpc/workspace.rs index d83ffab0e8..7446938156 100644 --- a/crates/openshell-server/src/grpc/workspace.rs +++ b/crates/openshell-server/src/grpc/workspace.rs @@ -14,9 +14,9 @@ use openshell_core::proto::{ CreateWorkspaceResponse, DeleteWorkspaceRequest, DeleteWorkspaceResponse, GetWorkspaceRequest, GetWorkspaceResponse, InferenceRoute, ListWorkspaceMembersRequest, ListWorkspaceMembersResponse, ListWorkspacesRequest, ListWorkspacesResponse, Provider, - RemoveWorkspaceMemberRequest, RemoveWorkspaceMemberResponse, Sandbox, ServiceEndpoint, - SshSession, StoredProviderCredentialRefreshState, StoredProviderProfile, Workspace, - WorkspaceMember, WorkspaceRole, + RemoveWorkspaceMemberRequest, RemoveWorkspaceMemberResponse, Sandbox, SandboxWorkloadTemplate, + ServiceEndpoint, SshSession, StoredProviderCredentialRefreshState, StoredProviderProfile, + Workspace, WorkspaceMember, WorkspaceRole, }; use prost::Message; use tonic::{Request, Response, Status}; @@ -375,6 +375,7 @@ pub(super) async fn handle_delete_workspace( let mut blocking = Vec::new(); for (object_type, label) in [ (Sandbox::object_type(), "sandbox"), + (SandboxWorkloadTemplate::object_type(), "sandbox template"), (Provider::object_type(), "provider"), (StoredProviderProfile::object_type(), "provider profile"), (ServiceEndpoint::object_type(), "service"), @@ -819,6 +820,72 @@ mod tests { assert!(resp.deleted); } + #[tokio::test] + async fn delete_workspace_blocked_by_sandbox_template() { + let state = test_server_state().await; + + handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "templated".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + let template = SandboxWorkloadTemplate { + metadata: Some(ObjectMeta { + id: "template-1".to_string(), + name: "gpu-kata".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + annotations: HashMap::new(), + resource_version: 0, + workspace: "templated".to_string(), + deletion_timestamp_ms: 0, + }), + spec: None, + }; + state.store.put_message(&template).await.unwrap(); + + let err = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "templated".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!( + err.message().contains("sandbox template"), + "error should name sandbox templates as blocking resources: {}", + err.message() + ); + + state + .store + .delete_by_name( + SandboxWorkloadTemplate::object_type(), + "templated", + "gpu-kata", + ) + .await + .unwrap(); + + let resp = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "templated".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(resp.deleted); + } + #[tokio::test] async fn delete_workspace_blocked_by_ssh_session() { let state = test_server_state().await; diff --git a/crates/openshell-server/src/persistence/mod.rs b/crates/openshell-server/src/persistence/mod.rs index 6934630110..716f26dad9 100644 --- a/crates/openshell-server/src/persistence/mod.rs +++ b/crates/openshell-server/src/persistence/mod.rs @@ -401,6 +401,39 @@ impl Store { )) } + /// Atomically insert a named object only if its workspace has fewer than + /// `max_count` objects of the same type. + /// + /// Returns `Ok(None)` when the quota is already full. A duplicate id or + /// `(object_type, workspace, name)` still returns + /// [`PersistenceError::UniqueViolation`]. + #[allow(clippy::too_many_arguments)] + #[tracing::instrument( + name = "store", + skip_all, + fields(otel.name = "store.create_if_workspace_count_below", otel.status_code = tracing::field::Empty, object_type = %object_type, object.id = %id, object.name = %name, workspace = %workspace, max_count = max_count) + )] + pub async fn create_if_workspace_count_below( + &self, + object_type: &str, + id: &str, + name: &str, + workspace: &str, + payload: &[u8], + labels: Option<&str>, + max_count: u64, + ) -> PersistenceResult> { + store_dispatch_traced!(self.create_if_workspace_count_below( + object_type, + id, + name, + workspace, + payload, + labels, + max_count + )) + } + /// Fetch an object by id. #[tracing::instrument( name = "store", diff --git a/crates/openshell-server/src/persistence/postgres.rs b/crates/openshell-server/src/persistence/postgres.rs index 89fe442695..19c50c6187 100644 --- a/crates/openshell-server/src/persistence/postgres.rs +++ b/crates/openshell-server/src/persistence/postgres.rs @@ -337,6 +337,73 @@ RETURNING resource_version, created_at_ms, updated_at_ms }) } + #[allow(clippy::too_many_arguments)] + pub async fn create_if_workspace_count_below( + &self, + object_type: &str, + id: &str, + name: &str, + workspace: &str, + payload: &[u8], + labels: Option<&str>, + max_count: u64, + ) -> PersistenceResult> { + let now_ms = current_time_ms(); + let labels_jsonb: Option = labels + .map(serde_json::from_str) + .transpose() + .map_err(|e| PersistenceError::Encode(format!("invalid labels JSON: {e}")))?; + let mut tx = self.pool.begin().await.map_err(|e| map_db_error(&e))?; + + sqlx::query("SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))") + .bind(object_type) + .bind(workspace) + .execute(&mut *tx) + .await + .map_err(|e| map_db_error(&e))?; + + let row: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM objects WHERE object_type = $1 AND workspace = $2", + ) + .bind(object_type) + .bind(workspace) + .fetch_one(&mut *tx) + .await + .map_err(|e| map_db_error(&e))?; + let count = u64::try_from(row.0).unwrap_or(0); + if count >= max_count { + tx.commit().await.map_err(|e| map_db_error(&e))?; + return Ok(None); + } + + let row = sqlx::query( + r" +INSERT INTO objects (object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version) +VALUES ($1, $2, $3, $4, $5, $6, $6, COALESCE($7, '{}'::jsonb), 1) +RETURNING resource_version, created_at_ms, updated_at_ms +", + ) + .bind(object_type) + .bind(id) + .bind(name) + .bind(workspace) + .bind(payload) + .bind(now_ms) + .bind(labels_jsonb) + .fetch_one(&mut *tx) + .await + .map_err(|e| map_db_error(&e))?; + + tx.commit().await.map_err(|e| map_db_error(&e))?; + + let resource_version_i64: i64 = row.try_get("resource_version").unwrap_or(1); + Ok(Some(WriteResult { + resource_version: resource_version_i64.max(1).cast_unsigned(), + created_at_ms: row.get("created_at_ms"), + updated_at_ms: row.get("updated_at_ms"), + })) + } + pub async fn get( &self, object_type: &str, diff --git a/crates/openshell-server/src/persistence/sqlite.rs b/crates/openshell-server/src/persistence/sqlite.rs index c28318d261..c945c417f9 100644 --- a/crates/openshell-server/src/persistence/sqlite.rs +++ b/crates/openshell-server/src/persistence/sqlite.rs @@ -357,6 +357,67 @@ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, ?8, 1) }) } + #[allow(clippy::too_many_arguments)] + pub async fn create_if_workspace_count_below( + &self, + object_type: &str, + id: &str, + name: &str, + workspace: &str, + payload: &[u8], + labels: Option<&str>, + max_count: u64, + ) -> PersistenceResult> { + let now_ms = current_time_ms(); + let mut tx = self + .pool + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|e| map_db_error(&e))?; + + let row: (i64,) = sqlx::query_as( + r#" +SELECT COUNT(*) FROM "objects" +WHERE "object_type" = ?1 AND "workspace" = ?2 +"#, + ) + .bind(object_type) + .bind(workspace) + .fetch_one(&mut *tx) + .await + .map_err(|e| map_db_error(&e))?; + let count = u64::try_from(row.0).unwrap_or(0); + if count >= max_count { + tx.commit().await.map_err(|e| map_db_error(&e))?; + return Ok(None); + } + + sqlx::query( + r#" +INSERT INTO "objects" ("object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version") +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, 1) +"#, + ) + .bind(object_type) + .bind(id) + .bind(name) + .bind(workspace) + .bind(payload) + .bind(now_ms) + .bind(labels.unwrap_or("{}")) + .execute(&mut *tx) + .await + .map_err(|e| map_db_error(&e))?; + + tx.commit().await.map_err(|e| map_db_error(&e))?; + + Ok(Some(WriteResult { + resource_version: 1, + created_at_ms: now_ms, + updated_at_ms: now_ms, + })) + } + pub async fn get( &self, object_type: &str, diff --git a/crates/openshell-server/src/persistence/tests.rs b/crates/openshell-server/src/persistence/tests.rs index 479f46e493..7882c9246c 100644 --- a/crates/openshell-server/src/persistence/tests.rs +++ b/crates/openshell-server/src/persistence/tests.rs @@ -1819,6 +1819,7 @@ async fn cas_update_message_cas_succeeds() { }), spec: None, status: None, + ..Sandbox::default() }; store.put_message(&sandbox).await.unwrap(); @@ -1861,6 +1862,7 @@ async fn cas_update_message_cas_conflicts_on_concurrent_updates() { }), spec: None, status: None, + ..Sandbox::default() }; store.put_message(&sandbox).await.unwrap(); @@ -1931,6 +1933,7 @@ async fn cas_update_message_cas_rejects_workspace_change() { }), spec: None, status: None, + ..Sandbox::default() }; store.put_message(&sandbox).await.unwrap(); @@ -1973,6 +1976,7 @@ async fn cas_update_message_cas_rejects_name_change() { }), spec: None, status: None, + ..Sandbox::default() }; store.put_message(&sandbox).await.unwrap(); diff --git a/crates/openshell-server/tests/common/mod.rs b/crates/openshell-server/tests/common/mod.rs index 9e3957abb6..a60fc8696b 100644 --- a/crates/openshell-server/tests/common/mod.rs +++ b/crates/openshell-server/tests/common/mod.rs @@ -98,6 +98,34 @@ impl OpenShell for TestOpenShell { Ok(Response::new(SandboxResponse::default())) } + async fn create_sandbox_template( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn get_sandbox_template( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn list_sandbox_templates( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn delete_sandbox_template( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn stop_sandbox( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/supervisor_relay_integration.rs b/crates/openshell-server/tests/supervisor_relay_integration.rs index 6cfd3b009b..91ac50dcbd 100644 --- a/crates/openshell-server/tests/supervisor_relay_integration.rs +++ b/crates/openshell-server/tests/supervisor_relay_integration.rs @@ -82,6 +82,34 @@ impl OpenShell for RelayGateway { // ------ unused stubs ------ + async fn create_sandbox_template( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn get_sandbox_template( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn list_sandbox_templates( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn delete_sandbox_template( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + type ConnectSupervisorStream = ReceiverStream>; async fn connect_supervisor( &self, diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index f08f44a3ac..174f9910d0 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1405,6 +1405,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { annotations: HashMap::new(), workspace: workspace.clone(), await_main_process_attachment: false, + workload_template_name: String::new(), }; let sandbox_name = diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index a28d653ead..47853b189d 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -157,6 +157,59 @@ Local directories and Dockerfiles require a local gateway because the CLI builds through the local Docker daemon. Use a registry image reference for remote gateways. +## Reuse Workload Templates + +Sandbox workload templates let workspace admins define reusable runtime shapes for a workspace. A template stores the image, environment, resource requests, and driver-specific configuration that sandboxes should inherit. When you create a sandbox from a template, the create request can still attach providers, labels, and policy, but the workload comes from the named template. + +Create a template: + +```shell +openshell sandbox template create gpu-kata \ + --image registry.example.com/agent:latest \ + --cpu 2 \ + --memory 4Gi \ + --gpu 1 \ + --label team=runtime \ + --env FEATURE_FLAG=on +``` + +Use `--gpu` without a count when the template should request the active +driver's default GPU assignment. Use `--gpu COUNT` when the template needs a +specific number of GPUs. + +Add driver-specific settings when the active compute driver needs them: + +```shell +openshell sandbox template create gpu-kata \ + --image registry.example.com/agent:latest \ + --driver-config-json '{"kubernetes":{"pod":{"runtime_class_name":"kata-containers","node_selector":{"pool":"gpu"}}}}' +``` + +If you omit `--image`, the gateway applies its default sandbox image when a sandbox is created from the template. Use this when the template should only define resource, environment, or driver settings. + +Create a sandbox from a template: + +```shell +openshell sandbox create --template gpu-kata --provider github -- claude +``` + +The `--template` flag cannot be combined with inline workload flags such as `--from`, `--cpu`, `--memory`, `--gpu`, `--env`, or `--driver-config-json`. Put those values on the template instead. Create-time policy and provider attachments remain part of the sandbox request, so each sandbox can keep its own access boundary. + +Inspect and manage templates: + +```shell +openshell sandbox template list +openshell sandbox template list --label-selector team=runtime +openshell sandbox template get gpu-kata +openshell sandbox template delete gpu-kata +``` + +Use `--all-workspaces` with `sandbox template list` when you need an admin view across workspaces: + +```shell +openshell sandbox template list --all-workspaces +``` + ## Base Sandbox Container The `base` sandbox container is the default runtime image for standard OpenShell sandboxes unless the gateway overrides its default sandbox image. It is published as `ghcr.io/nvidia/openshell-community/sandboxes/base:latest` and maintained in the [OpenShell Community](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) repository. @@ -295,6 +348,24 @@ with SandboxClient.from_active_cluster() as client: assert sandbox.id in {s.id for s in matches} ``` +Create reusable sandbox templates through the Python SDK when several runs +should share the same workload shape: + +```python +from openshell import SandboxClient + +with SandboxClient.from_active_cluster() as client: + templates = client.sandbox_templates() + + templates.create( + workspace="default", + name="python", + image="ghcr.io/nvidia/openshell-community/sandboxes/python:latest", + ) + + sandbox = client.create_from_template(workspace="default", template_name="python") +``` + For non-interactive automation, pass a renewable client-credentials provider. Omitted issuer, client ID, audience, and scopes are read from the active gateway's metadata. The client requires TLS for non-loopback gateways: diff --git a/docs/sandboxes/manage-workspaces.mdx b/docs/sandboxes/manage-workspaces.mdx index 05378af5f5..86da25fbb9 100644 --- a/docs/sandboxes/manage-workspaces.mdx +++ b/docs/sandboxes/manage-workspaces.mdx @@ -9,8 +9,9 @@ position: 3 --- An OpenShell workspace is an access and resource isolation boundary. Sandboxes, -providers, services, policies, settings, and inference routes belong to a -workspace and are not visible to members of other workspaces. +sandbox workload templates, providers, services, policies, settings, and +inference routes belong to a workspace and are not visible to members of other +workspaces. The CLI targets the `default` workspace unless you set `--workspace` or `OPENSHELL_WORKSPACE`. The logical OpenShell workspace described here is @@ -49,6 +50,8 @@ The following table summarizes common operations. | Add Workspace Users or remove members | Any workspace | Assigned workspace | No | | Assign the Workspace Admin role | Any workspace | No | No | | Create, use, or delete sandboxes and services | Any workspace | Assigned workspace | Assigned workspace | +| Create or delete sandbox workload templates | Any workspace | Assigned workspace | No | +| Read or list sandbox workload templates | Any workspace | Assigned workspace | Assigned workspace | | Create, update, or delete providers | Any workspace | Assigned workspace | No | | Change workspace policy or settings | Any workspace | Assigned workspace | No | | Manage platform profiles or global configuration | Yes | No | No | @@ -187,11 +190,11 @@ be deleted. openshell workspace delete team-ml ``` -A custom workspace must not contain sandboxes, providers, provider profiles, -services, SSH sessions, settings, policies, draft policy chunks, or credential -refresh state. Remove those resources before retrying deletion. OpenShell -removes membership records and inference routes as part of successful -workspace deletion. +A custom workspace must not contain sandboxes, sandbox workload templates, +providers, provider profiles, services, SSH sessions, settings, policies, draft +policy chunks, or credential refresh state. Remove those resources before +retrying deletion. OpenShell removes membership records and inference routes as +part of successful workspace deletion. ## Next Steps diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 6dca586879..6dbf46a392 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -168,6 +168,11 @@ name = "workspace_lifecycle" path = "tests/workspace_lifecycle.rs" required-features = ["e2e"] +[[test]] +name = "sandbox_templates" +path = "tests/sandbox_templates.rs" +required-features = ["e2e"] + [[test]] name = "proxy_egress_pipeline" path = "tests/proxy_egress_pipeline.rs" diff --git a/e2e/rust/tests/sandbox_templates.rs b/e2e/rust/tests/sandbox_templates.rs new file mode 100644 index 0000000000..21c9233daf --- /dev/null +++ b/e2e/rust/tests/sandbox_templates.rs @@ -0,0 +1,275 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "e2e")] + +//! E2E coverage for reusable sandbox workload templates. + +use std::process::Stdio; +use std::time::{SystemTime, UNIX_EPOCH}; + +use openshell_e2e::harness::binary::{openshell_bin, openshell_cmd}; +use openshell_e2e::harness::output::strip_ansi; +use openshell_e2e::harness::sandbox::SandboxGuard; +use serde_json::Value; + +struct CliResult { + output: String, + success: bool, +} + +struct TemplateGuard { + name: String, +} + +impl TemplateGuard { + fn new(name: String) -> Self { + Self { name } + } + + async fn cleanup(mut self) { + delete_template(&self.name).await; + self.name.clear(); + } +} + +impl Drop for TemplateGuard { + fn drop(&mut self) { + if self.name.is_empty() { + return; + } + let bin = openshell_bin(); + let _ = std::process::Command::new(&bin) + .args(["sandbox", "template", "delete", &self.name]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } +} + +async fn run_cli(args: &[&str]) -> CliResult { + let mut cmd = openshell_cmd(); + cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); + + let output = cmd.output().await.expect("spawn openshell command"); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let combined = format!("{stdout}{stderr}"); + + CliResult { + output: strip_ansi(&combined), + success: output.status.success(), + } +} + +async fn delete_template(name: &str) { + let mut cmd = openshell_cmd(); + cmd.args(["sandbox", "template", "delete", name]) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let _ = cmd.status().await; +} + +fn unique_name(prefix: &str) -> String { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before epoch") + .as_millis(); + let suffix = millis % 1_000_000; + format!("{prefix}-{suffix:06}") +} + +fn output_contains(output: &str, needle: &str) -> bool { + output.to_lowercase().contains(&needle.to_lowercase()) +} + +fn output_mentions_template_not_found(output: &str) -> bool { + output_contains(output, "sandbox template") && output_contains(output, "not found") +} + +#[tokio::test] +async fn sandbox_create_from_template_uses_reusable_workload() { + let template_name = unique_name("tmpl"); + let sandbox_name = unique_name("sb-tmpl"); + let template = TemplateGuard::new(template_name.clone()); + + let create_template = run_cli(&[ + "sandbox", + "template", + "create", + &template_name, + "--cpu", + "500m", + "--memory", + "512Mi", + "--label", + "e2e=sandbox-template", + "--env", + "FEATURE_FLAG=on", + ]) + .await; + assert!( + create_template.success, + "sandbox template create failed:\n{}", + create_template.output + ); + + let get_template = run_cli(&[ + "sandbox", + "template", + "get", + &template_name, + "--output", + "json", + ]) + .await; + assert!( + get_template.success, + "sandbox template get failed:\n{}", + get_template.output + ); + let template_json: Value = + serde_json::from_str(&get_template.output).expect("template JSON output"); + assert_eq!(template_json["name"].as_str(), Some(template_name.as_str())); + assert_eq!( + template_json["labels"]["e2e"].as_str(), + Some("sandbox-template") + ); + assert_eq!( + template_json["environment"]["FEATURE_FLAG"].as_str(), + Some("on") + ); + assert_eq!(template_json["resources"]["cpu"].as_str(), Some("500m")); + assert_eq!(template_json["resources"]["memory"].as_str(), Some("512Mi")); + + let list_templates = run_cli(&["sandbox", "template", "list", "--names"]).await; + assert!( + list_templates.success, + "sandbox template list failed:\n{}", + list_templates.output + ); + assert!( + list_templates + .output + .lines() + .any(|line| line.trim() == template_name), + "template list should include {template_name}:\n{}", + list_templates.output + ); + + let mut sandbox = SandboxGuard::create(&[ + "--name", + &sandbox_name, + "--template", + &template_name, + "--", + "sh", + "-lc", + "test \"$FEATURE_FLAG\" = on && echo template-env-ok", + ]) + .await + .expect("sandbox create from template should succeed"); + + assert!( + sandbox.create_output.contains("template-env-ok"), + "sandbox should inherit template environment:\n{}", + sandbox.create_output + ); + + sandbox.cleanup().await; + template.cleanup().await; +} + +#[tokio::test] +async fn sandbox_template_get_after_delete_returns_not_found() { + let template_name = unique_name("tmpl-del"); + let template = TemplateGuard::new(template_name.clone()); + + let create_template = run_cli(&["sandbox", "template", "create", &template_name]).await; + assert!( + create_template.success, + "sandbox template create failed:\n{}", + create_template.output + ); + + let delete_template = run_cli(&["sandbox", "template", "delete", &template_name]).await; + assert!( + delete_template.success, + "sandbox template delete failed:\n{}", + delete_template.output + ); + + let get_deleted = run_cli(&["sandbox", "template", "get", &template_name]).await; + assert!( + !get_deleted.success, + "deleted template should not be fetchable:\n{}", + get_deleted.output + ); + assert!( + output_mentions_template_not_found(&get_deleted.output), + "deleted template should return not-found:\n{}", + get_deleted.output + ); + + template.cleanup().await; +} + +#[tokio::test] +async fn sandbox_template_create_rejects_duplicate_name() { + let template_name = unique_name("tmpl-dupe"); + let template = TemplateGuard::new(template_name.clone()); + + let create_template = run_cli(&["sandbox", "template", "create", &template_name]).await; + assert!( + create_template.success, + "sandbox template create failed:\n{}", + create_template.output + ); + + let duplicate = run_cli(&["sandbox", "template", "create", &template_name]).await; + assert!( + !duplicate.success, + "duplicate sandbox template create should fail:\n{}", + duplicate.output + ); + assert!( + output_contains(&duplicate.output, "already exists"), + "duplicate sandbox template create should report already-exists:\n{}", + duplicate.output + ); + + template.cleanup().await; +} + +#[tokio::test] +async fn sandbox_create_from_missing_template_returns_not_found() { + let template_name = unique_name("tmpl-missing"); + let sandbox_name = unique_name("sb-missing"); + + let create_sandbox = run_cli(&[ + "sandbox", + "create", + "--name", + &sandbox_name, + "--template", + &template_name, + "--", + "true", + ]) + .await; + + if create_sandbox.success { + let _ = run_cli(&["sandbox", "delete", &sandbox_name]).await; + } + + assert!( + !create_sandbox.success, + "sandbox create from missing template should fail:\n{}", + create_sandbox.output + ); + assert!( + output_mentions_template_not_found(&create_sandbox.output), + "sandbox create from missing template should report not-found:\n{}", + create_sandbox.output + ); +} diff --git a/proto/openshell.proto b/proto/openshell.proto index a2d7a0ad02..138b973474 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -6,6 +6,7 @@ syntax = "proto3"; package openshell.v1; import "datamodel.proto"; +import "google/protobuf/duration.proto"; import "google/protobuf/struct.proto"; import "options.proto"; import "sandbox.proto"; @@ -69,6 +70,46 @@ service OpenShell { }; } + // Create a reusable sandbox workload template. + rpc CreateSandboxTemplate(CreateSandboxTemplateRequest) + returns (SandboxTemplateResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:write" + workspace_role: "admin" + }; + } + + // Fetch a reusable sandbox workload template by name. + rpc GetSandboxTemplate(GetSandboxTemplateRequest) + returns (SandboxTemplateResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:read" + workspace_role: "user" + }; + } + + // List reusable sandbox workload templates. + rpc ListSandboxTemplates(ListSandboxTemplatesRequest) + returns (ListSandboxTemplatesResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:read" + workspace_role: "user" + }; + } + + // Delete a reusable sandbox workload template by name. + rpc DeleteSandboxTemplate(DeleteSandboxTemplateRequest) + returns (DeleteSandboxTemplateResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:write" + workspace_role: "admin" + }; + } + // List provider records attached to a sandbox. rpc ListSandboxProviders(ListSandboxProvidersRequest) returns (ListSandboxProvidersResponse) { @@ -813,6 +854,8 @@ message Sandbox { SandboxSpec spec = 2; // Latest user-facing observed status derived by the gateway. SandboxStatus status = 3; + // Read-only provenance for sandboxes created from a reusable workload template. + SandboxWorkloadTemplateProvenance created_from_workload_template = 20; reserved 4, 5; reserved "phase", "current_policy_version"; @@ -861,7 +904,12 @@ message GpuResourceRequirements { optional uint32 count = 1; } -// Public sandbox template mapped onto compute-driver template inputs. +// Historical inline compute template mapped onto compute-driver template inputs. +// +// Despite its name, this is not a reusable named sandbox template resource. It +// is an inline part of `SandboxSpec` kept for v1 compatibility. A future +// breaking API cleanup may rename this message to free `SandboxTemplate` for +// the reusable template resource now represented by `SandboxWorkloadTemplate`. message SandboxTemplate { // Fully-qualified OCI image reference used to boot the sandbox. string image = 1; @@ -892,6 +940,62 @@ message SandboxTemplate { google.protobuf.Struct driver_config = 11; } +// Reusable named sandbox workload template resource. +// +// This is the actual workspace-scoped template resource used to create +// sandboxes by reference. It uses the longer name in v1 to avoid colliding with +// the historical inline `SandboxTemplate` message. A future breaking API +// cleanup may rename this resource to `SandboxTemplate`. +message SandboxWorkloadTemplate { + // Kubernetes-style metadata (id, name, labels, timestamps, resource version). + openshell.datamodel.v1.ObjectMeta metadata = 1; + // Desired reusable workload shape and template-owned driver config. + SandboxWorkloadTemplateSpec spec = 2; +} + +message SandboxWorkloadTemplateSpec { + // Portable workload shape. + SandboxWorkloadConfig workload = 1; + // Driver-keyed opaque config envelope supplied by the template owner. + google.protobuf.Struct driver_config = 2; + // Desired service level associated with this template. + SandboxServiceLevel desired_service_level = 3; +} + +message SandboxWorkloadConfig { + // Fully-qualified OCI image reference used to boot the sandbox. + string image = 1; + // Environment variables injected into the sandbox runtime. + map environment = 2; + // Portable resource requirements for sandboxes created from this workload. + SandboxResources resources = 3; +} + +message SandboxResources { + // Portable CPU quantity, for example "500m" or "2". + string cpu = 1; + // Portable memory quantity, for example "512Mi" or "2Gi". + string memory = 2; + // GPU requirements for the sandbox workload. Presence indicates a GPU + // request. When count is omitted, the request uses the selected driver's + // default GPU assignment behavior. + GpuResourceRequirements gpu = 3; +} + +message SandboxServiceLevel { + SandboxStartup startup = 1; +} + +message SandboxStartup { + google.protobuf.Duration ready_within = 1; + uint32 max_burst = 2; +} + +message SandboxWorkloadTemplateProvenance { + string name = 1; + string resource_version = 2; +} + // User-facing sandbox status derived by the gateway from compute-driver observations. // // Public status does not embed driver-only flags such as `deleting`. @@ -982,6 +1086,49 @@ message CreateSandboxRequest { // the canonical main process. The supervisor keeps the terminal transport // alive until that attachment connects and closes naturally. bool await_main_process_attachment = 6; + // Workspace-scoped SandboxWorkloadTemplate name to resolve at creation time. + string workload_template_name = 7; +} + +message CreateSandboxTemplateRequest { + SandboxWorkloadTemplate template = 1; + // Workspace for the template. Empty defaults to "default". + string workspace = 2; +} + +message GetSandboxTemplateRequest { + string name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; +} + +message ListSandboxTemplatesRequest { + uint32 limit = 1; + uint32 offset = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; + // List across all workspaces. Mutually exclusive with workspace. + bool all_workspaces = 4; + // Optional label selector in key=value comma-separated form. + string label_selector = 5; +} + +message DeleteSandboxTemplateRequest { + string name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; +} + +message SandboxTemplateResponse { + SandboxWorkloadTemplate template = 1; +} + +message ListSandboxTemplatesResponse { + repeated SandboxWorkloadTemplate templates = 1; +} + +message DeleteSandboxTemplateResponse { + bool deleted = 1; } // Get sandbox request. diff --git a/python/openshell/__init__.py b/python/openshell/__init__.py index 27f317578b..4001d2fb70 100644 --- a/python/openshell/__init__.py +++ b/python/openshell/__init__.py @@ -17,6 +17,8 @@ SandboxRef, SandboxSession, SandboxStatusRef, + SandboxTemplateClient, + SandboxWorkloadTemplateProvenanceRef, TlsConfig, WorkspaceClient, WorkspaceRef, @@ -41,6 +43,8 @@ "SandboxRef", "SandboxSession", "SandboxStatusRef", + "SandboxTemplateClient", + "SandboxWorkloadTemplateProvenanceRef", "TlsConfig", "WorkspaceClient", "WorkspaceRef", diff --git a/python/openshell/openshell_test.py b/python/openshell/openshell_test.py index 66daac6ee1..101b7b6833 100644 --- a/python/openshell/openshell_test.py +++ b/python/openshell/openshell_test.py @@ -9,3 +9,7 @@ def test_version() -> None: """Test that version is defined.""" assert openshell.__version__ + + +def test_sandbox_template_client_exported() -> None: + assert openshell.SandboxTemplateClient diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index 5653d8dc5e..07eeb6480e 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -394,6 +394,12 @@ def __reduce_ex__(self, protocol: SupportsIndex, /) -> str | tuple[Any, ...]: return type(self), (dict(self),) +@dataclass(frozen=True) +class SandboxWorkloadTemplateProvenanceRef: + name: str + resource_version: str + + @dataclass(frozen=True) class SandboxRef: id: str @@ -403,6 +409,7 @@ class SandboxRef: # Excluded from equality/hash to preserve the original identity while the # immutable mapping remains safe for deepcopy, pickle, and asdict. labels: Mapping[str, str] = field(default_factory=_ImmutableLabels, compare=False) + created_from_workload_template: SandboxWorkloadTemplateProvenanceRef | None = None def __post_init__(self) -> None: object.__setattr__(self, "labels", _ImmutableLabels(self.labels)) @@ -726,6 +733,33 @@ def create( raise SandboxError("CreateSandbox returned empty sandbox id") return sandbox_ref + def create_from_template( + self, + *, + workspace: str, + template_name: str, + spec: openshell_pb2.SandboxSpec | None = None, + name: str | None = None, + labels: Mapping[str, str] | None = None, + ) -> SandboxRef: + if not template_name.strip(): + raise SandboxError("template_name is required") + request_spec = spec if spec is not None else openshell_pb2.SandboxSpec() + response = self._stub.CreateSandbox( + openshell_pb2.CreateSandboxRequest( + spec=request_spec, + name=name or "", + labels=dict(labels) if labels else {}, + workspace=workspace, + workload_template_name=template_name, + ), + timeout=self._timeout, + ) + sandbox_ref = _sandbox_ref(response.sandbox) + if sandbox_ref.id == "": + raise SandboxError("CreateSandbox returned empty sandbox id") + return sandbox_ref + def create_session( self, *, @@ -738,6 +772,29 @@ def create_session( self, self.create(workspace=workspace, spec=spec, name=name, labels=labels) ) + def create_session_from_template( + self, + *, + workspace: str, + template_name: str, + spec: openshell_pb2.SandboxSpec | None = None, + name: str | None = None, + labels: Mapping[str, str] | None = None, + ) -> SandboxSession: + return SandboxSession( + self, + self.create_from_template( + workspace=workspace, + template_name=template_name, + spec=spec, + name=name, + labels=labels, + ), + ) + + def sandbox_templates(self) -> SandboxTemplateClient: + return SandboxTemplateClient(self._channel, timeout=self._timeout) + def get(self, sandbox_name: str, *, workspace: str) -> SandboxRef: response = self._stub.GetSandbox( openshell_pb2.GetSandboxRequest(name=sandbox_name, workspace=workspace), @@ -1027,6 +1084,126 @@ def exec_python( ) +class SandboxTemplateClient: + """gRPC client for reusable sandbox template lifecycle operations.""" + + def __init__(self, channel: grpc.Channel, *, timeout: float = 30.0) -> None: + self._stub = openshell_pb2_grpc.OpenShellStub(channel) + self._timeout = timeout + + @classmethod + def from_sandbox_client(cls, client: SandboxClient) -> SandboxTemplateClient: + return client.sandbox_templates() + + def create( + self, + *, + workspace: str, + template: openshell_pb2.SandboxWorkloadTemplate | None = None, + name: str | None = None, + image: str | None = None, + labels: Mapping[str, str] | None = None, + annotations: Mapping[str, str] | None = None, + environment: Mapping[str, str] | None = None, + cpu: str | None = None, + memory: str | None = None, + gpu_count: int | None = None, + gpu: bool = False, + driver_config: Mapping[str, Any] | None = None, + ) -> openshell_pb2.SandboxWorkloadTemplate: + if template is None: + if name is None or not name.strip(): + raise SandboxError("name is required when template is omitted") + template = _sandbox_workload_template( + name=name, + image=image, + labels=labels, + annotations=annotations, + environment=environment, + cpu=cpu, + memory=memory, + gpu_count=gpu_count, + gpu=gpu, + driver_config=driver_config, + ) + elif ( + any(value is not None for value in (name, image, cpu, memory, gpu_count)) + or any( + bool(value) + for value in (labels, annotations, environment, driver_config) + ) + or gpu + ): + raise SandboxError( + "template cannot be combined with template builder fields" + ) + + response = self._stub.CreateSandboxTemplate( + openshell_pb2.CreateSandboxTemplateRequest( + workspace=workspace, + template=template, + ), + timeout=self._timeout, + ) + return response.template + + def get( + self, + name: str, + *, + workspace: str, + ) -> openshell_pb2.SandboxWorkloadTemplate: + response = self._stub.GetSandboxTemplate( + openshell_pb2.GetSandboxTemplateRequest(name=name, workspace=workspace), + timeout=self._timeout, + ) + return response.template + + def list( + self, + *, + workspace: str, + limit: int = 100, + offset: int = 0, + label_selector: str = "", + ) -> builtins.list[openshell_pb2.SandboxWorkloadTemplate]: + response = self._stub.ListSandboxTemplates( + openshell_pb2.ListSandboxTemplatesRequest( + workspace=workspace, + limit=limit, + offset=offset, + label_selector=label_selector, + ), + timeout=self._timeout, + ) + return list(response.templates) + + def list_for_all_workspaces( + self, + *, + limit: int = 100, + offset: int = 0, + label_selector: str = "", + ) -> builtins.list[openshell_pb2.SandboxWorkloadTemplate]: + response = self._stub.ListSandboxTemplates( + openshell_pb2.ListSandboxTemplatesRequest( + all_workspaces=True, + limit=limit, + offset=offset, + label_selector=label_selector, + ), + timeout=self._timeout, + ) + return list(response.templates) + + def delete(self, name: str, *, workspace: str) -> bool: + response = self._stub.DeleteSandboxTemplate( + openshell_pb2.DeleteSandboxTemplateRequest(name=name, workspace=workspace), + timeout=self._timeout, + ) + return bool(response.deleted) + + @dataclass(frozen=True) class InferenceRouteConfig: provider_name: str @@ -1182,6 +1359,7 @@ def __init__( spec: openshell_pb2.SandboxSpec | None = None, name: str | None = None, labels: Mapping[str, str] | None = None, + template_name: str | None = None, timeout: float = 30.0, ready_timeout_seconds: float = 120.0, auto_refresh: bool = True, @@ -1207,6 +1385,7 @@ def __init__( self._name = name # Copy so later caller mutation cannot change what gets sent on enter. self._labels = dict(labels) if labels is not None else None + self._template_name = template_name self._timeout = timeout self._ready_timeout_seconds = ready_timeout_seconds self._auto_refresh = auto_refresh @@ -1232,10 +1411,12 @@ def __enter__(self) -> Sandbox: # Creation metadata cannot be applied when attaching to an existing # sandbox; reject it before opening a connection. if self._sandbox_input is not None and ( - self._name is not None or self._labels is not None + self._name is not None + or self._labels is not None + or self._template_name is not None ): raise SandboxError( - "name and labels cannot be set when attaching to an existing sandbox" + "name, labels, and template_name cannot be set when attaching to an existing sandbox" ) client = SandboxClient.from_active_cluster( @@ -1248,7 +1429,15 @@ def __enter__(self) -> Sandbox: ) self._client = client - if self._sandbox_input is None: + if self._sandbox_input is None and self._template_name is not None: + self._session = client.create_session_from_template( + workspace=self._workspace, + template_name=self._template_name, + spec=self._spec, + name=self._name, + labels=self._labels, + ) + elif self._sandbox_input is None: self._session = client.create_session( workspace=self._workspace, spec=self._spec, @@ -1374,6 +1563,14 @@ def _serialize_python_callable( def _sandbox_ref(sandbox: openshell_pb2.Sandbox) -> SandboxRef: status = sandbox.status if sandbox.HasField("status") else None + provenance = ( + SandboxWorkloadTemplateProvenanceRef( + name=sandbox.created_from_workload_template.name, + resource_version=sandbox.created_from_workload_template.resource_version, + ) + if sandbox.HasField("created_from_workload_template") + else None + ) return SandboxRef( id=sandbox.metadata.id if sandbox.metadata else "", name=sandbox.metadata.name if sandbox.metadata else "", @@ -1386,6 +1583,7 @@ def _sandbox_ref(sandbox: openshell_pb2.Sandbox) -> SandboxRef: else None, ), labels=sandbox.metadata.labels if sandbox.metadata else {}, + created_from_workload_template=provenance, ) @@ -1398,6 +1596,50 @@ def _default_spec() -> openshell_pb2.SandboxSpec: return openshell_pb2.SandboxSpec() +def _sandbox_workload_template( + *, + name: str, + image: str | None = None, + labels: Mapping[str, str] | None = None, + annotations: Mapping[str, str] | None = None, + environment: Mapping[str, str] | None = None, + cpu: str | None = None, + memory: str | None = None, + gpu_count: int | None = None, + gpu: bool = False, + driver_config: Mapping[str, Any] | None = None, +) -> openshell_pb2.SandboxWorkloadTemplate: + if gpu_count is not None and gpu_count <= 0: + raise SandboxError("gpu_count must be greater than zero") + + template = openshell_pb2.SandboxWorkloadTemplate() + template.metadata.name = name + # The gateway permits an empty workload so it can apply the default image + # at sandbox create time, but the workload message itself is required. + template.spec.workload.SetInParent() + if labels: + template.metadata.labels.update(dict(labels)) + if annotations: + template.metadata.annotations.update(dict(annotations)) + + if image is not None: + template.spec.workload.image = image + if environment: + template.spec.workload.environment.update(dict(environment)) + if cpu is not None: + template.spec.workload.resources.cpu = cpu + if memory is not None: + template.spec.workload.resources.memory = memory + if gpu or gpu_count is not None: + template.spec.workload.resources.gpu.SetInParent() + if gpu_count is not None: + template.spec.workload.resources.gpu.count = gpu_count + if driver_config: + template.spec.driver_config.update(dict(driver_config)) + + return template + + def _xdg_config_home() -> pathlib.Path: configured = os.environ.get("XDG_CONFIG_HOME") if configured: diff --git a/python/openshell/sandbox_test.py b/python/openshell/sandbox_test.py index 23382d1093..5e12d0c8a3 100644 --- a/python/openshell/sandbox_test.py +++ b/python/openshell/sandbox_test.py @@ -29,6 +29,7 @@ SandboxError, SandboxRef, SandboxStatusRef, + SandboxTemplateClient, TlsConfig, _atomic_replace, _BearerAuthInterceptor, @@ -437,6 +438,13 @@ def _client_with_fake_stub(stub: object) -> SandboxClient: return client +def _template_client_with_fake_stub(stub: object) -> SandboxTemplateClient: + client = cast("SandboxTemplateClient", object.__new__(SandboxTemplateClient)) + client._timeout = 30.0 + client._stub = cast("Any", stub) + return client + + def test_exec_sends_stdin_payload() -> None: stub = _FakeStub() client = _client_with_fake_stub(stub) @@ -1991,6 +1999,20 @@ def _make_sandbox_proto( return sandbox +def _make_workload_template_proto( + name: str, + *, + workspace: str = "default", +) -> openshell_pb2.SandboxWorkloadTemplate: + template = openshell_pb2.SandboxWorkloadTemplate() + template.metadata.name = name + template.metadata.workspace = workspace + template.spec.workload.image = f"ghcr.io/test/{name}:latest" + template.spec.workload.resources.cpu = "1" + template.spec.workload.resources.memory = "512Mi" + return template + + class _FakeSandboxStub: def __init__(self, listed: list[openshell_pb2.Sandbox] | None = None) -> None: self.create_request: openshell_pb2.CreateSandboxRequest | None = None @@ -1999,7 +2021,18 @@ def __init__(self, listed: list[openshell_pb2.Sandbox] | None = None) -> None: self.delete_request: openshell_pb2.DeleteSandboxRequest | None = None self.stop_request: openshell_pb2.StopSandboxRequest | None = None self.start_request: openshell_pb2.StartSandboxRequest | None = None + self.create_template_request: ( + openshell_pb2.CreateSandboxTemplateRequest | None + ) = None + self.get_template_request: openshell_pb2.GetSandboxTemplateRequest | None = None + self.list_template_request: openshell_pb2.ListSandboxTemplatesRequest | None = ( + None + ) + self.delete_template_request: ( + openshell_pb2.DeleteSandboxTemplateRequest | None + ) = None self._listed = listed or [] + self._templates: list[openshell_pb2.SandboxWorkloadTemplate] = [] def GetSandbox( self, @@ -2080,12 +2113,55 @@ def ListSandboxes( _ = timeout return SimpleNamespace(sandboxes=list(self._listed)) + def CreateSandboxTemplate( + self, + request: openshell_pb2.CreateSandboxTemplateRequest, + timeout: float | None = None, + ) -> Any: + self.create_template_request = request + _ = timeout + self._templates.append(request.template) + return SimpleNamespace(template=request.template) + + def GetSandboxTemplate( + self, + request: openshell_pb2.GetSandboxTemplateRequest, + timeout: float | None = None, + ) -> Any: + self.get_template_request = request + _ = timeout + return SimpleNamespace( + template=_make_workload_template_proto( + request.name, + workspace=request.workspace or "default", + ) + ) + + def ListSandboxTemplates( + self, + request: openshell_pb2.ListSandboxTemplatesRequest, + timeout: float | None = None, + ) -> Any: + self.list_template_request = request + _ = timeout + return SimpleNamespace(templates=list(self._templates)) + + def DeleteSandboxTemplate( + self, + request: openshell_pb2.DeleteSandboxTemplateRequest, + timeout: float | None = None, + ) -> Any: + self.delete_template_request = request + _ = timeout + return SimpleNamespace(deleted=True) + class _RecordingHighLevelClient: """A stand-in for SandboxClient used to observe high-level forwarding.""" def __init__(self) -> None: self.create_kwargs: dict[str, Any] | None = None + self.create_template_kwargs: dict[str, Any] | None = None def create_session( self, @@ -2103,6 +2179,24 @@ def create_session( } return SimpleNamespace(sandbox=SimpleNamespace(name=name or "generated")) + def create_session_from_template( + self, + *, + workspace: str, + template_name: str, + spec: Any = None, + name: str | None = None, + labels: Any = None, + ) -> Any: + self.create_template_kwargs = { + "workspace": workspace, + "template_name": template_name, + "spec": spec, + "name": name, + "labels": labels, + } + return SimpleNamespace(sandbox=SimpleNamespace(name=name or "generated")) + def wait_ready( self, name: str, *, workspace: str, timeout_seconds: float = 300.0 ) -> SandboxRef: @@ -2129,6 +2223,255 @@ def test_create_forwards_name_and_labels() -> None: assert dict(ref.labels) == {"aiq": "deep-research"} +def test_create_from_template_forwards_workload_template_name() -> None: + stub = _FakeSandboxStub() + client = _client_with_fake_stub(stub) + spec = openshell_pb2.SandboxSpec( + providers=["github"], + command=["/opt/worker", "--serve"], + tty=True, + ) + + ref = client.create_from_template( + workspace="default", + template_name="gpu-kata", + spec=spec, + name="job-1", + labels={"team": "runtime"}, + ) + + assert stub.create_request is not None + assert stub.create_request.name == "job-1" + assert stub.create_request.workload_template_name == "gpu-kata" + assert dict(stub.create_request.labels) == {"team": "runtime"} + assert list(stub.create_request.spec.providers) == ["github"] + assert list(stub.create_request.spec.command) == ["/opt/worker", "--serve"] + assert stub.create_request.spec.tty is True + assert dict(ref.labels) == {"team": "runtime"} + + +def test_create_from_template_rejects_empty_template_name() -> None: + stub = _FakeSandboxStub() + client = _client_with_fake_stub(stub) + + with pytest.raises(SandboxError): + client.create_from_template(workspace="default", template_name=" ") + + assert stub.create_request is None + + +def test_sandbox_template_create_builds_template_from_public_fields() -> None: + stub = _FakeSandboxStub() + client = _template_client_with_fake_stub(stub) + + created = client.create( + workspace="default", + name="gpu-kata", + image="ghcr.io/test/gpu-kata:latest", + labels={"team": "runtime"}, + annotations={"owner": "platform"}, + environment={"FEATURE_FLAG": "on"}, + cpu="1", + memory="512Mi", + gpu_count=2, + driver_config={"kubernetes": {"runtime_class_name": "kata"}}, + ) + + assert created.metadata.name == "gpu-kata" + assert stub.create_template_request is not None + assert stub.create_template_request.workspace == "default" + template = stub.create_template_request.template + assert template.metadata.name == "gpu-kata" + assert dict(template.metadata.labels) == {"team": "runtime"} + assert dict(template.metadata.annotations) == {"owner": "platform"} + assert template.spec.workload.image == "ghcr.io/test/gpu-kata:latest" + assert dict(template.spec.workload.environment) == {"FEATURE_FLAG": "on"} + assert template.spec.workload.resources.cpu == "1" + assert template.spec.workload.resources.memory == "512Mi" + assert template.spec.workload.resources.gpu.count == 2 + assert template.spec.driver_config["kubernetes"]["runtime_class_name"] == "kata" + + +def test_sandbox_template_create_materializes_default_workload() -> None: + stub = _FakeSandboxStub() + client = _template_client_with_fake_stub(stub) + + client.create(workspace="default", name="base") + + assert stub.create_template_request is not None + template = stub.create_template_request.template + assert template.HasField("spec") + assert template.spec.HasField("workload") + assert template.spec.workload.image == "" + + +def test_sandbox_template_create_materializes_workload_with_driver_config_only() -> ( + None +): + stub = _FakeSandboxStub() + client = _template_client_with_fake_stub(stub) + + client.create( + workspace="default", + name="kata-default-image", + driver_config={"kubernetes": {"runtime_class_name": "kata"}}, + ) + + assert stub.create_template_request is not None + template = stub.create_template_request.template + assert template.HasField("spec") + assert template.spec.HasField("workload") + assert template.spec.workload.image == "" + assert template.spec.driver_config["kubernetes"]["runtime_class_name"] == "kata" + + +def test_sandbox_template_create_rejects_missing_public_name() -> None: + stub = _FakeSandboxStub() + client = _template_client_with_fake_stub(stub) + + with pytest.raises(SandboxError): + client.create(workspace="default", image="ghcr.io/test/python:latest") + + assert stub.create_template_request is None + + +def test_sandbox_template_create_rejects_template_and_builder_fields() -> None: + stub = _FakeSandboxStub() + client = _template_client_with_fake_stub(stub) + template = _make_workload_template_proto("gpu-kata") + + with pytest.raises(SandboxError): + client.create(workspace="default", template=template, image="override") + + assert stub.create_template_request is None + + +@pytest.mark.parametrize( + "builder_kwargs", + ( + {"labels": {}}, + {"annotations": {}}, + {"environment": {}}, + {"driver_config": {}}, + ), +) +def test_sandbox_template_create_allows_template_and_empty_builder_mappings( + builder_kwargs: dict[str, Any], +) -> None: + stub = _FakeSandboxStub() + client = _template_client_with_fake_stub(stub) + template = _make_workload_template_proto("gpu-kata") + + created = client.create( + workspace="default", + template=template, + **builder_kwargs, + ) + + assert created.metadata.name == "gpu-kata" + assert stub.create_template_request is not None + assert stub.create_template_request.template.metadata.name == template.metadata.name + assert ( + stub.create_template_request.template.spec.workload.image + == template.spec.workload.image + ) + + +@pytest.mark.parametrize( + "builder_kwargs", + ( + {"labels": {"team": "runtime"}}, + {"annotations": {"owner": "platform"}}, + {"environment": {"FEATURE_FLAG": "on"}}, + {"driver_config": {"kubernetes": {"runtime_class_name": "kata"}}}, + ), +) +def test_sandbox_template_create_rejects_template_and_non_empty_builder_mappings( + builder_kwargs: dict[str, Any], +) -> None: + stub = _FakeSandboxStub() + client = _template_client_with_fake_stub(stub) + template = _make_workload_template_proto("gpu-kata") + + with pytest.raises(SandboxError): + client.create( + workspace="default", + template=template, + **builder_kwargs, + ) + + assert stub.create_template_request is None + + +def test_sandbox_template_create_rejects_non_positive_gpu_count() -> None: + stub = _FakeSandboxStub() + client = _template_client_with_fake_stub(stub) + + with pytest.raises(SandboxError): + client.create(workspace="default", name="gpu-kata", gpu_count=0) + + assert stub.create_template_request is None + + +def test_sandbox_template_client_crud_forwards_requests() -> None: + stub = _FakeSandboxStub() + client = _template_client_with_fake_stub(stub) + template = _make_workload_template_proto("gpu-kata") + template.spec.driver_config.update({"kubernetes": {"runtime_class_name": "kata"}}) + + created = client.create(workspace="default", template=template) + + assert created.metadata.name == "gpu-kata" + assert stub.create_template_request is not None + assert stub.create_template_request.workspace == "default" + assert ( + stub.create_template_request.template.spec.workload.image + == "ghcr.io/test/gpu-kata:latest" + ) + assert ( + stub.create_template_request.template.spec.driver_config["kubernetes"][ + "runtime_class_name" + ] + == "kata" + ) + + got = client.get("gpu-kata", workspace="default") + assert got.metadata.name == "gpu-kata" + assert stub.get_template_request is not None + assert stub.get_template_request.name == "gpu-kata" + assert stub.get_template_request.workspace == "default" + + listed = client.list( + workspace="default", limit=50, offset=10, label_selector="team=runtime" + ) + assert len(listed) == 1 + assert stub.list_template_request is not None + assert stub.list_template_request.workspace == "default" + assert stub.list_template_request.limit == 50 + assert stub.list_template_request.offset == 10 + assert stub.list_template_request.label_selector == "team=runtime" + assert not stub.list_template_request.all_workspaces + + assert client.delete("gpu-kata", workspace="default") is True + assert stub.delete_template_request is not None + assert stub.delete_template_request.name == "gpu-kata" + assert stub.delete_template_request.workspace == "default" + + +def test_sandbox_template_list_for_all_workspaces_clears_workspace() -> None: + stub = _FakeSandboxStub() + client = _template_client_with_fake_stub(stub) + + client.list_for_all_workspaces(limit=100, offset=5, label_selector="team=runtime") + + assert stub.list_template_request is not None + assert stub.list_template_request.all_workspaces + assert stub.list_template_request.workspace == "" + assert stub.list_template_request.limit == 100 + assert stub.list_template_request.offset == 5 + assert stub.list_template_request.label_selector == "team=runtime" + + def test_stop_and_start_forward_workspace_and_return_phase() -> None: stub = _FakeSandboxStub() client = _client_with_fake_stub(stub) @@ -2365,6 +2708,44 @@ def test_high_level_creation_forwards_name_and_labels( } +def test_high_level_template_creation_forwards_template_name( + monkeypatch: pytest.MonkeyPatch, +) -> None: + recording = _RecordingHighLevelClient() + monkeypatch.setattr( + SandboxClient, + "from_active_cluster", + classmethod(lambda _cls, **_kwargs: recording), + ) + + spec = openshell_pb2.SandboxSpec( + providers=["github"], + command=["/opt/worker", "--serve"], + tty=True, + ) + sandbox = Sandbox( + workspace="staging", + template_name="gpu-kata", + spec=spec, + name="job-1", + labels={"team": "runtime"}, + delete_on_exit=False, + ) + sandbox.__enter__() + + assert recording.create_template_kwargs == { + "workspace": "staging", + "template_name": "gpu-kata", + "spec": spec, + "name": "job-1", + "labels": {"team": "runtime"}, + } + assert recording.create_template_kwargs is not None + forwarded_spec = recording.create_template_kwargs["spec"] + assert list(forwarded_spec.command) == ["/opt/worker", "--serve"] + assert forwarded_spec.tty is True + + def test_high_level_attach_rejects_name() -> None: sandbox = Sandbox(workspace="default", sandbox="existing-sandbox", name="job-1") @@ -2385,6 +2766,15 @@ def test_high_level_attach_rejects_labels() -> None: sandbox.__enter__() +def test_high_level_attach_rejects_template_name() -> None: + sandbox = Sandbox( + workspace="default", sandbox="existing-sandbox", template_name="gpu-kata" + ) + + with pytest.raises(SandboxError): + sandbox.__enter__() + + # --------------------------------------------------------------------------- # Workspace support # --------------------------------------------------------------------------- @@ -2453,6 +2843,18 @@ def test_sandbox_ref_includes_workspace_from_proto() -> None: assert ref.workspace == "production" +def test_sandbox_ref_includes_workload_template_provenance() -> None: + proto = _make_sandbox_proto("sandbox-1", "job-1") + proto.created_from_workload_template.name = "gpu-kata" + proto.created_from_workload_template.resource_version = "7" + + ref = _sandbox_ref(proto) + + assert ref.created_from_workload_template is not None + assert ref.created_from_workload_template.name == "gpu-kata" + assert ref.created_from_workload_template.resource_version == "7" + + def test_sandbox_session_delete_passes_workspace() -> None: from openshell.sandbox import SandboxSession diff --git a/sdk/go/docs/src/SUMMARY.md b/sdk/go/docs/src/SUMMARY.md index 7a0915bfeb..bf500f68eb 100644 --- a/sdk/go/docs/src/SUMMARY.md +++ b/sdk/go/docs/src/SUMMARY.md @@ -15,6 +15,7 @@ - [Overview](api/overview.md) - [Client](api/client.md) - [Sandboxes](api/sandboxes.md) +- [Sandbox Templates](api/sandbox-templates.md) - [Exec](api/exec.md) - [Providers](api/providers.md) - [Profiles](api/profiles.md) diff --git a/sdk/go/docs/src/api/client.md b/sdk/go/docs/src/api/client.md index 748de5327e..9fe8b4aed9 100644 --- a/sdk/go/docs/src/api/client.md +++ b/sdk/go/docs/src/api/client.md @@ -2,14 +2,17 @@ Constructor: `v1.NewClient(config)` -The `ClientInterface` is the root entry point for all SDK operations. It provides -typed accessors for each resource domain and manages the underlying gRPC connection. +`Client` is the root entry point for SDK operations. It provides typed accessors +for each resource domain and manages the underlying gRPC connection. The +`ClientInterface` covers the stable core accessors; additive helpers such as +`SandboxTemplates()` are available on the concrete client. ## Methods | Accessor | Returns | Description | |----------|---------|-------------| | `Sandboxes()` | `SandboxInterface` | Sandbox lifecycle management | +| `SandboxTemplates()` | `SandboxTemplateInterface` | Reusable sandbox template management | | `Providers()` | `ProviderInterface` | Provider CRUD and idempotent ensure | | `Services()` | `ServiceInterface` | Service exposure and management | | `Exec()` | `ExecInterface` | Command execution (run, stream, interactive) | diff --git a/sdk/go/docs/src/api/fake.md b/sdk/go/docs/src/api/fake.md index 084858c542..ae96fc9d39 100644 --- a/sdk/go/docs/src/api/fake.md +++ b/sdk/go/docs/src/api/fake.md @@ -61,6 +61,13 @@ client.AddSandbox("default", &types.Sandbox{ Status: types.SandboxStatus{Phase: types.SandboxReady}, }) +client.AddSandboxTemplate("default", &types.SandboxWorkloadTemplate{ + Name: "gpu-kata", + Spec: types.SandboxWorkloadTemplateSpec{ + Workload: &types.SandboxWorkloadConfig{Image: "python:3.12"}, + }, +}) + client.AddProvider("default", &types.Provider{ Name: "my-provider", Spec: types.ProviderSpec{Type: "docker"}, @@ -78,11 +85,13 @@ insertion does not affect the stored object. ## Sub-Client Coverage -The fake client implements every interface in `v1.ClientInterface`: +The fake client implements every stable accessor in `v1.ClientInterface` plus +additive concrete-client accessors such as `SandboxTemplates()`: | Accessor | Interface | Behavior | |----------|-----------|----------| | `Sandboxes()` | `SandboxInterface` | Full CRUD, Watch, WaitReady | +| `SandboxTemplates()` | `SandboxTemplateInterface` | Full CRUD | | `Providers()` | `ProviderInterface` | Full CRUD, Ensure | | `Workspaces()` | `WorkspaceInterface` | Full CRUD, Members | | `Health()` | `HealthInterface` | Configurable result | diff --git a/sdk/go/docs/src/api/overview.md b/sdk/go/docs/src/api/overview.md index d96bbd747a..0ff01c4ce4 100644 --- a/sdk/go/docs/src/api/overview.md +++ b/sdk/go/docs/src/api/overview.md @@ -1,6 +1,7 @@ # API Overview -The OpenShell Go SDK exposes 12 interfaces through the sub-client pattern. You access each interface through a typed accessor on the `Client`. +The OpenShell Go SDK exposes typed interfaces through the sub-client pattern. +You access each interface through a typed accessor on the concrete `Client`. ## Interface Summary @@ -9,6 +10,7 @@ The OpenShell Go SDK exposes 12 interfaces through the sub-client pattern. You a | Interface | Accessor | Description | |-----------|----------|-------------| | [SandboxInterface](sandboxes.md) | `client.Sandboxes()` | Create, manage, and watch sandbox lifecycle | +| [SandboxTemplateInterface](sandbox-templates.md) | `client.SandboxTemplates()` | Manage reusable sandbox templates | | [ExecInterface](exec.md) | `client.Exec()` | Run commands, stream output, interactive sessions | | [ProviderInterface](providers.md) | `client.Providers()` | Manage compute providers and their lifecycle | | [ServiceInterface](services.md) | `client.Services()` | Expose and manage HTTP services inside sandboxes | @@ -39,6 +41,7 @@ These are accessed through `client.Providers()`: Each interface has a reference page with method signatures and usage examples: - **[Sandboxes](sandboxes.md)**: Create sandboxes, wait for readiness, watch state changes, manage providers, retrieve logs. +- **[Sandbox Templates](sandbox-templates.md)**: Create reusable workload templates and launch sandboxes from them. - **[Exec](exec.md)**: Execute commands with one-shot, streaming, or interactive modes. - **[Providers](providers.md)**: Register and manage compute providers. Includes sub-clients for profiles and credential refresh. - **[Services](services.md)**: Expose and manage HTTP services inside sandboxes. diff --git a/sdk/go/docs/src/api/sandbox-templates.md b/sdk/go/docs/src/api/sandbox-templates.md new file mode 100644 index 0000000000..d1dcc86e47 --- /dev/null +++ b/sdk/go/docs/src/api/sandbox-templates.md @@ -0,0 +1,127 @@ +# Sandbox Templates + +Accessor: `client.SandboxTemplates()` + +Manage reusable, workspace-scoped sandbox workload templates. Templates own the +portable workload shape and optional driver-specific config. Sandbox creation +can reference a template by name while supplying only governance fields such as +providers or policy. + +The Go SDK names the reusable resource `SandboxWorkloadTemplate` because +`SandboxTemplate` already represents the legacy inline compute template inside +`SandboxSpec`. + +## Create + +Creates a reusable template in a workspace. + +```go +gpuCount := uint32(1) + +template, err := client.SandboxTemplates().Create(ctx, "default", &v1.SandboxWorkloadTemplate{ + Name: "gpu-kata", + Labels: map[string]string{ + "team": "platform", + }, + Spec: v1.SandboxWorkloadTemplateSpec{ + Workload: &v1.SandboxWorkloadConfig{ + Image: "nvcr.io/nvidia/openshell:latest", + Environment: map[string]string{ + "NVIDIA_VISIBLE_DEVICES": "all", + }, + Resources: &v1.SandboxResources{ + CPU: "2", + Memory: "8Gi", + GPU: &v1.SandboxGPURequirements{Count: &gpuCount}, + }, + }, + DriverConfig: map[string]any{ + "kubernetes": map[string]any{ + "pod": map[string]any{ + "runtime_class_name": "kata", + }, + }, + }, + DesiredServiceLevel: &v1.SandboxServiceLevel{ + Startup: &v1.SandboxStartup{ + ReadyWithin: 45 * time.Second, + MaxBurst: 2, + }, + }, + }, +}) +``` + +Set `GPU: &v1.SandboxGPURequirements{}` to request the active driver's default +GPU assignment without specifying a count. + +## Create a Sandbox From a Template + +Use `CreateSandboxFromTemplate` to create a sandbox from a named reusable +template without changing the legacy `Sandboxes()` interface. + +```go +sb, err := client.CreateSandboxFromTemplate( + ctx, + "default", + "training-run", + "gpu-kata", + &v1.SandboxSpec{ + Providers: []string{"openai"}, + Policy: policy, + }, + map[string]string{"job": "training"}, +) +``` + +When creating from a template, the spec should only include governance fields: +`Providers`, `Policy`, `Command`, and `TTY`. Workload fields such as image, +environment, CPU, memory, GPU, and driver config come from the template. + +## Get + +Retrieves a template by name. + +```go +template, err := client.SandboxTemplates().Get(ctx, "default", "gpu-kata") +fmt.Println(template.Spec.Workload.Image) +``` + +## List + +Lists templates in one workspace or across all workspaces. + +```go +templates, err := client.SandboxTemplates().List(ctx, "default", v1.ListOptions{ + Limit: 50, + Offset: 0, +}) + +allTemplates, err := client.SandboxTemplates().List(ctx, "", v1.ListOptions{ + AllWorkspaces: true, +}) +``` + +## Delete + +Deletes a template by name. Existing sandboxes created from the template are not +deleted. + +```go +deleted, err := client.SandboxTemplates().Delete(ctx, "default", "gpu-kata") +``` + +## Fake Client + +The fake client includes the same template sub-client and can be pre-populated +for tests. + +```go +client := fake.NewClient() +client.AddSandboxTemplate("default", &types.SandboxWorkloadTemplate{ + Name: "gpu-kata", + Spec: types.SandboxWorkloadTemplateSpec{ + Workload: &types.SandboxWorkloadConfig{Image: "python:3.12"}, + }, +}) +``` diff --git a/sdk/go/docs/src/api/sandboxes.md b/sdk/go/docs/src/api/sandboxes.md index a9db856786..698982fe13 100644 --- a/sdk/go/docs/src/api/sandboxes.md +++ b/sdk/go/docs/src/api/sandboxes.md @@ -20,6 +20,32 @@ sb, err := client.Sandboxes().Create(ctx, "default", "my-sandbox", &v1.SandboxSp }) ``` +Set `GPU: true` to request the active driver's default GPU assignment. Set +`GPUCount` when the sandbox needs a specific GPU count; a non-nil `GPUCount` +also implies `GPU`. + +## Create From Template + +Creates a new sandbox from a reusable sandbox workload template. The template +provides workload fields such as image, environment, resources, and driver +config. The create request supplies governance fields such as providers, +policy, command, and TTY. + +```go +sb, err := client.CreateSandboxFromTemplate(ctx, + "default", + "my-sandbox", + "gpu-kata", + &v1.SandboxSpec{ + Providers: []string{"openai"}, + Policy: policy, + }, + map[string]string{"team": "platform"}, +) +``` + +See [Sandbox Templates](sandbox-templates.md) for template CRUD. + ## Get Retrieves a sandbox by name. diff --git a/sdk/go/docs/src/introduction.md b/sdk/go/docs/src/introduction.md index 61f3a05ca2..fbe63dbb7f 100644 --- a/sdk/go/docs/src/introduction.md +++ b/sdk/go/docs/src/introduction.md @@ -4,7 +4,7 @@ The OpenShell Go SDK provides an idiomatic Go client for the OpenShell gateway A ## Key Features -- **Sub-client pattern**: A single `Client` provides typed accessors for each API domain (Sandboxes, Exec, Providers, Files, Health, SSH, TCP, Config, Policy, Services) +- **Sub-client pattern**: A single `Client` provides typed accessors for each API domain (Sandboxes, SandboxTemplates, Exec, Providers, Files, Health, SSH, TCP, Config, Policy, Services) - **Clean types**: SDK types are self-contained, so your code works with idiomatic Go types without extra dependencies - **Fake client for testing**: An in-memory implementation of the full `ClientInterface` for testing without a live gateway - **Watch and streaming**: First-class support for watching sandbox state changes and streaming command output diff --git a/sdk/go/openshell/v1/client.go b/sdk/go/openshell/v1/client.go index bc3ee43165..06d8183360 100644 --- a/sdk/go/openshell/v1/client.go +++ b/sdk/go/openshell/v1/client.go @@ -4,6 +4,7 @@ package v1 import ( + "context" "sync" "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" @@ -18,6 +19,8 @@ type Config = types.Config // ClientInterface defines the top-level SDK surface. type ClientInterface interface { Sandboxes() SandboxInterface + SandboxTemplates() SandboxTemplateInterface + CreateSandboxFromTemplate(ctx context.Context, workspace, name, templateName string, spec *SandboxSpec, labels map[string]string, opts ...CreateOptions) (*Sandbox, error) Providers() ProviderInterface Services() ServiceInterface Exec() ExecInterface @@ -49,18 +52,20 @@ type Client struct { closeOnce sync.Once closeErr error - sandboxes SandboxInterface - providers ProviderInterface - services ServiceInterface - exec ExecInterface - files FileInterface - health HealthInterface - ssh SSHInterface - tcp TCPInterface - cfg ConfigInterface - policy PolicyInterface - workspaces WorkspaceInterface - inference InferenceInterface + sandboxes SandboxInterface + templateCreate SandboxTemplateCreateInterface + templates SandboxTemplateInterface + providers ProviderInterface + services ServiceInterface + exec ExecInterface + files FileInterface + health HealthInterface + ssh SSHInterface + tcp TCPInterface + cfg ConfigInterface + policy PolicyInterface + workspaces WorkspaceInterface + inference InferenceInterface } // NewClient creates a new SDK client connected to the given gateway. @@ -93,7 +98,10 @@ func NewClient(cfg Config) (*Client, error) { config: cfg, } - c.sandboxes = newSandboxClient(conn) + sandboxes := newSandboxClient(conn) + c.sandboxes = sandboxes + c.templateCreate = sandboxes + c.templates = newSandboxTemplateClient(conn) c.providers = newProviderClient(conn) c.services = newServiceClient(conn) c.exec = newExecClient(conn, c.sandboxes) @@ -112,6 +120,15 @@ func NewClient(cfg Config) (*Client, error) { // Sandboxes returns the sandbox sub-client. func (c *Client) Sandboxes() SandboxInterface { return c.sandboxes } +// SandboxTemplates returns the reusable sandbox template sub-client. +func (c *Client) SandboxTemplates() SandboxTemplateInterface { return c.templates } + +// CreateSandboxFromTemplate creates a sandbox from a named workload template +// without changing the legacy Sandboxes() interface. +func (c *Client) CreateSandboxFromTemplate(ctx context.Context, workspace, name, templateName string, spec *SandboxSpec, labels map[string]string, opts ...CreateOptions) (*Sandbox, error) { + return c.templateCreate.CreateFromTemplate(ctx, workspace, name, templateName, spec, labels, opts...) +} + // Providers returns the provider sub-client. func (c *Client) Providers() ProviderInterface { return c.providers } diff --git a/sdk/go/openshell/v1/fake/fake.go b/sdk/go/openshell/v1/fake/fake.go index d2978bc08c..16a35fed8a 100644 --- a/sdk/go/openshell/v1/fake/fake.go +++ b/sdk/go/openshell/v1/fake/fake.go @@ -4,6 +4,7 @@ package fake import ( + "context" "sync" v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" @@ -15,23 +16,26 @@ import ( // real gRPC connection. Create one with NewClient. type Client struct { sandboxStore *objectStore[*types.Sandbox] + templateStore *objectStore[*types.SandboxWorkloadTemplate] providerStore *objectStore[*types.Provider] workspaceStore *objectStore[*types.Workspace] memberStore *objectStore[*types.WorkspaceMember] sandboxBroadcaster *watchBroadcaster[*types.Sandbox] - sandboxes v1.SandboxInterface - providers v1.ProviderInterface - services v1.ServiceInterface - exec v1.ExecInterface - files v1.FileInterface - health v1.HealthInterface - ssh v1.SSHInterface - tcp v1.TCPInterface - cfg v1.ConfigInterface - policy v1.PolicyInterface - workspaces v1.WorkspaceInterface - inference v1.InferenceInterface + sandboxes v1.SandboxInterface + templateCreate v1.SandboxTemplateCreateInterface + templates v1.SandboxTemplateInterface + providers v1.ProviderInterface + services v1.ServiceInterface + exec v1.ExecInterface + files v1.FileInterface + health v1.HealthInterface + ssh v1.SSHInterface + tcp v1.TCPInterface + cfg v1.ConfigInterface + policy v1.PolicyInterface + workspaces v1.WorkspaceInterface + inference v1.InferenceInterface closeOnce sync.Once closed bool @@ -70,13 +74,17 @@ func WithCurrentUser(user *types.CurrentUser) ClientOption { func NewClient(opts ...ClientOption) *Client { fc := &Client{ sandboxStore: newobjectStore(sandboxName, copySandbox), + templateStore: newobjectStore(sandboxWorkloadTemplateName, copySandboxWorkloadTemplate), providerStore: newobjectStore(providerName, copyProvider), workspaceStore: newobjectStore(workspaceName, copyWorkspace), memberStore: newobjectStore(memberName, copyMember), sandboxBroadcaster: newWatchBroadcaster[*types.Sandbox](), } - fc.sandboxes = newFakeSandboxClient(fc.sandboxStore, fc.sandboxBroadcaster, fc.isClosed) + sandboxes := newFakeSandboxClient(fc.sandboxStore, fc.templateStore, fc.sandboxBroadcaster, fc.isClosed) + fc.sandboxes = sandboxes + fc.templateCreate = sandboxes + fc.templates = newFakeSandboxTemplateClient(fc.templateStore, fc.isClosed) fc.providers = newFakeProviderClient(fc.providerStore, fc.isClosed) fc.services = newFakeServiceClient(fc.isClosed) fc.exec = newFakeExecClient(fc.isClosed) @@ -107,6 +115,15 @@ func (fc *Client) isClosed() bool { // Sandboxes returns the sandbox sub-client. func (fc *Client) Sandboxes() v1.SandboxInterface { return fc.sandboxes } +// SandboxTemplates returns the reusable sandbox template sub-client. +func (fc *Client) SandboxTemplates() v1.SandboxTemplateInterface { return fc.templates } + +// CreateSandboxFromTemplate creates a sandbox from a named workload template +// without changing the legacy Sandboxes() interface. +func (fc *Client) CreateSandboxFromTemplate(ctx context.Context, workspace, name, templateName string, spec *types.SandboxSpec, labels map[string]string, opts ...types.CreateOptions) (*types.Sandbox, error) { + return fc.templateCreate.CreateFromTemplate(ctx, workspace, name, templateName, spec, labels, opts...) +} + // Providers returns the provider sub-client. func (fc *Client) Providers() v1.ProviderInterface { return fc.providers } @@ -164,6 +181,16 @@ func (fc *Client) AddSandbox(workspace string, sb *types.Sandbox) { fc.sandboxStore.Insert(workspace, sb) } +// AddSandboxTemplate inserts a sandbox workload template directly into the store. +// This is intended for pre-seeding test fixtures before the test begins. The +// template is deep-copied on insert. +func (fc *Client) AddSandboxTemplate(workspace string, template *types.SandboxWorkloadTemplate) { + if template == nil { + return + } + fc.templateStore.Insert(workspace, template) +} + // AddProvider inserts a provider directly into the store without triggering // any side effects. This is intended for pre-seeding test fixtures before // the test begins. The provider is deep-copied on insert. diff --git a/sdk/go/openshell/v1/fake/sandbox.go b/sdk/go/openshell/v1/fake/sandbox.go index 6fa0c316a9..5de3062d96 100644 --- a/sdk/go/openshell/v1/fake/sandbox.go +++ b/sdk/go/openshell/v1/fake/sandbox.go @@ -32,6 +32,10 @@ func copySandbox(sb *types.Sandbox) *types.Sandbox { t := *sb.DeletionTimestamp cp.DeletionTimestamp = &t } + if sb.CreatedFromWorkloadTemplate != nil { + provenance := *sb.CreatedFromWorkloadTemplate + cp.CreatedFromWorkloadTemplate = &provenance + } cp.Spec = copySandboxSpec(sb.Spec) cp.Status = copySandboxStatus(sb.Status) return &cp @@ -40,6 +44,7 @@ func copySandbox(sb *types.Sandbox) *types.Sandbox { func copySandboxSpec(s types.SandboxSpec) types.SandboxSpec { s.Environment = copyStringMap(s.Environment) s.Providers = copyStringSlice(s.Providers) + s.Command = copyStringSlice(s.Command) if s.Template != nil { t := copySandboxTemplate(*s.Template) s.Template = &t @@ -255,21 +260,27 @@ func copyStringSlice(s []string) []string { // fakeSandboxClient implements v1.SandboxInterface backed by an in-memory // objectStore and watchBroadcaster. type fakeSandboxClient struct { - store *objectStore[*types.Sandbox] - broadcaster *watchBroadcaster[*types.Sandbox] - closedFunc func() bool + store *objectStore[*types.Sandbox] + templateStore *objectStore[*types.SandboxWorkloadTemplate] + broadcaster *watchBroadcaster[*types.Sandbox] + closedFunc func() bool } +var _ v1.SandboxInterface = (*fakeSandboxClient)(nil) +var _ v1.SandboxTemplateCreateInterface = (*fakeSandboxClient)(nil) + // newFakeSandboxClient creates a new fakeSandboxClient. func newFakeSandboxClient( store *objectStore[*types.Sandbox], + templateStore *objectStore[*types.SandboxWorkloadTemplate], broadcaster *watchBroadcaster[*types.Sandbox], closedFunc func() bool, ) *fakeSandboxClient { return &fakeSandboxClient{ - store: store, - broadcaster: broadcaster, - closedFunc: closedFunc, + store: store, + templateStore: templateStore, + broadcaster: broadcaster, + closedFunc: closedFunc, } } @@ -315,6 +326,117 @@ func (c *fakeSandboxClient) Create(_ context.Context, workspace, name string, sp return result, nil } +// CreateFromTemplate creates a new sandbox from a named template with Provisioning phase. +func (c *fakeSandboxClient) CreateFromTemplate(_ context.Context, workspace, name, templateName string, spec *types.SandboxSpec, labels map[string]string, opts ...types.CreateOptions) (*types.Sandbox, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if templateName == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "template name is required"} + } + if err := validateTemplateCreateSpec(spec); err != nil { + return nil, err + } + template, err := c.templateStore.Get(workspace, templateName) + if err != nil { + return nil, err + } + if spec == nil { + spec = &types.SandboxSpec{} + } + + var annotations map[string]string + if len(opts) > 0 { + annotations = copyStringMap(opts[0].Annotations) + } + + resolvedSpec := sandboxSpecFromWorkloadTemplate(template) + resolvedSpec.Providers = copyStringSlice(spec.Providers) + resolvedSpec.Policy = copySandboxPolicy(spec.Policy) + resolvedSpec.Command = copyStringSlice(spec.Command) + resolvedSpec.TTY = spec.TTY + + sb := &types.Sandbox{ + Name: name, + Workspace: workspace, + CreatedAt: time.Now(), + Labels: copyStringMap(labels), + Annotations: annotations, + ResourceVersion: 1, + Spec: resolvedSpec, + CreatedFromWorkloadTemplate: &types.SandboxWorkloadTemplateProvenance{ + Name: template.Name, + ResourceVersion: fmt.Sprint(template.ResourceVersion), + }, + Status: types.SandboxStatus{ + SandboxName: name, + Phase: types.SandboxProvisioning, + }, + } + + result, err := c.store.Create(workspace, sb) + if err != nil { + return nil, err + } + + c.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventAdded, + Object: copySandbox(result), + }, name) + + return result, nil +} + +func validateTemplateCreateSpec(spec *types.SandboxSpec) error { + if spec == nil { + return nil + } + if spec.LogLevel != "" || len(spec.Environment) > 0 || spec.Template != nil || spec.GPU || spec.GPUCount != nil { + return &types.StatusError{Code: types.ErrorInvalidArgument, Message: "template creates only allow policy, providers, command, and tty in spec"} + } + return nil +} + +func sandboxSpecFromWorkloadTemplate(template *types.SandboxWorkloadTemplate) types.SandboxSpec { + var spec types.SandboxSpec + if template == nil || template.Spec.Workload == nil { + return spec + } + + workload := template.Spec.Workload + spec.Environment = copyStringMap(workload.Environment) + spec.Template = &types.SandboxTemplate{ + Image: workload.Image, + Resources: sandboxTemplateResources(workload.Resources), + DriverConfig: copyAnyMap(template.Spec.DriverConfig), + } + if workload.Resources != nil && workload.Resources.GPU != nil { + spec.GPU = true + if workload.Resources.GPU.Count != nil { + count := *workload.Resources.GPU.Count + spec.GPUCount = &count + } + } + return spec +} + +func sandboxTemplateResources(resources *types.SandboxResources) map[string]any { + if resources == nil { + return nil + } + limits := make(map[string]any) + if resources.CPU != "" { + limits["cpu"] = resources.CPU + } + if resources.Memory != "" { + limits["memory"] = resources.Memory + } + if len(limits) == 0 { + return nil + } + return map[string]any{"limits": limits} +} + // Get retrieves a sandbox by name. func (c *fakeSandboxClient) Get(_ context.Context, workspace, name string) (*types.Sandbox, error) { if c.closedFunc() { diff --git a/sdk/go/openshell/v1/fake/sandbox_template.go b/sdk/go/openshell/v1/fake/sandbox_template.go new file mode 100644 index 0000000000..8dbf68cc12 --- /dev/null +++ b/sdk/go/openshell/v1/fake/sandbox_template.go @@ -0,0 +1,301 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "slices" + "strings" + "time" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +func sandboxWorkloadTemplateName(template *types.SandboxWorkloadTemplate) string { + return template.Name +} + +func copySandboxWorkloadTemplate(template *types.SandboxWorkloadTemplate) *types.SandboxWorkloadTemplate { + if template == nil { + return nil + } + copied := *template + copied.Labels = copyStringMap(template.Labels) + copied.Annotations = copyStringMap(template.Annotations) + if template.DeletionTimestamp != nil { + t := *template.DeletionTimestamp + copied.DeletionTimestamp = &t + } + copied.Spec = copySandboxWorkloadTemplateSpec(template.Spec) + return &copied +} + +func copySandboxWorkloadTemplateSpec(spec types.SandboxWorkloadTemplateSpec) types.SandboxWorkloadTemplateSpec { + if spec.Workload != nil { + workload := *spec.Workload + workload.Environment = copyStringMap(spec.Workload.Environment) + if spec.Workload.Resources != nil { + resources := *spec.Workload.Resources + if spec.Workload.Resources.GPU != nil { + gpu := *spec.Workload.Resources.GPU + if spec.Workload.Resources.GPU.Count != nil { + count := *spec.Workload.Resources.GPU.Count + gpu.Count = &count + } + resources.GPU = &gpu + } + workload.Resources = &resources + } + spec.Workload = &workload + } + spec.DriverConfig = copyAnyMap(spec.DriverConfig) + if spec.DesiredServiceLevel != nil { + level := *spec.DesiredServiceLevel + if spec.DesiredServiceLevel.Startup != nil { + startup := *spec.DesiredServiceLevel.Startup + level.Startup = &startup + } + spec.DesiredServiceLevel = &level + } + return spec +} + +type fakeSandboxTemplateClient struct { + store *objectStore[*types.SandboxWorkloadTemplate] + closedFunc func() bool +} + +var _ v1.SandboxTemplateInterface = (*fakeSandboxTemplateClient)(nil) + +func newFakeSandboxTemplateClient( + store *objectStore[*types.SandboxWorkloadTemplate], + closedFunc func() bool, +) *fakeSandboxTemplateClient { + return &fakeSandboxTemplateClient{ + store: store, + closedFunc: closedFunc, + } +} + +func (c *fakeSandboxTemplateClient) Create(_ context.Context, workspace string, template *types.SandboxWorkloadTemplate) (*types.SandboxWorkloadTemplate, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if template == nil { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "template must not be nil"} + } + if err := validateSandboxWorkloadTemplate(template); err != nil { + return nil, err + } + + t := copySandboxWorkloadTemplate(template) + t.Workspace = workspace + t.CreatedAt = time.Now() + t.ResourceVersion = 1 + + return c.store.Create(workspace, t) +} + +func (c *fakeSandboxTemplateClient) Get(_ context.Context, workspace, name string) (*types.SandboxWorkloadTemplate, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return c.store.Get(workspace, name) +} + +func (c *fakeSandboxTemplateClient) List(_ context.Context, workspace string, opts ...v1.ListOptions) ([]*types.SandboxWorkloadTemplate, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + var options v1.ListOptions + if len(opts) > 0 { + options = opts[0] + if options.Limit < 0 { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "limit must not be negative"} + } + if options.Offset < 0 { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "offset must not be negative"} + } + } + var templates []*types.SandboxWorkloadTemplate + if options.AllWorkspaces { + templates = c.store.ListAll() + } else { + templates = c.store.List(workspace) + } + slices.SortFunc(templates, compareSandboxWorkloadTemplatesForList) + templates, err := filterSandboxWorkloadTemplatesByLabelSelector(templates, options.LabelSelector) + if err != nil { + return nil, err + } + return paginateSandboxWorkloadTemplates(templates, options), nil +} + +func (c *fakeSandboxTemplateClient) Delete(_ context.Context, workspace, name string) (bool, error) { + if c.closedFunc() { + return false, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + _, existed := c.store.DeleteAndGet(workspace, name) + return existed, nil +} + +func validateSandboxWorkloadTemplate(template *types.SandboxWorkloadTemplate) error { + if template.Name == "" { + return &types.StatusError{Code: types.ErrorInvalidArgument, Message: "sandbox_template.metadata.name is required"} + } + if !isDNS1123Label(template.Name) { + return &types.StatusError{Code: types.ErrorInvalidArgument, Message: "template.metadata.name must be a DNS-1123 label"} + } + if template.Spec.Workload == nil { + return &types.StatusError{Code: types.ErrorInvalidArgument, Message: "sandbox template workload is required"} + } + if err := validateSandboxWorkloadEnvironment(template.Spec.Workload.Environment); err != nil { + return err + } + if resources := template.Spec.Workload.Resources; resources != nil && resources.GPU != nil && resources.GPU.Count != nil && *resources.GPU.Count == 0 { + return &types.StatusError{Code: types.ErrorInvalidArgument, Message: "gpu count must be greater than 0"} + } + return nil +} + +func validateSandboxWorkloadEnvironment(environment map[string]string) error { + for key := range environment { + if !isValidEnvKey(key) { + return &types.StatusError{Code: types.ErrorInvalidArgument, Message: "spec.template.environment keys must match ^[A-Za-z_][A-Za-z0-9_]*$"} + } + if len(key) >= len("OPENSHELL_") && key[:len("OPENSHELL_")] == "OPENSHELL_" { + return &types.StatusError{Code: types.ErrorInvalidArgument, Message: "spec.template.environment keys starting with OPENSHELL_ are reserved"} + } + } + return nil +} + +func filterSandboxWorkloadTemplatesByLabelSelector( + templates []*types.SandboxWorkloadTemplate, + selector string, +) ([]*types.SandboxWorkloadTemplate, error) { + selector = strings.TrimSpace(selector) + if selector == "" { + return templates, nil + } + labels, err := parseSimpleLabelSelector(selector) + if err != nil { + return nil, err + } + filtered := make([]*types.SandboxWorkloadTemplate, 0, len(templates)) + for _, template := range templates { + if labelsMatchSelector(template.Labels, labels) { + filtered = append(filtered, template) + } + } + return filtered, nil +} + +func parseSimpleLabelSelector(selector string) (map[string]string, error) { + labels := make(map[string]string) + for _, part := range strings.Split(selector, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + key, value, ok := strings.Cut(part, "=") + if !ok || strings.TrimSpace(key) == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "label selector must use key=value pairs"} + } + labels[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + return labels, nil +} + +func labelsMatchSelector(labels map[string]string, selector map[string]string) bool { + for key, value := range selector { + if labels[key] != value { + return false + } + } + return true +} + +func compareSandboxWorkloadTemplatesForList(a, b *types.SandboxWorkloadTemplate) int { + if a.CreatedAt.Before(b.CreatedAt) { + return -1 + } + if a.CreatedAt.After(b.CreatedAt) { + return 1 + } + if a.Name < b.Name { + return -1 + } + if a.Name > b.Name { + return 1 + } + if a.Workspace < b.Workspace { + return -1 + } + if a.Workspace > b.Workspace { + return 1 + } + return 0 +} + +func paginateSandboxWorkloadTemplates( + templates []*types.SandboxWorkloadTemplate, + options v1.ListOptions, +) []*types.SandboxWorkloadTemplate { + if options.Offset >= len(templates) { + return templates[:0] + } + templates = templates[options.Offset:] + if options.Limit > 0 && options.Limit < len(templates) { + return templates[:options.Limit] + } + return templates +} + +func isDNS1123Label(name string) bool { + if len(name) == 0 || len(name) > 63 || name[0] == '-' || name[len(name)-1] == '-' { + return false + } + previousHyphen := false + for i := 0; i < len(name); i++ { + b := name[i] + if b == '-' { + if previousHyphen { + return false + } + previousHyphen = true + continue + } + previousHyphen = false + if !isASCIIDigit(b) && (b < 'a' || b > 'z') { + return false + } + } + return true +} + +func isValidEnvKey(key string) bool { + if key == "" { + return false + } + for i := 0; i < len(key); i++ { + b := key[i] + if i == 0 && b != '_' && !isASCIIAlpha(b) { + return false + } + if i > 0 && b != '_' && !isASCIIAlpha(b) && !isASCIIDigit(b) { + return false + } + } + return true +} + +func isASCIIAlpha(b byte) bool { + return (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') +} + +func isASCIIDigit(b byte) bool { + return b >= '0' && b <= '9' +} diff --git a/sdk/go/openshell/v1/fake/sandbox_template_test.go b/sdk/go/openshell/v1/fake/sandbox_template_test.go new file mode 100644 index 0000000000..06a5187227 --- /dev/null +++ b/sdk/go/openshell/v1/fake/sandbox_template_test.go @@ -0,0 +1,481 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + "time" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestSandboxTemplateClient() *fakeSandboxTemplateClient { + store := newobjectStore(sandboxWorkloadTemplateName, copySandboxWorkloadTemplate) + return newFakeSandboxTemplateClient(store, func() bool { return false }) +} + +func testSandboxWorkloadTemplate(name string) *types.SandboxWorkloadTemplate { + return &types.SandboxWorkloadTemplate{ + Name: name, + Spec: types.SandboxWorkloadTemplateSpec{ + Workload: &types.SandboxWorkloadConfig{Image: "registry.example.com/agent:latest"}, + }, + } +} + +func TestIsDNS1123Label(t *testing.T) { + tests := map[string]bool{ + "": false, + "gpu-kata": true, + "Invalid_Template_Name": false, + "-gpu": false, + "gpu-": false, + "gpu--kata": false, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": false, + } + + for name, want := range tests { + assert.Equal(t, want, isDNS1123Label(name), "name %q", name) + } +} + +func TestSandboxTemplate_CreateGetListDelete(t *testing.T) { + tc := newTestSandboxTemplateClient() + ctx := context.Background() + + template := &types.SandboxWorkloadTemplate{ + Name: "gpu-kata", + Labels: map[string]string{"team": "platform"}, + Spec: types.SandboxWorkloadTemplateSpec{ + Workload: &types.SandboxWorkloadConfig{Image: "python:3.12"}, + DriverConfig: map[string]any{"kubernetes": map[string]any{"runtime_class_name": "kata"}}, + DesiredServiceLevel: &types.SandboxServiceLevel{ + Startup: &types.SandboxStartup{ReadyWithin: 30 * time.Second, MaxBurst: 4}, + }, + }, + } + + created, err := tc.Create(ctx, "default", template) + require.NoError(t, err) + assert.Equal(t, "gpu-kata", created.Name) + assert.Equal(t, "default", created.Workspace) + assert.Equal(t, uint64(1), created.ResourceVersion) + assert.NotZero(t, created.CreatedAt) + + got, err := tc.Get(ctx, "default", "gpu-kata") + require.NoError(t, err) + assert.Equal(t, "python:3.12", got.Spec.Workload.Image) + assert.Equal(t, "kata", got.Spec.DriverConfig["kubernetes"].(map[string]any)["runtime_class_name"]) + + listed, err := tc.List(ctx, "default") + require.NoError(t, err) + require.Len(t, listed, 1) + assert.Equal(t, "gpu-kata", listed[0].Name) + + deleted, err := tc.Delete(ctx, "default", "gpu-kata") + require.NoError(t, err) + assert.True(t, deleted) + _, err = tc.Get(ctx, "default", "gpu-kata") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) + + deleted, err = tc.Delete(ctx, "default", "gpu-kata") + require.NoError(t, err) + assert.False(t, deleted) +} + +func TestSandboxTemplate_CreateAlreadyExists(t *testing.T) { + tc := newTestSandboxTemplateClient() + ctx := context.Background() + + template := testSandboxWorkloadTemplate("gpu-kata") + _, err := tc.Create(ctx, "default", template) + require.NoError(t, err) + + _, err = tc.Create(ctx, "default", template) + require.Error(t, err) + assert.True(t, types.IsAlreadyExists(err)) +} + +func TestSandboxTemplate_ListAllWorkspaces(t *testing.T) { + tc := newTestSandboxTemplateClient() + ctx := context.Background() + + _, _ = tc.Create(ctx, "default", testSandboxWorkloadTemplate("default-template")) + _, _ = tc.Create(ctx, "team-a", testSandboxWorkloadTemplate("team-template")) + + listed, err := tc.List(ctx, "default", types.ListOptions{AllWorkspaces: true}) + require.NoError(t, err) + assert.Len(t, listed, 2) +} + +func TestSandboxTemplate_ListFiltersByLabelSelector(t *testing.T) { + tc := newTestSandboxTemplateClient() + ctx := context.Background() + + _, _ = tc.Create(ctx, "default", &types.SandboxWorkloadTemplate{ + Name: "runtime-template", + Labels: map[string]string{"team": "runtime"}, + Spec: types.SandboxWorkloadTemplateSpec{ + Workload: &types.SandboxWorkloadConfig{Image: "python:3.12"}, + }, + }) + _, _ = tc.Create(ctx, "default", &types.SandboxWorkloadTemplate{ + Name: "batch-template", + Labels: map[string]string{"team": "batch"}, + Spec: types.SandboxWorkloadTemplateSpec{ + Workload: &types.SandboxWorkloadConfig{Image: "python:3.12"}, + }, + }) + + listed, err := tc.List(ctx, "default", types.ListOptions{LabelSelector: "team=runtime"}) + + require.NoError(t, err) + require.Len(t, listed, 1) + assert.Equal(t, "runtime-template", listed[0].Name) +} + +func TestSandboxTemplate_ListRejectsNegativePagination(t *testing.T) { + tc := newTestSandboxTemplateClient() + ctx := context.Background() + + _, err := tc.List(ctx, "default", types.ListOptions{Limit: -1}) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) + + _, err = tc.List(ctx, "default", types.ListOptions{Offset: -1}) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestSandboxTemplate_ListAppliesPaginationAfterFiltering(t *testing.T) { + tc := newTestSandboxTemplateClient() + ctx := context.Background() + + _, _ = tc.Create(ctx, "default", &types.SandboxWorkloadTemplate{ + Name: "runtime-a", + Labels: map[string]string{"team": "runtime"}, + Spec: types.SandboxWorkloadTemplateSpec{ + Workload: &types.SandboxWorkloadConfig{Image: "python:3.12"}, + }, + }) + _, _ = tc.Create(ctx, "default", &types.SandboxWorkloadTemplate{ + Name: "batch-a", + Labels: map[string]string{"team": "batch"}, + Spec: types.SandboxWorkloadTemplateSpec{ + Workload: &types.SandboxWorkloadConfig{Image: "python:3.12"}, + }, + }) + _, _ = tc.Create(ctx, "default", &types.SandboxWorkloadTemplate{ + Name: "runtime-b", + Labels: map[string]string{"team": "runtime"}, + Spec: types.SandboxWorkloadTemplateSpec{ + Workload: &types.SandboxWorkloadConfig{Image: "python:3.12"}, + }, + }) + + listed, err := tc.List(ctx, "default", types.ListOptions{ + LabelSelector: "team=runtime", + Offset: 1, + Limit: 1, + }) + + require.NoError(t, err) + require.Len(t, listed, 1) + assert.Equal(t, "runtime-b", listed[0].Name) + + listed, err = tc.List(ctx, "default", types.ListOptions{ + LabelSelector: "team=runtime", + Offset: 2, + }) + + require.NoError(t, err) + assert.Empty(t, listed) +} + +func TestSandboxTemplate_CreateSandboxFromTemplateRequiresExistingTemplate(t *testing.T) { + client := NewClient() + ctx := context.Background() + + _, err := client.CreateSandboxFromTemplate(ctx, "default", "job-1", "missing", nil, nil) + + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandboxTemplate_CreateSandboxFromTemplateResolvesWorkloadAndGovernance(t *testing.T) { + client := NewClient() + ctx := context.Background() + gpuCount := uint32(1) + policy := &types.SandboxPolicy{ + Version: 1, + NetworkPolicies: map[string]types.NetworkPolicyRule{ + "api": {Name: "api"}, + }, + } + client.AddSandboxTemplate("default", &types.SandboxWorkloadTemplate{ + Name: "gpu-kata", + ResourceVersion: 7, + Spec: types.SandboxWorkloadTemplateSpec{ + Workload: &types.SandboxWorkloadConfig{ + Image: "registry.example.com/agent:latest", + Environment: map[string]string{"FEATURE_FLAG": "on"}, + Resources: &types.SandboxResources{ + CPU: "2", + Memory: "4Gi", + GPU: &types.SandboxGPURequirements{Count: &gpuCount}, + }, + }, + DriverConfig: map[string]any{ + "kubernetes": map[string]any{"runtime_class_name": "kata-containers"}, + }, + }, + }) + + created, err := client.CreateSandboxFromTemplate( + ctx, + "default", + "job-1", + "gpu-kata", + &types.SandboxSpec{ + Providers: []string{"github"}, + Policy: policy, + }, + map[string]string{"team": "runtime"}, + ) + + require.NoError(t, err) + assert.Equal(t, "job-1", created.Name) + assert.Equal(t, map[string]string{"team": "runtime"}, created.Labels) + assert.Equal(t, map[string]string{"FEATURE_FLAG": "on"}, created.Spec.Environment) + require.NotNil(t, created.Spec.Template) + assert.Equal(t, "registry.example.com/agent:latest", created.Spec.Template.Image) + assert.Equal(t, map[string]any{"limits": map[string]any{"cpu": "2", "memory": "4Gi"}}, created.Spec.Template.Resources) + assert.Equal(t, "kata-containers", created.Spec.Template.DriverConfig["kubernetes"].(map[string]any)["runtime_class_name"]) + assert.True(t, created.Spec.GPU) + require.NotNil(t, created.Spec.GPUCount) + assert.Equal(t, uint32(1), *created.Spec.GPUCount) + assert.Equal(t, []string{"github"}, created.Spec.Providers) + require.NotNil(t, created.Spec.Policy) + assert.Equal(t, uint32(1), created.Spec.Policy.Version) + require.NotNil(t, created.CreatedFromWorkloadTemplate) + assert.Equal(t, "gpu-kata", created.CreatedFromWorkloadTemplate.Name) + assert.Equal(t, "7", created.CreatedFromWorkloadTemplate.ResourceVersion) +} + +func TestSandboxTemplate_CreateSandboxFromTemplateRejectsWorkloadOverrides(t *testing.T) { + client := NewClient() + ctx := context.Background() + gpuCount := uint32(1) + client.AddSandboxTemplate("default", &types.SandboxWorkloadTemplate{ + Name: "gpu-kata", + Spec: types.SandboxWorkloadTemplateSpec{ + Workload: &types.SandboxWorkloadConfig{Image: "registry.example.com/agent:latest"}, + }, + }) + + tests := map[string]*types.SandboxSpec{ + "log_level": { + LogLevel: "debug", + }, + "environment": { + Environment: map[string]string{"FEATURE_FLAG": "off"}, + }, + "template": { + Template: &types.SandboxTemplate{Image: "registry.example.com/override:latest"}, + }, + "gpu_count": { + GPUCount: &gpuCount, + }, + "gpu": { + GPU: true, + }, + } + + for name, spec := range tests { + t.Run(name, func(t *testing.T) { + _, err := client.CreateSandboxFromTemplate(ctx, "default", "job-"+name, "gpu-kata", spec, nil) + + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) + }) + } + + listed, err := client.Sandboxes().List(ctx, "default") + require.NoError(t, err) + assert.Empty(t, listed) +} + +func TestSandboxTemplate_DefaultGpuRequestRoundTripsTemplate(t *testing.T) { + tc := newTestSandboxTemplateClient() + ctx := context.Background() + + created, err := tc.Create(ctx, "default", &types.SandboxWorkloadTemplate{ + Name: "default-gpu", + Spec: types.SandboxWorkloadTemplateSpec{ + Workload: &types.SandboxWorkloadConfig{ + Resources: &types.SandboxResources{ + GPU: &types.SandboxGPURequirements{}, + }, + }, + }, + }) + + require.NoError(t, err) + require.NotNil(t, created.Spec.Workload.Resources.GPU) + assert.Nil(t, created.Spec.Workload.Resources.GPU.Count) + + got, err := tc.Get(ctx, "default", "default-gpu") + require.NoError(t, err) + require.NotNil(t, got.Spec.Workload.Resources.GPU) + assert.Nil(t, got.Spec.Workload.Resources.GPU.Count) +} + +func TestSandboxTemplate_CreateSandboxFromTemplatePreservesDefaultGPURequest(t *testing.T) { + client := NewClient() + ctx := context.Background() + + client.AddSandboxTemplate("default", &types.SandboxWorkloadTemplate{ + Name: "default-gpu", + ResourceVersion: 3, + Spec: types.SandboxWorkloadTemplateSpec{ + Workload: &types.SandboxWorkloadConfig{ + Image: "registry.example.com/agent:latest", + Resources: &types.SandboxResources{ + GPU: &types.SandboxGPURequirements{}, + }, + }, + }, + }) + + created, err := client.CreateSandboxFromTemplate( + ctx, + "default", + "job-default-gpu", + "default-gpu", + nil, + nil, + ) + + require.NoError(t, err) + assert.True(t, created.Spec.GPU) + assert.Nil(t, created.Spec.GPUCount) +} + +func TestSandboxTemplate_DeepCopy(t *testing.T) { + tc := newTestSandboxTemplateClient() + ctx := context.Background() + + template := &types.SandboxWorkloadTemplate{ + Name: "gpu-kata", + Spec: types.SandboxWorkloadTemplateSpec{ + Workload: &types.SandboxWorkloadConfig{ + Image: "python:3.12", + Environment: map[string]string{"KEY": "value"}, + }, + DriverConfig: map[string]any{"kubernetes": map[string]any{"runtime_class_name": "kata"}}, + }, + } + + created, err := tc.Create(ctx, "default", template) + require.NoError(t, err) + + template.Spec.Workload.Image = "mutated" + template.Spec.Workload.Environment["KEY"] = "mutated" + template.Spec.DriverConfig["kubernetes"].(map[string]any)["runtime_class_name"] = "mutated" + created.Spec.Workload.Image = "mutated-return" + + got, err := tc.Get(ctx, "default", "gpu-kata") + require.NoError(t, err) + assert.Equal(t, "python:3.12", got.Spec.Workload.Image) + assert.Equal(t, "value", got.Spec.Workload.Environment["KEY"]) + assert.Equal(t, "kata", got.Spec.DriverConfig["kubernetes"].(map[string]any)["runtime_class_name"]) +} + +func TestSandboxTemplate_CreateNil(t *testing.T) { + tc := newTestSandboxTemplateClient() + ctx := context.Background() + + _, err := tc.Create(ctx, "default", nil) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestSandboxTemplate_CreateRejectsInvalidTemplate(t *testing.T) { + tc := newTestSandboxTemplateClient() + ctx := context.Background() + zeroGPUCount := uint32(0) + + tests := map[string]*types.SandboxWorkloadTemplate{ + "missing_name": { + Spec: types.SandboxWorkloadTemplateSpec{ + Workload: &types.SandboxWorkloadConfig{Image: "registry.example.com/agent:latest"}, + }, + }, + "invalid_name": { + Name: "Invalid_Template_Name", + Spec: types.SandboxWorkloadTemplateSpec{ + Workload: &types.SandboxWorkloadConfig{Image: "registry.example.com/agent:latest"}, + }, + }, + "missing_workload": { + Name: "missing-workload", + }, + "invalid_environment": { + Name: "invalid-env", + Spec: types.SandboxWorkloadTemplateSpec{ + Workload: &types.SandboxWorkloadConfig{ + Image: "registry.example.com/agent:latest", + Environment: map[string]string{"BAD KEY": "value"}, + }, + }, + }, + "reserved_environment": { + Name: "reserved-env", + Spec: types.SandboxWorkloadTemplateSpec{ + Workload: &types.SandboxWorkloadConfig{ + Image: "registry.example.com/agent:latest", + Environment: map[string]string{"OPENSHELL_TOKEN": "value"}, + }, + }, + }, + "zero_gpu": { + Name: "zero-gpu", + Spec: types.SandboxWorkloadTemplateSpec{ + Workload: &types.SandboxWorkloadConfig{ + Image: "registry.example.com/agent:latest", + Resources: &types.SandboxResources{ + GPU: &types.SandboxGPURequirements{Count: &zeroGPUCount}, + }, + }, + }, + }, + } + + for name, template := range tests { + t.Run(name, func(t *testing.T) { + _, err := tc.Create(ctx, "default", template) + + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) + }) + } + + listed, err := tc.List(ctx, "default") + require.NoError(t, err) + assert.Empty(t, listed) +} + +func TestSandboxTemplate_ClosedReturnsUnavailable(t *testing.T) { + store := newobjectStore(sandboxWorkloadTemplateName, copySandboxWorkloadTemplate) + tc := newFakeSandboxTemplateClient(store, func() bool { return true }) + ctx := context.Background() + + _, err := tc.Create(ctx, "default", &types.SandboxWorkloadTemplate{Name: "gpu-kata"}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/sandbox_test.go b/sdk/go/openshell/v1/fake/sandbox_test.go index b675621891..75ff32da2d 100644 --- a/sdk/go/openshell/v1/fake/sandbox_test.go +++ b/sdk/go/openshell/v1/fake/sandbox_test.go @@ -20,8 +20,9 @@ import ( // helper to build a minimal fake sandbox client for testing. func newTestSandboxClient() *fakeSandboxClient { store := newobjectStore(sandboxName, copySandbox) + templateStore := newobjectStore(sandboxWorkloadTemplateName, copySandboxWorkloadTemplate) broadcaster := newWatchBroadcaster[*types.Sandbox]() - return newFakeSandboxClient(store, broadcaster, func() bool { return false }) + return newFakeSandboxClient(store, templateStore, broadcaster, func() bool { return false }) } // --- T008: Sandbox CRUD tests --- @@ -913,6 +914,41 @@ func TestFakeSandboxCreateWithNilPolicy(t *testing.T) { assert.Nil(t, got.Spec.Policy) } +func TestFakeSandboxCreateFromTemplatePreservesCommandAndTTY(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + sc.templateStore.Insert("default", &types.SandboxWorkloadTemplate{ + Name: "gpu-kata", + ResourceVersion: 7, + Spec: types.SandboxWorkloadTemplateSpec{ + Workload: &types.SandboxWorkloadConfig{ + Image: "registry.example.com/agent:latest", + }, + }, + }) + spec := &types.SandboxSpec{ + Providers: []string{"github"}, + Command: []string{"/opt/worker", "--serve"}, + TTY: true, + } + + created, err := sc.CreateFromTemplate(ctx, "default", "job-1", "gpu-kata", spec, map[string]string{"team": "runtime"}) + + require.NoError(t, err) + assert.Equal(t, []string{"/opt/worker", "--serve"}, created.Spec.Command) + assert.True(t, created.Spec.TTY) + assert.Equal(t, []string{"github"}, created.Spec.Providers) + require.NotNil(t, created.CreatedFromWorkloadTemplate) + assert.Equal(t, "gpu-kata", created.CreatedFromWorkloadTemplate.Name) + assert.Equal(t, "7", created.CreatedFromWorkloadTemplate.ResourceVersion) + + spec.Command[0] = "mutated" + got, err := sc.Get(ctx, "default", "job-1") + require.NoError(t, err) + assert.Equal(t, []string{"/opt/worker", "--serve"}, got.Spec.Command) + assert.True(t, got.Spec.TTY) +} + // --- T032: GetLogs stub tests --- func TestSandbox_GetLogs_ReturnsUnimplemented(t *testing.T) { @@ -924,8 +960,9 @@ func TestSandbox_GetLogs_ReturnsUnimplemented(t *testing.T) { func TestSandbox_GetLogs_ClosedReturnsUnavailable(t *testing.T) { store := newobjectStore(sandboxName, copySandbox) + templateStore := newobjectStore(sandboxWorkloadTemplateName, copySandboxWorkloadTemplate) broadcaster := newWatchBroadcaster[*types.Sandbox]() - sc := newFakeSandboxClient(store, broadcaster, func() bool { return true }) + sc := newFakeSandboxClient(store, templateStore, broadcaster, func() bool { return true }) _, err := sc.GetLogs(context.Background(), "default", "sb-1") require.Error(t, err) assert.True(t, types.IsUnavailable(err)) diff --git a/sdk/go/openshell/v1/internal/converter/coverage_test.go b/sdk/go/openshell/v1/internal/converter/coverage_test.go index ea9add6d21..ad20aca260 100644 --- a/sdk/go/openshell/v1/internal/converter/coverage_test.go +++ b/sdk/go/openshell/v1/internal/converter/coverage_test.go @@ -52,6 +52,62 @@ func TestConverterCoversAllProtoFields_SandboxTemplate(t *testing.T) { assertAllFieldsCovered(t, (&pb.SandboxTemplate{}).ProtoReflect().Descriptor(), handled, nil) } +func TestConverterCoversAllProtoFields_SandboxWorkloadTemplate(t *testing.T) { + handled := fieldSet{ + "metadata": true, + "spec": true, + } + + assertAllFieldsCovered(t, (&pb.SandboxWorkloadTemplate{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_SandboxWorkloadTemplateSpec(t *testing.T) { + handled := fieldSet{ + "workload": true, + "driver_config": true, + "desired_service_level": true, + } + + assertAllFieldsCovered(t, (&pb.SandboxWorkloadTemplateSpec{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_SandboxWorkloadConfig(t *testing.T) { + handled := fieldSet{ + "image": true, + "environment": true, + "resources": true, + } + + assertAllFieldsCovered(t, (&pb.SandboxWorkloadConfig{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_SandboxResources(t *testing.T) { + handled := fieldSet{ + "cpu": true, + "memory": true, + "gpu": true, + } + + assertAllFieldsCovered(t, (&pb.SandboxResources{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_SandboxServiceLevel(t *testing.T) { + handled := fieldSet{ + "startup": true, + } + + assertAllFieldsCovered(t, (&pb.SandboxServiceLevel{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_SandboxStartup(t *testing.T) { + handled := fieldSet{ + "ready_within": true, + "max_burst": true, + } + + assertAllFieldsCovered(t, (&pb.SandboxStartup{}).ProtoReflect().Descriptor(), handled, nil) +} + func TestConverterCoversAllProtoFields_SandboxStatus(t *testing.T) { handled := fieldSet{ "sandbox_name": true, diff --git a/sdk/go/openshell/v1/internal/converter/sandbox.go b/sdk/go/openshell/v1/internal/converter/sandbox.go index 6b61053295..bf15f11059 100644 --- a/sdk/go/openshell/v1/internal/converter/sandbox.go +++ b/sdk/go/openshell/v1/internal/converter/sandbox.go @@ -5,10 +5,12 @@ package converter import ( "fmt" + "time" "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/structpb" ) @@ -31,6 +33,13 @@ func SandboxFromProto(s *pb.Sandbox) *types.Sandbox { result.DeletionTimestamp = TimeFromMillisPtr(m.GetDeletionTimestampMs()) } + if provenance := s.GetCreatedFromWorkloadTemplate(); provenance != nil { + result.CreatedFromWorkloadTemplate = &types.SandboxWorkloadTemplateProvenance{ + Name: provenance.GetName(), + ResourceVersion: provenance.GetResourceVersion(), + } + } + if spec := s.GetSpec(); spec != nil { result.Spec = sandboxSpecFromProto(spec) } @@ -72,8 +81,11 @@ func sandboxSpecFromProto(spec *pb.SandboxSpec) types.SandboxSpec { } if rr := spec.GetResourceRequirements(); rr != nil { - if gpu := rr.GetGpu(); gpu != nil && gpu.Count != nil { - result.GPUCount = gpu.Count + if gpu := rr.GetGpu(); gpu != nil { + result.GPU = true + if gpu.Count != nil { + result.GPUCount = gpu.Count + } } } result.Command = CopyStringSlice(spec.GetCommand()) @@ -219,7 +231,7 @@ func SandboxSpecToProto(spec *types.SandboxSpec) *pb.SandboxSpec { result.Template = tmpl } - if spec.GPUCount != nil { + if spec.GPU || spec.GPUCount != nil { result.ResourceRequirements = &pb.ResourceRequirements{ Gpu: &pb.GpuResourceRequirements{ Count: spec.GPUCount, @@ -264,3 +276,221 @@ func SandboxSpecToProtoChecked(spec *types.SandboxSpec) (*pb.SandboxSpec, error) } return result, nil } + +// SandboxWorkloadTemplateFromProto converts a reusable template proto to an SDK template. +func SandboxWorkloadTemplateFromProto(t *pb.SandboxWorkloadTemplate) *types.SandboxWorkloadTemplate { + if t == nil { + return nil + } + + result := &types.SandboxWorkloadTemplate{} + if m := t.GetMetadata(); m != nil { + result.ID = m.GetId() + result.Name = m.GetName() + result.CreatedAt = TimeFromMillis(m.GetCreatedAtMs()) + result.Labels = CopyStringMap(m.GetLabels()) + result.Annotations = CopyStringMap(m.GetAnnotations()) + result.ResourceVersion = m.GetResourceVersion() + result.Workspace = m.GetWorkspace() + result.DeletionTimestamp = TimeFromMillisPtr(m.GetDeletionTimestampMs()) + } + if spec := t.GetSpec(); spec != nil { + result.Spec = SandboxWorkloadTemplateSpecFromProto(spec) + } + return result +} + +// SandboxWorkloadTemplateSpecFromProto converts a reusable template spec proto. +func SandboxWorkloadTemplateSpecFromProto(spec *pb.SandboxWorkloadTemplateSpec) types.SandboxWorkloadTemplateSpec { + result := types.SandboxWorkloadTemplateSpec{} + if spec == nil { + return result + } + result.Workload = SandboxWorkloadConfigFromProto(spec.GetWorkload()) + result.DesiredServiceLevel = SandboxServiceLevelFromProto(spec.GetDesiredServiceLevel()) + if dc := spec.GetDriverConfig(); dc != nil { + result.DriverConfig = dc.AsMap() + } + return result +} + +// SandboxWorkloadConfigFromProto converts a portable workload proto. +func SandboxWorkloadConfigFromProto(workload *pb.SandboxWorkloadConfig) *types.SandboxWorkloadConfig { + if workload == nil { + return nil + } + return &types.SandboxWorkloadConfig{ + Image: workload.GetImage(), + Environment: CopyStringMap(workload.GetEnvironment()), + Resources: SandboxResourcesFromProto(workload.GetResources()), + } +} + +// SandboxResourcesFromProto converts portable resource requirements. +func SandboxResourcesFromProto(resources *pb.SandboxResources) *types.SandboxResources { + if resources == nil { + return nil + } + return &types.SandboxResources{ + CPU: resources.GetCpu(), + Memory: resources.GetMemory(), + GPU: sandboxResourceGpuFromProto(resources), + } +} + +// SandboxServiceLevelFromProto converts template service-level hints. +func SandboxServiceLevelFromProto(level *pb.SandboxServiceLevel) *types.SandboxServiceLevel { + if level == nil { + return nil + } + return &types.SandboxServiceLevel{ + Startup: SandboxStartupFromProto(level.GetStartup()), + } +} + +// SandboxStartupFromProto converts startup service-level hints. +func SandboxStartupFromProto(startup *pb.SandboxStartup) *types.SandboxStartup { + if startup == nil { + return nil + } + return &types.SandboxStartup{ + ReadyWithin: durationFromProto(startup.GetReadyWithin()), + MaxBurst: startup.GetMaxBurst(), + } +} + +// SandboxWorkloadTemplateToProto converts an SDK reusable template to proto. +func SandboxWorkloadTemplateToProto(t *types.SandboxWorkloadTemplate) *pb.SandboxWorkloadTemplate { + if t == nil { + return nil + } + return &pb.SandboxWorkloadTemplate{ + Metadata: &dm.ObjectMeta{ + Id: t.ID, + Name: t.Name, + CreatedAtMs: MillisFromTime(t.CreatedAt), + Labels: CopyStringMap(t.Labels), + Annotations: CopyStringMap(t.Annotations), + ResourceVersion: t.ResourceVersion, + Workspace: t.Workspace, + DeletionTimestampMs: MillisFromTimePtr(t.DeletionTimestamp), + }, + Spec: SandboxWorkloadTemplateSpecToProto(&t.Spec), + } +} + +// SandboxWorkloadTemplateSpecToProto converts an SDK reusable template spec to proto. +func SandboxWorkloadTemplateSpecToProto(spec *types.SandboxWorkloadTemplateSpec) *pb.SandboxWorkloadTemplateSpec { + if spec == nil { + return nil + } + result := &pb.SandboxWorkloadTemplateSpec{ + Workload: SandboxWorkloadConfigToProto(spec.Workload), + DesiredServiceLevel: SandboxServiceLevelToProto(spec.DesiredServiceLevel), + } + if spec.DriverConfig != nil { + if driverConfig, err := structpb.NewStruct(spec.DriverConfig); err == nil { + result.DriverConfig = driverConfig + } + } + return result +} + +// SandboxWorkloadConfigToProto converts an SDK portable workload to proto. +func SandboxWorkloadConfigToProto(workload *types.SandboxWorkloadConfig) *pb.SandboxWorkloadConfig { + if workload == nil { + return nil + } + return &pb.SandboxWorkloadConfig{ + Image: workload.Image, + Environment: CopyStringMap(workload.Environment), + Resources: SandboxResourcesToProto(workload.Resources), + } +} + +// SandboxResourcesToProto converts portable resource requirements. +func SandboxResourcesToProto(resources *types.SandboxResources) *pb.SandboxResources { + if resources == nil { + return nil + } + return &pb.SandboxResources{ + Cpu: resources.CPU, + Memory: resources.Memory, + Gpu: sandboxResourceGpuToProto(resources), + } +} + +func sandboxResourceGpuToProto(resources *types.SandboxResources) *pb.GpuResourceRequirements { + if resources == nil || resources.GPU == nil { + return nil + } + return &pb.GpuResourceRequirements{Count: CopyUint32Ptr(resources.GPU.Count)} +} + +func sandboxResourceGpuFromProto(resources *pb.SandboxResources) *types.SandboxGPURequirements { + if resources == nil || resources.GetGpu() == nil { + return nil + } + return &types.SandboxGPURequirements{Count: CopyUint32Ptr(resources.GetGpu().Count)} +} + +// SandboxServiceLevelToProto converts template service-level hints. +func SandboxServiceLevelToProto(level *types.SandboxServiceLevel) *pb.SandboxServiceLevel { + if level == nil { + return nil + } + return &pb.SandboxServiceLevel{ + Startup: SandboxStartupToProto(level.Startup), + } +} + +// SandboxStartupToProto converts startup service-level hints. +func SandboxStartupToProto(startup *types.SandboxStartup) *pb.SandboxStartup { + if startup == nil { + return nil + } + return &pb.SandboxStartup{ + ReadyWithin: durationToProto(startup.ReadyWithin), + MaxBurst: startup.MaxBurst, + } +} + +// SandboxWorkloadTemplateToProtoChecked converts an SDK reusable template and +// reports driver config values that protobuf Struct cannot represent. +func SandboxWorkloadTemplateToProtoChecked(t *types.SandboxWorkloadTemplate) (*pb.SandboxWorkloadTemplate, error) { + result := SandboxWorkloadTemplateToProto(t) + if t == nil { + return result, nil + } + if t.Spec.DriverConfig != nil { + driverConfig, err := structpb.NewStruct(t.Spec.DriverConfig) + if err != nil { + return nil, fmt.Errorf("driver config: %w", err) + } + result.Spec.DriverConfig = driverConfig + } + return result, nil +} + +// CopyUint32Ptr returns a copy of a *uint32 pointer. +func CopyUint32Ptr(p *uint32) *uint32 { + if p == nil { + return nil + } + v := *p + return &v +} + +func durationFromProto(d *durationpb.Duration) time.Duration { + if d == nil { + return 0 + } + return d.AsDuration() +} + +func durationToProto(d time.Duration) *durationpb.Duration { + if d == 0 { + return nil + } + return durationpb.New(d) +} diff --git a/sdk/go/openshell/v1/internal/converter/sandbox_test.go b/sdk/go/openshell/v1/internal/converter/sandbox_test.go index 9f68013845..e1687464b5 100644 --- a/sdk/go/openshell/v1/internal/converter/sandbox_test.go +++ b/sdk/go/openshell/v1/internal/converter/sandbox_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/structpb" ) @@ -60,6 +61,10 @@ func TestSandboxFromProto(t *testing.T) { Command: []string{"/opt/agent", "--serve"}, Tty: false, }, + CreatedFromWorkloadTemplate: &pb.SandboxWorkloadTemplateProvenance{ + Name: "gpu-kata", + ResourceVersion: "7", + }, Status: &pb.SandboxStatus{ SandboxName: "sb-compute-1", AgentPod: "agent-pod-xyz", @@ -93,11 +98,15 @@ func TestSandboxFromProto(t *testing.T) { assert.Equal(t, "prod", s.Workspace) require.NotNil(t, s.DeletionTimestamp) assert.Equal(t, time.UnixMilli(1700000060000).UTC(), *s.DeletionTimestamp) + require.NotNil(t, s.CreatedFromWorkloadTemplate) + assert.Equal(t, "gpu-kata", s.CreatedFromWorkloadTemplate.Name) + assert.Equal(t, "7", s.CreatedFromWorkloadTemplate.ResourceVersion) // Spec assert.Equal(t, "debug", s.Spec.LogLevel) assert.Equal(t, map[string]string{"FOO": "bar"}, s.Spec.Environment) assert.Equal(t, []string{"claude", "github"}, s.Spec.Providers) + assert.True(t, s.Spec.GPU) require.NotNil(t, s.Spec.GPUCount) assert.Equal(t, uint32(2), *s.Spec.GPUCount) assert.Equal(t, []string{"/opt/agent", "--serve"}, s.Spec.Command) @@ -173,10 +182,27 @@ func TestSandboxFromProto_NilFields(t *testing.T) { assert.Empty(t, s.Name) assert.True(t, s.CreatedAt.IsZero()) assert.Nil(t, s.Spec.Template) + assert.False(t, s.Spec.GPU) assert.Nil(t, s.Spec.GPUCount) assert.Equal(t, v1.SandboxUnknown, s.Status.Phase) } +func TestSandboxFromProto_DefaultGPURequest(t *testing.T) { + proto := &pb.Sandbox{ + Spec: &pb.SandboxSpec{ + ResourceRequirements: &pb.ResourceRequirements{ + Gpu: &pb.GpuResourceRequirements{}, + }, + }, + } + + s := SandboxFromProto(proto) + + require.NotNil(t, s) + assert.True(t, s.Spec.GPU) + assert.Nil(t, s.Spec.GPUCount) +} + func TestSandboxFromProto_Nil(t *testing.T) { s := SandboxFromProto(nil) assert.Nil(t, s) @@ -314,6 +340,137 @@ func TestSandboxToProto_NilTemplate(t *testing.T) { assert.Nil(t, p.Spec.ResourceRequirements) } +func TestSandboxToProto_DefaultGPURequest(t *testing.T) { + s := &v1.Sandbox{ + Spec: v1.SandboxSpec{ + GPU: true, + }, + } + + p := SandboxToProto(s) + + require.NotNil(t, p) + require.NotNil(t, p.Spec) + require.NotNil(t, p.Spec.ResourceRequirements) + require.NotNil(t, p.Spec.ResourceRequirements.Gpu) + assert.Nil(t, p.Spec.ResourceRequirements.Gpu.Count) +} + +func TestSandboxWorkloadTemplateRoundTrip(t *testing.T) { + gpuCount := uint32(2) + delTime := time.UnixMilli(1700000060000).UTC() + original := &v1.SandboxWorkloadTemplate{ + ID: "tmpl-1", + Name: "gpu-kata", + CreatedAt: time.UnixMilli(1700000000000).UTC(), + Labels: map[string]string{"team": "platform"}, + Annotations: map[string]string{"note": "fast-start"}, + ResourceVersion: 9, + Workspace: "prod", + DeletionTimestamp: &delTime, + Spec: v1.SandboxWorkloadTemplateSpec{ + Workload: &v1.SandboxWorkloadConfig{ + Image: "nvcr.io/nvidia/openshell:latest", + Environment: map[string]string{"CUDA_VISIBLE_DEVICES": "all"}, + Resources: &v1.SandboxResources{ + CPU: "2", + Memory: "8Gi", + GPU: &v1.SandboxGPURequirements{Count: &gpuCount}, + }, + }, + DriverConfig: map[string]any{ + "kubernetes": map[string]any{"runtimeClassName": "kata"}, + }, + DesiredServiceLevel: &v1.SandboxServiceLevel{ + Startup: &v1.SandboxStartup{ + ReadyWithin: 30 * time.Second, + MaxBurst: 3, + }, + }, + }, + } + + protoTemplate, err := SandboxWorkloadTemplateToProtoChecked(original) + require.NoError(t, err) + + require.NotNil(t, protoTemplate.Metadata) + assert.Equal(t, "gpu-kata", protoTemplate.Metadata.Name) + require.NotNil(t, protoTemplate.Spec) + require.NotNil(t, protoTemplate.Spec.Workload) + assert.Equal(t, "nvcr.io/nvidia/openshell:latest", protoTemplate.Spec.Workload.Image) + assert.Equal(t, map[string]string{"CUDA_VISIBLE_DEVICES": "all"}, protoTemplate.Spec.Workload.Environment) + require.NotNil(t, protoTemplate.Spec.Workload.Resources) + assert.Equal(t, "2", protoTemplate.Spec.Workload.Resources.Cpu) + assert.Equal(t, "8Gi", protoTemplate.Spec.Workload.Resources.Memory) + require.NotNil(t, protoTemplate.Spec.Workload.Resources.Gpu) + require.NotNil(t, protoTemplate.Spec.Workload.Resources.Gpu.Count) + assert.Equal(t, uint32(2), *protoTemplate.Spec.Workload.Resources.Gpu.Count) + require.NotNil(t, protoTemplate.Spec.DriverConfig) + require.NotNil(t, protoTemplate.Spec.DesiredServiceLevel) + require.NotNil(t, protoTemplate.Spec.DesiredServiceLevel.Startup) + assert.Equal(t, durationpb.New(30*time.Second), protoTemplate.Spec.DesiredServiceLevel.Startup.ReadyWithin) + assert.Equal(t, uint32(3), protoTemplate.Spec.DesiredServiceLevel.Startup.MaxBurst) + + back := SandboxWorkloadTemplateFromProto(protoTemplate) + require.NotNil(t, back) + assert.Equal(t, original.ID, back.ID) + assert.Equal(t, original.Name, back.Name) + assert.Equal(t, original.CreatedAt, back.CreatedAt) + assert.Equal(t, original.Labels, back.Labels) + assert.Equal(t, original.Annotations, back.Annotations) + assert.Equal(t, original.ResourceVersion, back.ResourceVersion) + assert.Equal(t, original.Workspace, back.Workspace) + require.NotNil(t, back.DeletionTimestamp) + assert.Equal(t, *original.DeletionTimestamp, *back.DeletionTimestamp) + require.NotNil(t, back.Spec.Workload) + assert.Equal(t, original.Spec.Workload.Image, back.Spec.Workload.Image) + assert.Equal(t, original.Spec.Workload.Environment, back.Spec.Workload.Environment) + require.NotNil(t, back.Spec.Workload.Resources) + assert.Equal(t, original.Spec.Workload.Resources.CPU, back.Spec.Workload.Resources.CPU) + assert.Equal(t, original.Spec.Workload.Resources.Memory, back.Spec.Workload.Resources.Memory) + require.NotNil(t, back.Spec.Workload.Resources.GPU) + require.NotNil(t, back.Spec.Workload.Resources.GPU.Count) + assert.Equal(t, *original.Spec.Workload.Resources.GPU.Count, *back.Spec.Workload.Resources.GPU.Count) + assert.Equal(t, "kata", back.Spec.DriverConfig["kubernetes"].(map[string]any)["runtimeClassName"]) + require.NotNil(t, back.Spec.DesiredServiceLevel) + require.NotNil(t, back.Spec.DesiredServiceLevel.Startup) + assert.Equal(t, 30*time.Second, back.Spec.DesiredServiceLevel.Startup.ReadyWithin) + assert.Equal(t, uint32(3), back.Spec.DesiredServiceLevel.Startup.MaxBurst) +} + +func TestSandboxWorkloadTemplateRoundTrip_DefaultGpuRequest(t *testing.T) { + original := &v1.SandboxWorkloadTemplate{ + Name: "default-gpu", + Spec: v1.SandboxWorkloadTemplateSpec{ + Workload: &v1.SandboxWorkloadConfig{ + Resources: &v1.SandboxResources{ + GPU: &v1.SandboxGPURequirements{}, + }, + }, + }, + } + + protoTemplate, err := SandboxWorkloadTemplateToProtoChecked(original) + require.NoError(t, err) + require.NotNil(t, protoTemplate.GetSpec().GetWorkload().GetResources().GetGpu()) + assert.Nil(t, protoTemplate.GetSpec().GetWorkload().GetResources().GetGpu().Count) + + back := SandboxWorkloadTemplateFromProto(protoTemplate) + require.NotNil(t, back.Spec.Workload.Resources.GPU) + assert.Nil(t, back.Spec.Workload.Resources.GPU.Count) +} + +func TestSandboxWorkloadTemplateToProtoChecked_RejectsUnrepresentableDriverConfig(t *testing.T) { + _, err := SandboxWorkloadTemplateToProtoChecked(&v1.SandboxWorkloadTemplate{ + Name: "bad", + Spec: v1.SandboxWorkloadTemplateSpec{ + DriverConfig: map[string]any{"invalid": make(chan int)}, + }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "driver config") +} + func TestSandboxRoundTrip(t *testing.T) { userNS := false gpuCount := uint32(1) @@ -384,6 +541,7 @@ func TestSandboxRoundTrip(t *testing.T) { assert.Equal(t, original.Spec.LogLevel, back.Spec.LogLevel) assert.Equal(t, original.Spec.Environment, back.Spec.Environment) assert.Equal(t, original.Spec.Providers, back.Spec.Providers) + assert.True(t, back.Spec.GPU) require.NotNil(t, back.Spec.GPUCount) assert.Equal(t, *original.Spec.GPUCount, *back.Spec.GPUCount) require.NotNil(t, back.Spec.Template) diff --git a/sdk/go/openshell/v1/sandbox.go b/sdk/go/openshell/v1/sandbox.go index 871bbb1bf3..79174ac7f3 100644 --- a/sdk/go/openshell/v1/sandbox.go +++ b/sdk/go/openshell/v1/sandbox.go @@ -67,3 +67,9 @@ type SandboxInterface interface { Watch(ctx context.Context, workspace, name string, opts ...WatchOptions) (WatchInterface[*Sandbox], error) GetLogs(ctx context.Context, workspace, sandboxName string, opts ...LogOption) (*LogResult, error) } + +// SandboxTemplateCreateInterface defines additive sandbox creation from named +// workload templates without widening SandboxInterface. +type SandboxTemplateCreateInterface interface { + CreateFromTemplate(ctx context.Context, workspace, name, templateName string, spec *SandboxSpec, labels map[string]string, opts ...CreateOptions) (*Sandbox, error) +} diff --git a/sdk/go/openshell/v1/sandbox_client.go b/sdk/go/openshell/v1/sandbox_client.go index f31cd40af9..75d8d4caa3 100644 --- a/sdk/go/openshell/v1/sandbox_client.go +++ b/sdk/go/openshell/v1/sandbox_client.go @@ -21,6 +21,9 @@ type sandboxClient struct { client pb.OpenShellClient } +var _ SandboxInterface = (*sandboxClient)(nil) +var _ SandboxTemplateCreateInterface = (*sandboxClient)(nil) + func newSandboxClient(conn grpc.ClientConnInterface) *sandboxClient { return &sandboxClient{client: pb.NewOpenShellClient(conn)} } @@ -46,6 +49,44 @@ func (s *sandboxClient) Create(ctx context.Context, workspace, name string, spec return converter.SandboxFromProto(resp.GetSandbox()), nil } +func (s *sandboxClient) CreateFromTemplate(ctx context.Context, workspace, name, templateName string, spec *SandboxSpec, labels map[string]string, opts ...CreateOptions) (*Sandbox, error) { + if templateName == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "template name is required"} + } + if err := validateTemplateCreateSpec(spec); err != nil { + return nil, err + } + protoSpec, err := converter.SandboxSpecToProtoChecked(spec) + if err != nil { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: err.Error()} + } + req := &pb.CreateSandboxRequest{ + Name: name, + Spec: protoSpec, + Labels: labels, + Workspace: workspace, + WorkloadTemplateName: templateName, + } + if len(opts) > 0 { + req.Annotations = converter.CopyStringMap(opts[0].Annotations) + } + resp, err := s.client.CreateSandbox(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.SandboxFromProto(resp.GetSandbox()), nil +} + +func validateTemplateCreateSpec(spec *SandboxSpec) error { + if spec == nil { + return nil + } + if spec.LogLevel != "" || len(spec.Environment) > 0 || spec.Template != nil || spec.GPU || spec.GPUCount != nil { + return &StatusError{Code: ErrorInvalidArgument, Message: "template creates only allow policy, providers, command, and tty in spec"} + } + return nil +} + func (s *sandboxClient) Get(ctx context.Context, workspace, name string) (*Sandbox, error) { resp, err := s.client.GetSandbox(ctx, &pb.GetSandboxRequest{ Name: name, diff --git a/sdk/go/openshell/v1/sandbox_client_test.go b/sdk/go/openshell/v1/sandbox_client_test.go index 91e80db148..7926b28a16 100644 --- a/sdk/go/openshell/v1/sandbox_client_test.go +++ b/sdk/go/openshell/v1/sandbox_client_test.go @@ -34,6 +34,7 @@ type mockSandboxServer struct { attachErr error detachErr error listProvErr error + createRequest *pb.CreateSandboxRequest watchEvents []*pb.SandboxStreamEvent watchErr error watchPostEventsErr error @@ -56,6 +57,7 @@ func newMockSandboxServer() *mockSandboxServer { func (s *mockSandboxServer) CreateSandbox(_ context.Context, req *pb.CreateSandboxRequest) (*pb.SandboxResponse, error) { s.mu.Lock() defer s.mu.Unlock() + s.createRequest = proto.Clone(req).(*pb.CreateSandboxRequest) if s.createErr != nil { return nil, s.createErr } @@ -265,6 +267,29 @@ func TestSandboxCreate(t *testing.T) { assert.Equal(t, SandboxProvisioning, result.Status.Phase) } +func TestSandboxCreate_DefaultGPURequest(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.Create(context.Background(), "default", "gpu-sandbox", &SandboxSpec{ + GPU: true, + }, nil) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Spec.GPU) + assert.Nil(t, result.Spec.GPUCount) + + mock.mu.Lock() + defer mock.mu.Unlock() + require.NotNil(t, mock.createRequest) + require.NotNil(t, mock.createRequest.Spec) + require.NotNil(t, mock.createRequest.Spec.ResourceRequirements) + require.NotNil(t, mock.createRequest.Spec.ResourceRequirements.Gpu) + assert.Nil(t, mock.createRequest.Spec.ResourceRequirements.Gpu.Count) +} + func TestSandboxCreate_RejectsUnrepresentableResourcesBeforeRPC(t *testing.T) { mock := newMockSandboxServer() client, cleanup := setupSandboxTest(t, mock) @@ -280,6 +305,47 @@ func TestSandboxCreate_RejectsUnrepresentableResourcesBeforeRPC(t *testing.T) { assert.Empty(t, mock.sandboxes) } +func TestSandboxCreateFromTemplateRejectsGPUOverrideBeforeRPC(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.CreateFromTemplate(context.Background(), "default", "bad", "gpu-kata", &SandboxSpec{ + GPU: true, + }, nil) + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) + mock.mu.Lock() + defer mock.mu.Unlock() + assert.Nil(t, mock.createRequest) +} + +func TestSandboxCreateFromTemplateSendsCommandAndTTY(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.CreateFromTemplate(context.Background(), "default", "job-1", "gpu-kata", &SandboxSpec{ + Providers: []string{"github"}, + Command: []string{"/opt/worker", "--serve"}, + TTY: true, + }, map[string]string{"team": "runtime"}) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, []string{"/opt/worker", "--serve"}, result.Spec.Command) + assert.True(t, result.Spec.TTY) + + mock.mu.Lock() + defer mock.mu.Unlock() + require.NotNil(t, mock.createRequest) + assert.Equal(t, "gpu-kata", mock.createRequest.GetWorkloadTemplateName()) + require.NotNil(t, mock.createRequest.GetSpec()) + assert.Equal(t, []string{"/opt/worker", "--serve"}, mock.createRequest.GetSpec().GetCommand()) + assert.True(t, mock.createRequest.GetSpec().GetTty()) +} + func TestSandboxCreate_AlreadyExists(t *testing.T) { mock := newMockSandboxServer() mock.createErr = status.Error(codes.AlreadyExists, "sandbox already exists") diff --git a/sdk/go/openshell/v1/sandbox_template.go b/sdk/go/openshell/v1/sandbox_template.go new file mode 100644 index 0000000000..e2f2d71874 --- /dev/null +++ b/sdk/go/openshell/v1/sandbox_template.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// SandboxWorkloadTemplate is a reusable workspace-scoped sandbox template resource. +type SandboxWorkloadTemplate = types.SandboxWorkloadTemplate + +// SandboxWorkloadTemplateSpec holds reusable sandbox template settings. +type SandboxWorkloadTemplateSpec = types.SandboxWorkloadTemplateSpec + +// SandboxWorkloadConfig defines the portable workload for a reusable template. +type SandboxWorkloadConfig = types.SandboxWorkloadConfig + +// SandboxResources defines portable sandbox resource requirements. +type SandboxResources = types.SandboxResources + +// SandboxGPURequirements defines template GPU requirements. +type SandboxGPURequirements = types.SandboxGPURequirements + +// SandboxServiceLevel describes desired operational characteristics. +type SandboxServiceLevel = types.SandboxServiceLevel + +// SandboxStartup describes desired startup characteristics. +type SandboxStartup = types.SandboxStartup + +// SandboxWorkloadTemplateProvenance identifies the reusable template revision used to create a sandbox. +type SandboxWorkloadTemplateProvenance = types.SandboxWorkloadTemplateProvenance + +// SandboxTemplateInterface defines CRUD operations on reusable sandbox templates. +// +// The resource type is named SandboxWorkloadTemplate in the v1 Go SDK so it does +// not collide with the legacy inline SandboxTemplate field on SandboxSpec. +type SandboxTemplateInterface interface { + Create(ctx context.Context, workspace string, template *SandboxWorkloadTemplate) (*SandboxWorkloadTemplate, error) + Get(ctx context.Context, workspace, name string) (*SandboxWorkloadTemplate, error) + List(ctx context.Context, workspace string, opts ...ListOptions) ([]*SandboxWorkloadTemplate, error) + Delete(ctx context.Context, workspace, name string) (bool, error) +} diff --git a/sdk/go/openshell/v1/sandbox_template_client.go b/sdk/go/openshell/v1/sandbox_template_client.go new file mode 100644 index 0000000000..de6e19237c --- /dev/null +++ b/sdk/go/openshell/v1/sandbox_template_client.go @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type sandboxTemplateClient struct { + client pb.OpenShellClient +} + +var _ SandboxTemplateInterface = (*sandboxTemplateClient)(nil) + +func newSandboxTemplateClient(conn grpc.ClientConnInterface) *sandboxTemplateClient { + return &sandboxTemplateClient{client: pb.NewOpenShellClient(conn)} +} + +func (s *sandboxTemplateClient) Create(ctx context.Context, workspace string, template *SandboxWorkloadTemplate) (*SandboxWorkloadTemplate, error) { + if template == nil { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "template must not be nil"} + } + protoTemplate, err := converter.SandboxWorkloadTemplateToProtoChecked(template) + if err != nil { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: err.Error()} + } + resp, err := s.client.CreateSandboxTemplate(ctx, &pb.CreateSandboxTemplateRequest{ + Template: protoTemplate, + Workspace: workspace, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.SandboxWorkloadTemplateFromProto(resp.GetTemplate()), nil +} + +func (s *sandboxTemplateClient) Get(ctx context.Context, workspace, name string) (*SandboxWorkloadTemplate, error) { + resp, err := s.client.GetSandboxTemplate(ctx, &pb.GetSandboxTemplateRequest{ + Name: name, + Workspace: workspace, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.SandboxWorkloadTemplateFromProto(resp.GetTemplate()), nil +} + +func (s *sandboxTemplateClient) List(ctx context.Context, workspace string, opts ...ListOptions) ([]*SandboxWorkloadTemplate, error) { + req := &pb.ListSandboxTemplatesRequest{ + Workspace: workspace, + } + if len(opts) > 0 { + if opts[0].Limit < 0 { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "limit must not be negative"} + } + if opts[0].Offset < 0 { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "offset must not be negative"} + } + req.Limit = uint32(opts[0].Limit) + req.Offset = uint32(opts[0].Offset) + req.LabelSelector = opts[0].LabelSelector + req.AllWorkspaces = opts[0].AllWorkspaces + if req.AllWorkspaces { + req.Workspace = "" + } + } + + resp, err := s.client.ListSandboxTemplates(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + templates := make([]*SandboxWorkloadTemplate, 0, len(resp.GetTemplates())) + for _, protoTemplate := range resp.GetTemplates() { + templates = append(templates, converter.SandboxWorkloadTemplateFromProto(protoTemplate)) + } + return templates, nil +} + +func (s *sandboxTemplateClient) Delete(ctx context.Context, workspace, name string) (bool, error) { + resp, err := s.client.DeleteSandboxTemplate(ctx, &pb.DeleteSandboxTemplateRequest{ + Name: name, + Workspace: workspace, + }) + if err != nil { + return false, converter.FromGRPCError(err) + } + return resp.GetDeleted(), nil +} diff --git a/sdk/go/openshell/v1/sandbox_template_client_test.go b/sdk/go/openshell/v1/sandbox_template_client_test.go new file mode 100644 index 0000000000..4f9e3058c0 --- /dev/null +++ b/sdk/go/openshell/v1/sandbox_template_client_test.go @@ -0,0 +1,265 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "sync" + "testing" + "time" + + dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/durationpb" +) + +type mockSandboxTemplateServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + + templates map[string]*pb.SandboxWorkloadTemplate + + createRequest *pb.CreateSandboxTemplateRequest + getRequest *pb.GetSandboxTemplateRequest + listRequest *pb.ListSandboxTemplatesRequest + deleteRequest *pb.DeleteSandboxTemplateRequest + + createErr error + getErr error + listErr error + deleteErr error +} + +func newMockSandboxTemplateServer() *mockSandboxTemplateServer { + return &mockSandboxTemplateServer{ + templates: make(map[string]*pb.SandboxWorkloadTemplate), + } +} + +func (s *mockSandboxTemplateServer) CreateSandboxTemplate(_ context.Context, req *pb.CreateSandboxTemplateRequest) (*pb.SandboxTemplateResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.createRequest = proto.Clone(req).(*pb.CreateSandboxTemplateRequest) + if s.createErr != nil { + return nil, s.createErr + } + template := proto.Clone(req.GetTemplate()).(*pb.SandboxWorkloadTemplate) + if template.Metadata == nil { + template.Metadata = &dm.ObjectMeta{} + } + template.Metadata.Workspace = req.GetWorkspace() + template.Metadata.ResourceVersion = 1 + s.templates[template.Metadata.GetName()] = template + return &pb.SandboxTemplateResponse{Template: proto.Clone(template).(*pb.SandboxWorkloadTemplate)}, nil +} + +func (s *mockSandboxTemplateServer) GetSandboxTemplate(_ context.Context, req *pb.GetSandboxTemplateRequest) (*pb.SandboxTemplateResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.getRequest = proto.Clone(req).(*pb.GetSandboxTemplateRequest) + if s.getErr != nil { + return nil, s.getErr + } + template, ok := s.templates[req.GetName()] + if !ok { + return nil, status.Errorf(codes.NotFound, "template %q not found", req.GetName()) + } + return &pb.SandboxTemplateResponse{Template: proto.Clone(template).(*pb.SandboxWorkloadTemplate)}, nil +} + +func (s *mockSandboxTemplateServer) ListSandboxTemplates(_ context.Context, req *pb.ListSandboxTemplatesRequest) (*pb.ListSandboxTemplatesResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.listRequest = proto.Clone(req).(*pb.ListSandboxTemplatesRequest) + if s.listErr != nil { + return nil, s.listErr + } + templates := make([]*pb.SandboxWorkloadTemplate, 0, len(s.templates)) + for _, template := range s.templates { + templates = append(templates, proto.Clone(template).(*pb.SandboxWorkloadTemplate)) + } + return &pb.ListSandboxTemplatesResponse{Templates: templates}, nil +} + +func (s *mockSandboxTemplateServer) DeleteSandboxTemplate(_ context.Context, req *pb.DeleteSandboxTemplateRequest) (*pb.DeleteSandboxTemplateResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.deleteRequest = proto.Clone(req).(*pb.DeleteSandboxTemplateRequest) + if s.deleteErr != nil { + return nil, s.deleteErr + } + delete(s.templates, req.GetName()) + return &pb.DeleteSandboxTemplateResponse{Deleted: true}, nil +} + +func setupSandboxTemplateTest(t *testing.T, mock *mockSandboxTemplateServer) (*sandboxTemplateClient, func()) { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + return newSandboxTemplateClient(conn), func() { + _ = conn.Close() + srv.Stop() + } +} + +func TestSandboxTemplateCreate(t *testing.T) { + mock := newMockSandboxTemplateServer() + client, cleanup := setupSandboxTemplateTest(t, mock) + defer cleanup() + gpuCount := uint32(1) + + template, err := client.Create(context.Background(), "default", &SandboxWorkloadTemplate{ + Name: "gpu-kata", + Labels: map[string]string{ + "team": "platform", + }, + Spec: SandboxWorkloadTemplateSpec{ + Workload: &SandboxWorkloadConfig{ + Image: "nvcr.io/nvidia/openshell:latest", + Environment: map[string]string{"NVIDIA_VISIBLE_DEVICES": "all"}, + Resources: &SandboxResources{ + CPU: "2", + Memory: "8Gi", + GPU: &SandboxGPURequirements{Count: &gpuCount}, + }, + }, + DriverConfig: map[string]any{ + "kubernetes": map[string]any{"runtimeClassName": "kata"}, + }, + DesiredServiceLevel: &SandboxServiceLevel{ + Startup: &SandboxStartup{ + ReadyWithin: 45 * time.Second, + MaxBurst: 2, + }, + }, + }, + }) + + require.NoError(t, err) + require.NotNil(t, template) + assert.Equal(t, "gpu-kata", template.Name) + assert.Equal(t, "default", template.Workspace) + assert.Equal(t, uint64(1), template.ResourceVersion) + mock.mu.Lock() + defer mock.mu.Unlock() + require.NotNil(t, mock.createRequest) + assert.Equal(t, "default", mock.createRequest.Workspace) + assert.Equal(t, "gpu-kata", mock.createRequest.Template.Metadata.Name) + assert.Equal(t, "2", mock.createRequest.Template.Spec.Workload.Resources.Cpu) + assert.Equal(t, "8Gi", mock.createRequest.Template.Spec.Workload.Resources.Memory) + require.NotNil(t, mock.createRequest.Template.Spec.Workload.Resources.Gpu) + require.NotNil(t, mock.createRequest.Template.Spec.Workload.Resources.Gpu.Count) + assert.Equal(t, uint32(1), *mock.createRequest.Template.Spec.Workload.Resources.Gpu.Count) + assert.Equal(t, durationpb.New(45*time.Second), mock.createRequest.Template.Spec.DesiredServiceLevel.Startup.ReadyWithin) +} + +func TestSandboxTemplateCreate_RejectsNilTemplate(t *testing.T) { + mock := newMockSandboxTemplateServer() + client, cleanup := setupSandboxTemplateTest(t, mock) + defer cleanup() + + _, err := client.Create(context.Background(), "default", nil) + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestSandboxTemplateCreate_RejectsUnrepresentableDriverConfigBeforeRPC(t *testing.T) { + mock := newMockSandboxTemplateServer() + client, cleanup := setupSandboxTemplateTest(t, mock) + defer cleanup() + + _, err := client.Create(context.Background(), "default", &SandboxWorkloadTemplate{ + Name: "bad", + Spec: SandboxWorkloadTemplateSpec{ + DriverConfig: map[string]any{"invalid": make(chan int)}, + }, + }) + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) + mock.mu.Lock() + defer mock.mu.Unlock() + assert.Nil(t, mock.createRequest) +} + +func TestSandboxTemplateGetListDelete(t *testing.T) { + mock := newMockSandboxTemplateServer() + mock.templates["gpu-kata"] = &pb.SandboxWorkloadTemplate{ + Metadata: &dm.ObjectMeta{Name: "gpu-kata", Workspace: "default"}, + Spec: &pb.SandboxWorkloadTemplateSpec{ + Workload: &pb.SandboxWorkloadConfig{Image: "img:v1"}, + }, + } + client, cleanup := setupSandboxTemplateTest(t, mock) + defer cleanup() + + got, err := client.Get(context.Background(), "default", "gpu-kata") + require.NoError(t, err) + assert.Equal(t, "gpu-kata", got.Name) + assert.Equal(t, "img:v1", got.Spec.Workload.Image) + + list, err := client.List(context.Background(), "default", ListOptions{ + Limit: 10, + Offset: 2, + LabelSelector: "team=runtime", + AllWorkspaces: true, + }) + require.NoError(t, err) + require.Len(t, list, 1) + assert.Equal(t, "gpu-kata", list[0].Name) + + deleted, err := client.Delete(context.Background(), "default", "gpu-kata") + require.NoError(t, err) + assert.True(t, deleted) + + mock.mu.Lock() + defer mock.mu.Unlock() + require.NotNil(t, mock.getRequest) + assert.Equal(t, "default", mock.getRequest.Workspace) + assert.Equal(t, "gpu-kata", mock.getRequest.Name) + require.NotNil(t, mock.listRequest) + assert.Empty(t, mock.listRequest.Workspace) + assert.Equal(t, uint32(10), mock.listRequest.Limit) + assert.Equal(t, uint32(2), mock.listRequest.Offset) + assert.Equal(t, "team=runtime", mock.listRequest.LabelSelector) + assert.True(t, mock.listRequest.AllWorkspaces) + require.NotNil(t, mock.deleteRequest) + assert.Equal(t, "default", mock.deleteRequest.Workspace) + assert.Equal(t, "gpu-kata", mock.deleteRequest.Name) +} + +func TestSandboxTemplateList_RejectsNegativePagination(t *testing.T) { + mock := newMockSandboxTemplateServer() + client, cleanup := setupSandboxTemplateTest(t, mock) + defer cleanup() + + _, err := client.List(context.Background(), "default", ListOptions{Limit: -1}) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) + + _, err = client.List(context.Background(), "default", ListOptions{Offset: -1}) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} diff --git a/sdk/go/openshell/v1/types/sandbox.go b/sdk/go/openshell/v1/types/sandbox.go index 6ce51d84a1..8be13888ba 100644 --- a/sdk/go/openshell/v1/types/sandbox.go +++ b/sdk/go/openshell/v1/types/sandbox.go @@ -7,16 +7,17 @@ import "time" // Sandbox represents a sandbox instance. type Sandbox struct { - ID string - Name string - CreatedAt time.Time - Labels map[string]string - Annotations map[string]string - ResourceVersion uint64 - Workspace string - DeletionTimestamp *time.Time - Spec SandboxSpec - Status SandboxStatus + ID string + Name string + CreatedAt time.Time + Labels map[string]string + Annotations map[string]string + ResourceVersion uint64 + Workspace string + DeletionTimestamp *time.Time + CreatedFromWorkloadTemplate *SandboxWorkloadTemplateProvenance + Spec SandboxSpec + Status SandboxStatus } // SandboxSpec holds the desired state of a sandbox. @@ -25,7 +26,10 @@ type SandboxSpec struct { Environment map[string]string Template *SandboxTemplate Providers []string - GPUCount *uint32 + // GPU requests GPU resources using the active driver's default GPU assignment + // when GPUCount is nil. GPUCount implies GPU for backward compatibility. + GPU bool + GPUCount *uint32 // Policy is the security policy for the sandbox. Nil means no policy specified. Policy *SandboxPolicy Command []string @@ -45,6 +49,64 @@ type SandboxTemplate struct { DriverConfig map[string]any } +// SandboxWorkloadTemplate is a reusable workspace-scoped sandbox template resource. +type SandboxWorkloadTemplate struct { + ID string + Name string + CreatedAt time.Time + Labels map[string]string + Annotations map[string]string + ResourceVersion uint64 + Workspace string + DeletionTimestamp *time.Time + Spec SandboxWorkloadTemplateSpec +} + +// SandboxWorkloadTemplateSpec holds reusable sandbox template settings. +type SandboxWorkloadTemplateSpec struct { + Workload *SandboxWorkloadConfig + DriverConfig map[string]any + DesiredServiceLevel *SandboxServiceLevel +} + +// SandboxWorkloadConfig defines the portable workload for a reusable template. +type SandboxWorkloadConfig struct { + Image string + Environment map[string]string + Resources *SandboxResources +} + +// SandboxResources defines portable sandbox resource requirements. +type SandboxResources struct { + CPU string + Memory string + // GPU requests GPU resources for template-backed sandboxes. A non-nil GPU + // with nil Count requests the active driver's default GPU assignment. + GPU *SandboxGPURequirements +} + +// SandboxGPURequirements defines template GPU requirements. +type SandboxGPURequirements struct { + Count *uint32 +} + +// SandboxServiceLevel describes desired operational characteristics. +type SandboxServiceLevel struct { + Startup *SandboxStartup +} + +// SandboxStartup describes desired startup characteristics. +type SandboxStartup struct { + ReadyWithin time.Duration + MaxBurst uint32 +} + +// SandboxWorkloadTemplateProvenance identifies the reusable template revision used to create a sandbox. +type SandboxWorkloadTemplateProvenance struct { + Name string + ResourceVersion string +} + // SandboxStatus holds the observed state of a sandbox. type SandboxStatus struct { SandboxName string diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index 0d2b86fce0..f9e7f39030 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -15,6 +15,7 @@ import ( sandboxv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + durationpb "google.golang.org/protobuf/types/known/durationpb" structpb "google.golang.org/protobuf/types/known/structpb" reflect "reflect" sync "sync" @@ -1155,9 +1156,11 @@ type Sandbox struct { // Desired sandbox configuration submitted through the API. Spec *SandboxSpec `protobuf:"bytes,2,opt,name=spec,proto3" json:"spec,omitempty"` // Latest user-facing observed status derived by the gateway. - Status *SandboxStatus `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Status *SandboxStatus `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + // Read-only provenance for sandboxes created from a reusable workload template. + CreatedFromWorkloadTemplate *SandboxWorkloadTemplateProvenance `protobuf:"bytes,20,opt,name=created_from_workload_template,json=createdFromWorkloadTemplate,proto3" json:"created_from_workload_template,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Sandbox) Reset() { @@ -1211,6 +1214,13 @@ func (x *Sandbox) GetStatus() *SandboxStatus { return nil } +func (x *Sandbox) GetCreatedFromWorkloadTemplate() *SandboxWorkloadTemplateProvenance { + if x != nil { + return x.CreatedFromWorkloadTemplate + } + return nil +} + // Desired sandbox configuration provided through the public API. type SandboxSpec struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1415,7 +1425,12 @@ func (x *GpuResourceRequirements) GetCount() uint32 { return 0 } -// Public sandbox template mapped onto compute-driver template inputs. +// Historical inline compute template mapped onto compute-driver template inputs. +// +// Despite its name, this is not a reusable named sandbox template resource. It +// is an inline part of `SandboxSpec` kept for v1 compatibility. A future +// breaking API cleanup may rename this message to free `SandboxTemplate` for +// the reusable template resource now represented by `SandboxWorkloadTemplate`. type SandboxTemplate struct { state protoimpl.MessageState `protogen:"open.v1"` // Fully-qualified OCI image reference used to boot the sandbox. @@ -1540,50 +1555,36 @@ func (x *SandboxTemplate) GetDriverConfig() *structpb.Struct { return nil } -// User-facing sandbox status derived by the gateway from compute-driver observations. +// Reusable named sandbox workload template resource. // -// Public status does not embed driver-only flags such as `deleting`. -type SandboxStatus struct { +// This is the actual workspace-scoped template resource used to create +// sandboxes by reference. It uses the longer name in v1 to avoid colliding with +// the historical inline `SandboxTemplate` message. A future breaking API +// cleanup may rename this resource to `SandboxTemplate`. +type SandboxWorkloadTemplate struct { state protoimpl.MessageState `protogen:"open.v1"` - // Compute-platform sandbox object name. - SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` - // Name of the agent pod or equivalent runtime instance. - AgentPod string `protobuf:"bytes,2,opt,name=agent_pod,json=agentPod,proto3" json:"agent_pod,omitempty"` - // File descriptor or endpoint for reaching the agent service, when available. - AgentFd string `protobuf:"bytes,3,opt,name=agent_fd,json=agentFd,proto3" json:"agent_fd,omitempty"` - // File descriptor or endpoint for reaching the sandbox service, when available. - SandboxFd string `protobuf:"bytes,4,opt,name=sandbox_fd,json=sandboxFd,proto3" json:"sandbox_fd,omitempty"` - // Latest user-facing readiness and lifecycle conditions. - Conditions []*SandboxCondition `protobuf:"bytes,5,rep,name=conditions,proto3" json:"conditions,omitempty"` - // Gateway-derived lifecycle summary. - Phase SandboxPhase `protobuf:"varint,6,opt,name=phase,proto3,enum=openshell.v1.SandboxPhase" json:"phase,omitempty"` - // Currently active policy version (updated when sandbox reports loaded). - CurrentPolicyVersion uint32 `protobuf:"varint,7,opt,name=current_policy_version,json=currentPolicyVersion,proto3" json:"current_policy_version,omitempty"` - // Supervisor instance currently associated with the canonical main process. - // The gateway uses this to reject stale exit reports after a restart. - MainProcessInstanceId string `protobuf:"bytes,8,opt,name=main_process_instance_id,json=mainProcessInstanceId,proto3" json:"main_process_instance_id,omitempty"` - // Normalized main process result. Signal exits use 128 + signal number. - // Presence indicates that the canonical main process exited. Exit code 0 - // produces Completed; nonzero and signal-normalized exits produce Error. - ExitCode *int32 `protobuf:"varint,9,opt,name=exit_code,json=exitCode,proto3,oneof" json:"exit_code,omitempty"` + // Kubernetes-style metadata (id, name, labels, timestamps, resource version). + Metadata *datamodelv1.ObjectMeta `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` + // Desired reusable workload shape and template-owned driver config. + Spec *SandboxWorkloadTemplateSpec `protobuf:"bytes,2,opt,name=spec,proto3" json:"spec,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *SandboxStatus) Reset() { - *x = SandboxStatus{} +func (x *SandboxWorkloadTemplate) Reset() { + *x = SandboxWorkloadTemplate{} mi := &file_openshell_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *SandboxStatus) String() string { +func (x *SandboxWorkloadTemplate) String() string { return protoimpl.X.MessageStringOf(x) } -func (*SandboxStatus) ProtoMessage() {} +func (*SandboxWorkloadTemplate) ProtoMessage() {} -func (x *SandboxStatus) ProtoReflect() protoreflect.Message { +func (x *SandboxWorkloadTemplate) ProtoReflect() protoreflect.Message { mi := &file_openshell_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -1595,106 +1596,115 @@ func (x *SandboxStatus) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use SandboxStatus.ProtoReflect.Descriptor instead. -func (*SandboxStatus) Descriptor() ([]byte, []int) { +// Deprecated: Use SandboxWorkloadTemplate.ProtoReflect.Descriptor instead. +func (*SandboxWorkloadTemplate) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{17} } -func (x *SandboxStatus) GetSandboxName() string { +func (x *SandboxWorkloadTemplate) GetMetadata() *datamodelv1.ObjectMeta { if x != nil { - return x.SandboxName + return x.Metadata } - return "" + return nil } -func (x *SandboxStatus) GetAgentPod() string { +func (x *SandboxWorkloadTemplate) GetSpec() *SandboxWorkloadTemplateSpec { if x != nil { - return x.AgentPod + return x.Spec } - return "" + return nil } -func (x *SandboxStatus) GetAgentFd() string { - if x != nil { - return x.AgentFd - } - return "" +type SandboxWorkloadTemplateSpec struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Portable workload shape. + Workload *SandboxWorkloadConfig `protobuf:"bytes,1,opt,name=workload,proto3" json:"workload,omitempty"` + // Driver-keyed opaque config envelope supplied by the template owner. + DriverConfig *structpb.Struct `protobuf:"bytes,2,opt,name=driver_config,json=driverConfig,proto3" json:"driver_config,omitempty"` + // Desired service level associated with this template. + DesiredServiceLevel *SandboxServiceLevel `protobuf:"bytes,3,opt,name=desired_service_level,json=desiredServiceLevel,proto3" json:"desired_service_level,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxWorkloadTemplateSpec) Reset() { + *x = SandboxWorkloadTemplateSpec{} + mi := &file_openshell_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } -func (x *SandboxStatus) GetSandboxFd() string { - if x != nil { - return x.SandboxFd - } - return "" +func (x *SandboxWorkloadTemplateSpec) String() string { + return protoimpl.X.MessageStringOf(x) } -func (x *SandboxStatus) GetConditions() []*SandboxCondition { +func (*SandboxWorkloadTemplateSpec) ProtoMessage() {} + +func (x *SandboxWorkloadTemplateSpec) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[18] if x != nil { - return x.Conditions + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms } - return nil + return mi.MessageOf(x) } -func (x *SandboxStatus) GetPhase() SandboxPhase { - if x != nil { - return x.Phase - } - return SandboxPhase_SANDBOX_PHASE_UNSPECIFIED +// Deprecated: Use SandboxWorkloadTemplateSpec.ProtoReflect.Descriptor instead. +func (*SandboxWorkloadTemplateSpec) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{18} } -func (x *SandboxStatus) GetCurrentPolicyVersion() uint32 { +func (x *SandboxWorkloadTemplateSpec) GetWorkload() *SandboxWorkloadConfig { if x != nil { - return x.CurrentPolicyVersion + return x.Workload } - return 0 + return nil } -func (x *SandboxStatus) GetMainProcessInstanceId() string { +func (x *SandboxWorkloadTemplateSpec) GetDriverConfig() *structpb.Struct { if x != nil { - return x.MainProcessInstanceId + return x.DriverConfig } - return "" + return nil } -func (x *SandboxStatus) GetExitCode() int32 { - if x != nil && x.ExitCode != nil { - return *x.ExitCode +func (x *SandboxWorkloadTemplateSpec) GetDesiredServiceLevel() *SandboxServiceLevel { + if x != nil { + return x.DesiredServiceLevel } - return 0 + return nil } -// User-facing sandbox condition derived from driver-native conditions. -type SandboxCondition struct { +type SandboxWorkloadConfig struct { state protoimpl.MessageState `protogen:"open.v1"` - // Condition class, typically mirroring the underlying platform condition type. - Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` - // Condition status value such as `True`, `False`, or `Unknown`. - Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` - // Short machine-readable reason associated with the condition. - Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` - // Human-readable condition message. - Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"` - // Timestamp reported by the underlying platform for the last transition. - LastTransitionTime string `protobuf:"bytes,5,opt,name=last_transition_time,json=lastTransitionTime,proto3" json:"last_transition_time,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Fully-qualified OCI image reference used to boot the sandbox. + Image string `protobuf:"bytes,1,opt,name=image,proto3" json:"image,omitempty"` + // Environment variables injected into the sandbox runtime. + Environment map[string]string `protobuf:"bytes,2,rep,name=environment,proto3" json:"environment,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Portable resource requirements for sandboxes created from this workload. + Resources *SandboxResources `protobuf:"bytes,3,opt,name=resources,proto3" json:"resources,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *SandboxCondition) Reset() { - *x = SandboxCondition{} - mi := &file_openshell_proto_msgTypes[18] +func (x *SandboxWorkloadConfig) Reset() { + *x = SandboxWorkloadConfig{} + mi := &file_openshell_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *SandboxCondition) String() string { +func (x *SandboxWorkloadConfig) String() string { return protoimpl.X.MessageStringOf(x) } -func (*SandboxCondition) ProtoMessage() {} +func (*SandboxWorkloadConfig) ProtoMessage() {} -func (x *SandboxCondition) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[18] +func (x *SandboxWorkloadConfig) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1705,80 +1715,61 @@ func (x *SandboxCondition) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use SandboxCondition.ProtoReflect.Descriptor instead. -func (*SandboxCondition) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{18} -} - -func (x *SandboxCondition) GetType() string { - if x != nil { - return x.Type - } - return "" -} - -func (x *SandboxCondition) GetStatus() string { - if x != nil { - return x.Status - } - return "" +// Deprecated: Use SandboxWorkloadConfig.ProtoReflect.Descriptor instead. +func (*SandboxWorkloadConfig) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{19} } -func (x *SandboxCondition) GetReason() string { +func (x *SandboxWorkloadConfig) GetImage() string { if x != nil { - return x.Reason + return x.Image } return "" } -func (x *SandboxCondition) GetMessage() string { +func (x *SandboxWorkloadConfig) GetEnvironment() map[string]string { if x != nil { - return x.Message + return x.Environment } - return "" + return nil } -func (x *SandboxCondition) GetLastTransitionTime() string { +func (x *SandboxWorkloadConfig) GetResources() *SandboxResources { if x != nil { - return x.LastTransitionTime + return x.Resources } - return "" + return nil } -// Public platform event exposed on the sandbox watch stream. -type PlatformEvent struct { +type SandboxResources struct { state protoimpl.MessageState `protogen:"open.v1"` - // Event timestamp in milliseconds since epoch. - TimestampMs int64 `protobuf:"varint,1,opt,name=timestamp_ms,json=timestampMs,proto3" json:"timestamp_ms,omitempty"` - // Event source (e.g. "kubernetes", "docker", "process"). - Source string `protobuf:"bytes,2,opt,name=source,proto3" json:"source,omitempty"` - // Event type/severity (e.g. "Normal", "Warning"). - Type string `protobuf:"bytes,3,opt,name=type,proto3" json:"type,omitempty"` - // Short reason code (e.g. "Started", "Pulled", "Failed"). - Reason string `protobuf:"bytes,4,opt,name=reason,proto3" json:"reason,omitempty"` - // Human-readable event message. - Message string `protobuf:"bytes,5,opt,name=message,proto3" json:"message,omitempty"` - // Optional metadata as key-value pairs. - Metadata map[string]string `protobuf:"bytes,6,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Portable CPU quantity, for example "500m" or "2". + Cpu string `protobuf:"bytes,1,opt,name=cpu,proto3" json:"cpu,omitempty"` + // Portable memory quantity, for example "512Mi" or "2Gi". + Memory string `protobuf:"bytes,2,opt,name=memory,proto3" json:"memory,omitempty"` + // GPU requirements for the sandbox workload. Presence indicates a GPU + // request. When count is omitted, the request uses the selected driver's + // default GPU assignment behavior. + Gpu *GpuResourceRequirements `protobuf:"bytes,3,opt,name=gpu,proto3" json:"gpu,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *PlatformEvent) Reset() { - *x = PlatformEvent{} - mi := &file_openshell_proto_msgTypes[19] +func (x *SandboxResources) Reset() { + *x = SandboxResources{} + mi := &file_openshell_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *PlatformEvent) String() string { +func (x *SandboxResources) String() string { return protoimpl.X.MessageStringOf(x) } -func (*PlatformEvent) ProtoMessage() {} +func (*SandboxResources) ProtoMessage() {} -func (x *PlatformEvent) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[19] +func (x *SandboxResources) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1789,88 +1780,54 @@ func (x *PlatformEvent) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use PlatformEvent.ProtoReflect.Descriptor instead. -func (*PlatformEvent) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{19} -} - -func (x *PlatformEvent) GetTimestampMs() int64 { - if x != nil { - return x.TimestampMs - } - return 0 -} - -func (x *PlatformEvent) GetSource() string { - if x != nil { - return x.Source - } - return "" -} - -func (x *PlatformEvent) GetType() string { - if x != nil { - return x.Type - } - return "" +// Deprecated: Use SandboxResources.ProtoReflect.Descriptor instead. +func (*SandboxResources) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{20} } -func (x *PlatformEvent) GetReason() string { +func (x *SandboxResources) GetCpu() string { if x != nil { - return x.Reason + return x.Cpu } return "" } -func (x *PlatformEvent) GetMessage() string { +func (x *SandboxResources) GetMemory() string { if x != nil { - return x.Message + return x.Memory } return "" } -func (x *PlatformEvent) GetMetadata() map[string]string { +func (x *SandboxResources) GetGpu() *GpuResourceRequirements { if x != nil { - return x.Metadata + return x.Gpu } return nil } -// Create sandbox request. -type CreateSandboxRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Spec *SandboxSpec `protobuf:"bytes,1,opt,name=spec,proto3" json:"spec,omitempty"` - // Optional user-supplied sandbox name. When empty the server generates one. - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - // Optional labels for the sandbox (key-value metadata). - Labels map[string]string `protobuf:"bytes,3,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - // Optional annotations for the sandbox (non-selector metadata). - Annotations map[string]string `protobuf:"bytes,4,rep,name=annotations,proto3" json:"annotations,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - // Workspace for the sandbox. Empty defaults to "default". - Workspace string `protobuf:"bytes,5,opt,name=workspace,proto3" json:"workspace,omitempty"` - // One-shot launch hint indicating that the creating client will attach to - // the canonical main process. The supervisor keeps the terminal transport - // alive until that attachment connects and closes naturally. - AwaitMainProcessAttachment bool `protobuf:"varint,6,opt,name=await_main_process_attachment,json=awaitMainProcessAttachment,proto3" json:"await_main_process_attachment,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +type SandboxServiceLevel struct { + state protoimpl.MessageState `protogen:"open.v1"` + Startup *SandboxStartup `protobuf:"bytes,1,opt,name=startup,proto3" json:"startup,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *CreateSandboxRequest) Reset() { - *x = CreateSandboxRequest{} - mi := &file_openshell_proto_msgTypes[20] +func (x *SandboxServiceLevel) Reset() { + *x = SandboxServiceLevel{} + mi := &file_openshell_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *CreateSandboxRequest) String() string { +func (x *SandboxServiceLevel) String() string { return protoimpl.X.MessageStringOf(x) } -func (*CreateSandboxRequest) ProtoMessage() {} +func (*SandboxServiceLevel) ProtoMessage() {} -func (x *CreateSandboxRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[20] +func (x *SandboxServiceLevel) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1881,49 +1838,885 @@ func (x *CreateSandboxRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use CreateSandboxRequest.ProtoReflect.Descriptor instead. -func (*CreateSandboxRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{20} +// Deprecated: Use SandboxServiceLevel.ProtoReflect.Descriptor instead. +func (*SandboxServiceLevel) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{21} } -func (x *CreateSandboxRequest) GetSpec() *SandboxSpec { +func (x *SandboxServiceLevel) GetStartup() *SandboxStartup { if x != nil { - return x.Spec + return x.Startup } return nil } -func (x *CreateSandboxRequest) GetName() string { - if x != nil { - return x.Name - } - return "" +type SandboxStartup struct { + state protoimpl.MessageState `protogen:"open.v1"` + ReadyWithin *durationpb.Duration `protobuf:"bytes,1,opt,name=ready_within,json=readyWithin,proto3" json:"ready_within,omitempty"` + MaxBurst uint32 `protobuf:"varint,2,opt,name=max_burst,json=maxBurst,proto3" json:"max_burst,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *CreateSandboxRequest) GetLabels() map[string]string { - if x != nil { +func (x *SandboxStartup) Reset() { + *x = SandboxStartup{} + mi := &file_openshell_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxStartup) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxStartup) ProtoMessage() {} + +func (x *SandboxStartup) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SandboxStartup.ProtoReflect.Descriptor instead. +func (*SandboxStartup) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{22} +} + +func (x *SandboxStartup) GetReadyWithin() *durationpb.Duration { + if x != nil { + return x.ReadyWithin + } + return nil +} + +func (x *SandboxStartup) GetMaxBurst() uint32 { + if x != nil { + return x.MaxBurst + } + return 0 +} + +type SandboxWorkloadTemplateProvenance struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + ResourceVersion string `protobuf:"bytes,2,opt,name=resource_version,json=resourceVersion,proto3" json:"resource_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxWorkloadTemplateProvenance) Reset() { + *x = SandboxWorkloadTemplateProvenance{} + mi := &file_openshell_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxWorkloadTemplateProvenance) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxWorkloadTemplateProvenance) ProtoMessage() {} + +func (x *SandboxWorkloadTemplateProvenance) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SandboxWorkloadTemplateProvenance.ProtoReflect.Descriptor instead. +func (*SandboxWorkloadTemplateProvenance) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{23} +} + +func (x *SandboxWorkloadTemplateProvenance) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *SandboxWorkloadTemplateProvenance) GetResourceVersion() string { + if x != nil { + return x.ResourceVersion + } + return "" +} + +// User-facing sandbox status derived by the gateway from compute-driver observations. +// +// Public status does not embed driver-only flags such as `deleting`. +type SandboxStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Compute-platform sandbox object name. + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + // Name of the agent pod or equivalent runtime instance. + AgentPod string `protobuf:"bytes,2,opt,name=agent_pod,json=agentPod,proto3" json:"agent_pod,omitempty"` + // File descriptor or endpoint for reaching the agent service, when available. + AgentFd string `protobuf:"bytes,3,opt,name=agent_fd,json=agentFd,proto3" json:"agent_fd,omitempty"` + // File descriptor or endpoint for reaching the sandbox service, when available. + SandboxFd string `protobuf:"bytes,4,opt,name=sandbox_fd,json=sandboxFd,proto3" json:"sandbox_fd,omitempty"` + // Latest user-facing readiness and lifecycle conditions. + Conditions []*SandboxCondition `protobuf:"bytes,5,rep,name=conditions,proto3" json:"conditions,omitempty"` + // Gateway-derived lifecycle summary. + Phase SandboxPhase `protobuf:"varint,6,opt,name=phase,proto3,enum=openshell.v1.SandboxPhase" json:"phase,omitempty"` + // Currently active policy version (updated when sandbox reports loaded). + CurrentPolicyVersion uint32 `protobuf:"varint,7,opt,name=current_policy_version,json=currentPolicyVersion,proto3" json:"current_policy_version,omitempty"` + // Supervisor instance currently associated with the canonical main process. + // The gateway uses this to reject stale exit reports after a restart. + MainProcessInstanceId string `protobuf:"bytes,8,opt,name=main_process_instance_id,json=mainProcessInstanceId,proto3" json:"main_process_instance_id,omitempty"` + // Normalized main process result. Signal exits use 128 + signal number. + // Presence indicates that the canonical main process exited. Exit code 0 + // produces Completed; nonzero and signal-normalized exits produce Error. + ExitCode *int32 `protobuf:"varint,9,opt,name=exit_code,json=exitCode,proto3,oneof" json:"exit_code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxStatus) Reset() { + *x = SandboxStatus{} + mi := &file_openshell_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxStatus) ProtoMessage() {} + +func (x *SandboxStatus) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SandboxStatus.ProtoReflect.Descriptor instead. +func (*SandboxStatus) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{24} +} + +func (x *SandboxStatus) GetSandboxName() string { + if x != nil { + return x.SandboxName + } + return "" +} + +func (x *SandboxStatus) GetAgentPod() string { + if x != nil { + return x.AgentPod + } + return "" +} + +func (x *SandboxStatus) GetAgentFd() string { + if x != nil { + return x.AgentFd + } + return "" +} + +func (x *SandboxStatus) GetSandboxFd() string { + if x != nil { + return x.SandboxFd + } + return "" +} + +func (x *SandboxStatus) GetConditions() []*SandboxCondition { + if x != nil { + return x.Conditions + } + return nil +} + +func (x *SandboxStatus) GetPhase() SandboxPhase { + if x != nil { + return x.Phase + } + return SandboxPhase_SANDBOX_PHASE_UNSPECIFIED +} + +func (x *SandboxStatus) GetCurrentPolicyVersion() uint32 { + if x != nil { + return x.CurrentPolicyVersion + } + return 0 +} + +func (x *SandboxStatus) GetMainProcessInstanceId() string { + if x != nil { + return x.MainProcessInstanceId + } + return "" +} + +func (x *SandboxStatus) GetExitCode() int32 { + if x != nil && x.ExitCode != nil { + return *x.ExitCode + } + return 0 +} + +// User-facing sandbox condition derived from driver-native conditions. +type SandboxCondition struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Condition class, typically mirroring the underlying platform condition type. + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + // Condition status value such as `True`, `False`, or `Unknown`. + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` + // Short machine-readable reason associated with the condition. + Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` + // Human-readable condition message. + Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"` + // Timestamp reported by the underlying platform for the last transition. + LastTransitionTime string `protobuf:"bytes,5,opt,name=last_transition_time,json=lastTransitionTime,proto3" json:"last_transition_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxCondition) Reset() { + *x = SandboxCondition{} + mi := &file_openshell_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxCondition) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxCondition) ProtoMessage() {} + +func (x *SandboxCondition) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SandboxCondition.ProtoReflect.Descriptor instead. +func (*SandboxCondition) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{25} +} + +func (x *SandboxCondition) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *SandboxCondition) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *SandboxCondition) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +func (x *SandboxCondition) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *SandboxCondition) GetLastTransitionTime() string { + if x != nil { + return x.LastTransitionTime + } + return "" +} + +// Public platform event exposed on the sandbox watch stream. +type PlatformEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Event timestamp in milliseconds since epoch. + TimestampMs int64 `protobuf:"varint,1,opt,name=timestamp_ms,json=timestampMs,proto3" json:"timestamp_ms,omitempty"` + // Event source (e.g. "kubernetes", "docker", "process"). + Source string `protobuf:"bytes,2,opt,name=source,proto3" json:"source,omitempty"` + // Event type/severity (e.g. "Normal", "Warning"). + Type string `protobuf:"bytes,3,opt,name=type,proto3" json:"type,omitempty"` + // Short reason code (e.g. "Started", "Pulled", "Failed"). + Reason string `protobuf:"bytes,4,opt,name=reason,proto3" json:"reason,omitempty"` + // Human-readable event message. + Message string `protobuf:"bytes,5,opt,name=message,proto3" json:"message,omitempty"` + // Optional metadata as key-value pairs. + Metadata map[string]string `protobuf:"bytes,6,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PlatformEvent) Reset() { + *x = PlatformEvent{} + mi := &file_openshell_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PlatformEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PlatformEvent) ProtoMessage() {} + +func (x *PlatformEvent) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PlatformEvent.ProtoReflect.Descriptor instead. +func (*PlatformEvent) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{26} +} + +func (x *PlatformEvent) GetTimestampMs() int64 { + if x != nil { + return x.TimestampMs + } + return 0 +} + +func (x *PlatformEvent) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *PlatformEvent) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *PlatformEvent) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +func (x *PlatformEvent) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *PlatformEvent) GetMetadata() map[string]string { + if x != nil { + return x.Metadata + } + return nil +} + +// Create sandbox request. +type CreateSandboxRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Spec *SandboxSpec `protobuf:"bytes,1,opt,name=spec,proto3" json:"spec,omitempty"` + // Optional user-supplied sandbox name. When empty the server generates one. + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + // Optional labels for the sandbox (key-value metadata). + Labels map[string]string `protobuf:"bytes,3,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Optional annotations for the sandbox (non-selector metadata). + Annotations map[string]string `protobuf:"bytes,4,rep,name=annotations,proto3" json:"annotations,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Workspace for the sandbox. Empty defaults to "default". + Workspace string `protobuf:"bytes,5,opt,name=workspace,proto3" json:"workspace,omitempty"` + // One-shot launch hint indicating that the creating client will attach to + // the canonical main process. The supervisor keeps the terminal transport + // alive until that attachment connects and closes naturally. + AwaitMainProcessAttachment bool `protobuf:"varint,6,opt,name=await_main_process_attachment,json=awaitMainProcessAttachment,proto3" json:"await_main_process_attachment,omitempty"` + // Workspace-scoped SandboxWorkloadTemplate name to resolve at creation time. + WorkloadTemplateName string `protobuf:"bytes,7,opt,name=workload_template_name,json=workloadTemplateName,proto3" json:"workload_template_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateSandboxRequest) Reset() { + *x = CreateSandboxRequest{} + mi := &file_openshell_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateSandboxRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateSandboxRequest) ProtoMessage() {} + +func (x *CreateSandboxRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateSandboxRequest.ProtoReflect.Descriptor instead. +func (*CreateSandboxRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{27} +} + +func (x *CreateSandboxRequest) GetSpec() *SandboxSpec { + if x != nil { + return x.Spec + } + return nil +} + +func (x *CreateSandboxRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CreateSandboxRequest) GetLabels() map[string]string { + if x != nil { return x.Labels } return nil } -func (x *CreateSandboxRequest) GetAnnotations() map[string]string { +func (x *CreateSandboxRequest) GetAnnotations() map[string]string { + if x != nil { + return x.Annotations + } + return nil +} + +func (x *CreateSandboxRequest) GetWorkspace() string { + if x != nil { + return x.Workspace + } + return "" +} + +func (x *CreateSandboxRequest) GetAwaitMainProcessAttachment() bool { + if x != nil { + return x.AwaitMainProcessAttachment + } + return false +} + +func (x *CreateSandboxRequest) GetWorkloadTemplateName() string { + if x != nil { + return x.WorkloadTemplateName + } + return "" +} + +type CreateSandboxTemplateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Template *SandboxWorkloadTemplate `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"` + // Workspace for the template. Empty defaults to "default". + Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateSandboxTemplateRequest) Reset() { + *x = CreateSandboxTemplateRequest{} + mi := &file_openshell_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateSandboxTemplateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateSandboxTemplateRequest) ProtoMessage() {} + +func (x *CreateSandboxTemplateRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateSandboxTemplateRequest.ProtoReflect.Descriptor instead. +func (*CreateSandboxTemplateRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{28} +} + +func (x *CreateSandboxTemplateRequest) GetTemplate() *SandboxWorkloadTemplate { + if x != nil { + return x.Template + } + return nil +} + +func (x *CreateSandboxTemplateRequest) GetWorkspace() string { + if x != nil { + return x.Workspace + } + return "" +} + +type GetSandboxTemplateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Workspace scope. Empty defaults to "default". + Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSandboxTemplateRequest) Reset() { + *x = GetSandboxTemplateRequest{} + mi := &file_openshell_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSandboxTemplateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSandboxTemplateRequest) ProtoMessage() {} + +func (x *GetSandboxTemplateRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSandboxTemplateRequest.ProtoReflect.Descriptor instead. +func (*GetSandboxTemplateRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{29} +} + +func (x *GetSandboxTemplateRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *GetSandboxTemplateRequest) GetWorkspace() string { + if x != nil { + return x.Workspace + } + return "" +} + +type ListSandboxTemplatesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Limit uint32 `protobuf:"varint,1,opt,name=limit,proto3" json:"limit,omitempty"` + Offset uint32 `protobuf:"varint,2,opt,name=offset,proto3" json:"offset,omitempty"` + // Workspace scope. Empty defaults to "default". + Workspace string `protobuf:"bytes,3,opt,name=workspace,proto3" json:"workspace,omitempty"` + // List across all workspaces. Mutually exclusive with workspace. + AllWorkspaces bool `protobuf:"varint,4,opt,name=all_workspaces,json=allWorkspaces,proto3" json:"all_workspaces,omitempty"` + // Optional label selector in key=value comma-separated form. + LabelSelector string `protobuf:"bytes,5,opt,name=label_selector,json=labelSelector,proto3" json:"label_selector,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSandboxTemplatesRequest) Reset() { + *x = ListSandboxTemplatesRequest{} + mi := &file_openshell_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSandboxTemplatesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSandboxTemplatesRequest) ProtoMessage() {} + +func (x *ListSandboxTemplatesRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSandboxTemplatesRequest.ProtoReflect.Descriptor instead. +func (*ListSandboxTemplatesRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{30} +} + +func (x *ListSandboxTemplatesRequest) GetLimit() uint32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *ListSandboxTemplatesRequest) GetOffset() uint32 { + if x != nil { + return x.Offset + } + return 0 +} + +func (x *ListSandboxTemplatesRequest) GetWorkspace() string { + if x != nil { + return x.Workspace + } + return "" +} + +func (x *ListSandboxTemplatesRequest) GetAllWorkspaces() bool { + if x != nil { + return x.AllWorkspaces + } + return false +} + +func (x *ListSandboxTemplatesRequest) GetLabelSelector() string { + if x != nil { + return x.LabelSelector + } + return "" +} + +type DeleteSandboxTemplateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Workspace scope. Empty defaults to "default". + Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteSandboxTemplateRequest) Reset() { + *x = DeleteSandboxTemplateRequest{} + mi := &file_openshell_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteSandboxTemplateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteSandboxTemplateRequest) ProtoMessage() {} + +func (x *DeleteSandboxTemplateRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteSandboxTemplateRequest.ProtoReflect.Descriptor instead. +func (*DeleteSandboxTemplateRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{31} +} + +func (x *DeleteSandboxTemplateRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *DeleteSandboxTemplateRequest) GetWorkspace() string { + if x != nil { + return x.Workspace + } + return "" +} + +type SandboxTemplateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Template *SandboxWorkloadTemplate `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxTemplateResponse) Reset() { + *x = SandboxTemplateResponse{} + mi := &file_openshell_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxTemplateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxTemplateResponse) ProtoMessage() {} + +func (x *SandboxTemplateResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[32] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SandboxTemplateResponse.ProtoReflect.Descriptor instead. +func (*SandboxTemplateResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{32} +} + +func (x *SandboxTemplateResponse) GetTemplate() *SandboxWorkloadTemplate { + if x != nil { + return x.Template + } + return nil +} + +type ListSandboxTemplatesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Templates []*SandboxWorkloadTemplate `protobuf:"bytes,1,rep,name=templates,proto3" json:"templates,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSandboxTemplatesResponse) Reset() { + *x = ListSandboxTemplatesResponse{} + mi := &file_openshell_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSandboxTemplatesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSandboxTemplatesResponse) ProtoMessage() {} + +func (x *ListSandboxTemplatesResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[33] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSandboxTemplatesResponse.ProtoReflect.Descriptor instead. +func (*ListSandboxTemplatesResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{33} +} + +func (x *ListSandboxTemplatesResponse) GetTemplates() []*SandboxWorkloadTemplate { + if x != nil { + return x.Templates + } + return nil +} + +type DeleteSandboxTemplateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Deleted bool `protobuf:"varint,1,opt,name=deleted,proto3" json:"deleted,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteSandboxTemplateResponse) Reset() { + *x = DeleteSandboxTemplateResponse{} + mi := &file_openshell_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteSandboxTemplateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteSandboxTemplateResponse) ProtoMessage() {} + +func (x *DeleteSandboxTemplateResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[34] if x != nil { - return x.Annotations + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms } - return nil + return mi.MessageOf(x) } -func (x *CreateSandboxRequest) GetWorkspace() string { - if x != nil { - return x.Workspace - } - return "" +// Deprecated: Use DeleteSandboxTemplateResponse.ProtoReflect.Descriptor instead. +func (*DeleteSandboxTemplateResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{34} } -func (x *CreateSandboxRequest) GetAwaitMainProcessAttachment() bool { +func (x *DeleteSandboxTemplateResponse) GetDeleted() bool { if x != nil { - return x.AwaitMainProcessAttachment + return x.Deleted } return false } @@ -1941,7 +2734,7 @@ type GetSandboxRequest struct { func (x *GetSandboxRequest) Reset() { *x = GetSandboxRequest{} - mi := &file_openshell_proto_msgTypes[21] + mi := &file_openshell_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1953,7 +2746,7 @@ func (x *GetSandboxRequest) String() string { func (*GetSandboxRequest) ProtoMessage() {} func (x *GetSandboxRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[21] + mi := &file_openshell_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1966,7 +2759,7 @@ func (x *GetSandboxRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxRequest.ProtoReflect.Descriptor instead. func (*GetSandboxRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{21} + return file_openshell_proto_rawDescGZIP(), []int{35} } func (x *GetSandboxRequest) GetName() string { @@ -2000,7 +2793,7 @@ type ListSandboxesRequest struct { func (x *ListSandboxesRequest) Reset() { *x = ListSandboxesRequest{} - mi := &file_openshell_proto_msgTypes[22] + mi := &file_openshell_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2012,7 +2805,7 @@ func (x *ListSandboxesRequest) String() string { func (*ListSandboxesRequest) ProtoMessage() {} func (x *ListSandboxesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[22] + mi := &file_openshell_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2025,7 +2818,7 @@ func (x *ListSandboxesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSandboxesRequest.ProtoReflect.Descriptor instead. func (*ListSandboxesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{22} + return file_openshell_proto_rawDescGZIP(), []int{36} } func (x *ListSandboxesRequest) GetLimit() uint32 { @@ -2076,7 +2869,7 @@ type ListSandboxProvidersRequest struct { func (x *ListSandboxProvidersRequest) Reset() { *x = ListSandboxProvidersRequest{} - mi := &file_openshell_proto_msgTypes[23] + mi := &file_openshell_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2088,7 +2881,7 @@ func (x *ListSandboxProvidersRequest) String() string { func (*ListSandboxProvidersRequest) ProtoMessage() {} func (x *ListSandboxProvidersRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[23] + mi := &file_openshell_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2101,7 +2894,7 @@ func (x *ListSandboxProvidersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSandboxProvidersRequest.ProtoReflect.Descriptor instead. func (*ListSandboxProvidersRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{23} + return file_openshell_proto_rawDescGZIP(), []int{37} } func (x *ListSandboxProvidersRequest) GetSandboxName() string { @@ -2138,7 +2931,7 @@ type AttachSandboxProviderRequest struct { func (x *AttachSandboxProviderRequest) Reset() { *x = AttachSandboxProviderRequest{} - mi := &file_openshell_proto_msgTypes[24] + mi := &file_openshell_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2150,7 +2943,7 @@ func (x *AttachSandboxProviderRequest) String() string { func (*AttachSandboxProviderRequest) ProtoMessage() {} func (x *AttachSandboxProviderRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[24] + mi := &file_openshell_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2163,7 +2956,7 @@ func (x *AttachSandboxProviderRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AttachSandboxProviderRequest.ProtoReflect.Descriptor instead. func (*AttachSandboxProviderRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{24} + return file_openshell_proto_rawDescGZIP(), []int{38} } func (x *AttachSandboxProviderRequest) GetSandboxName() string { @@ -2214,7 +3007,7 @@ type DetachSandboxProviderRequest struct { func (x *DetachSandboxProviderRequest) Reset() { *x = DetachSandboxProviderRequest{} - mi := &file_openshell_proto_msgTypes[25] + mi := &file_openshell_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2226,7 +3019,7 @@ func (x *DetachSandboxProviderRequest) String() string { func (*DetachSandboxProviderRequest) ProtoMessage() {} func (x *DetachSandboxProviderRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[25] + mi := &file_openshell_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2239,7 +3032,7 @@ func (x *DetachSandboxProviderRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DetachSandboxProviderRequest.ProtoReflect.Descriptor instead. func (*DetachSandboxProviderRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{25} + return file_openshell_proto_rawDescGZIP(), []int{39} } func (x *DetachSandboxProviderRequest) GetSandboxName() string { @@ -2283,7 +3076,7 @@ type DeleteSandboxRequest struct { func (x *DeleteSandboxRequest) Reset() { *x = DeleteSandboxRequest{} - mi := &file_openshell_proto_msgTypes[26] + mi := &file_openshell_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2295,7 +3088,7 @@ func (x *DeleteSandboxRequest) String() string { func (*DeleteSandboxRequest) ProtoMessage() {} func (x *DeleteSandboxRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[26] + mi := &file_openshell_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2308,7 +3101,7 @@ func (x *DeleteSandboxRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteSandboxRequest.ProtoReflect.Descriptor instead. func (*DeleteSandboxRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{26} + return file_openshell_proto_rawDescGZIP(), []int{40} } func (x *DeleteSandboxRequest) GetName() string { @@ -2338,7 +3131,7 @@ type StopSandboxRequest struct { func (x *StopSandboxRequest) Reset() { *x = StopSandboxRequest{} - mi := &file_openshell_proto_msgTypes[27] + mi := &file_openshell_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2350,7 +3143,7 @@ func (x *StopSandboxRequest) String() string { func (*StopSandboxRequest) ProtoMessage() {} func (x *StopSandboxRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[27] + mi := &file_openshell_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2363,7 +3156,7 @@ func (x *StopSandboxRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopSandboxRequest.ProtoReflect.Descriptor instead. func (*StopSandboxRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{27} + return file_openshell_proto_rawDescGZIP(), []int{41} } func (x *StopSandboxRequest) GetName() string { @@ -2393,7 +3186,7 @@ type StartSandboxRequest struct { func (x *StartSandboxRequest) Reset() { *x = StartSandboxRequest{} - mi := &file_openshell_proto_msgTypes[28] + mi := &file_openshell_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2405,7 +3198,7 @@ func (x *StartSandboxRequest) String() string { func (*StartSandboxRequest) ProtoMessage() {} func (x *StartSandboxRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[28] + mi := &file_openshell_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2418,7 +3211,7 @@ func (x *StartSandboxRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartSandboxRequest.ProtoReflect.Descriptor instead. func (*StartSandboxRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{28} + return file_openshell_proto_rawDescGZIP(), []int{42} } func (x *StartSandboxRequest) GetName() string { @@ -2445,7 +3238,7 @@ type SandboxResponse struct { func (x *SandboxResponse) Reset() { *x = SandboxResponse{} - mi := &file_openshell_proto_msgTypes[29] + mi := &file_openshell_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2457,7 +3250,7 @@ func (x *SandboxResponse) String() string { func (*SandboxResponse) ProtoMessage() {} func (x *SandboxResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[29] + mi := &file_openshell_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2470,7 +3263,7 @@ func (x *SandboxResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxResponse.ProtoReflect.Descriptor instead. func (*SandboxResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{29} + return file_openshell_proto_rawDescGZIP(), []int{43} } func (x *SandboxResponse) GetSandbox() *Sandbox { @@ -2490,7 +3283,7 @@ type ListSandboxesResponse struct { func (x *ListSandboxesResponse) Reset() { *x = ListSandboxesResponse{} - mi := &file_openshell_proto_msgTypes[30] + mi := &file_openshell_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2502,7 +3295,7 @@ func (x *ListSandboxesResponse) String() string { func (*ListSandboxesResponse) ProtoMessage() {} func (x *ListSandboxesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[30] + mi := &file_openshell_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2515,7 +3308,7 @@ func (x *ListSandboxesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSandboxesResponse.ProtoReflect.Descriptor instead. func (*ListSandboxesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{30} + return file_openshell_proto_rawDescGZIP(), []int{44} } func (x *ListSandboxesResponse) GetSandboxes() []*Sandbox { @@ -2535,7 +3328,7 @@ type ListSandboxProvidersResponse struct { func (x *ListSandboxProvidersResponse) Reset() { *x = ListSandboxProvidersResponse{} - mi := &file_openshell_proto_msgTypes[31] + mi := &file_openshell_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2547,7 +3340,7 @@ func (x *ListSandboxProvidersResponse) String() string { func (*ListSandboxProvidersResponse) ProtoMessage() {} func (x *ListSandboxProvidersResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[31] + mi := &file_openshell_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2560,7 +3353,7 @@ func (x *ListSandboxProvidersResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSandboxProvidersResponse.ProtoReflect.Descriptor instead. func (*ListSandboxProvidersResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{31} + return file_openshell_proto_rawDescGZIP(), []int{45} } func (x *ListSandboxProvidersResponse) GetProviders() []*datamodelv1.Provider { @@ -2582,7 +3375,7 @@ type AttachSandboxProviderResponse struct { func (x *AttachSandboxProviderResponse) Reset() { *x = AttachSandboxProviderResponse{} - mi := &file_openshell_proto_msgTypes[32] + mi := &file_openshell_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2594,7 +3387,7 @@ func (x *AttachSandboxProviderResponse) String() string { func (*AttachSandboxProviderResponse) ProtoMessage() {} func (x *AttachSandboxProviderResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[32] + mi := &file_openshell_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2607,7 +3400,7 @@ func (x *AttachSandboxProviderResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AttachSandboxProviderResponse.ProtoReflect.Descriptor instead. func (*AttachSandboxProviderResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{32} + return file_openshell_proto_rawDescGZIP(), []int{46} } func (x *AttachSandboxProviderResponse) GetSandbox() *Sandbox { @@ -2636,7 +3429,7 @@ type DetachSandboxProviderResponse struct { func (x *DetachSandboxProviderResponse) Reset() { *x = DetachSandboxProviderResponse{} - mi := &file_openshell_proto_msgTypes[33] + mi := &file_openshell_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2648,7 +3441,7 @@ func (x *DetachSandboxProviderResponse) String() string { func (*DetachSandboxProviderResponse) ProtoMessage() {} func (x *DetachSandboxProviderResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[33] + mi := &file_openshell_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2661,7 +3454,7 @@ func (x *DetachSandboxProviderResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DetachSandboxProviderResponse.ProtoReflect.Descriptor instead. func (*DetachSandboxProviderResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{33} + return file_openshell_proto_rawDescGZIP(), []int{47} } func (x *DetachSandboxProviderResponse) GetSandbox() *Sandbox { @@ -2688,7 +3481,7 @@ type DeleteSandboxResponse struct { func (x *DeleteSandboxResponse) Reset() { *x = DeleteSandboxResponse{} - mi := &file_openshell_proto_msgTypes[34] + mi := &file_openshell_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2700,7 +3493,7 @@ func (x *DeleteSandboxResponse) String() string { func (*DeleteSandboxResponse) ProtoMessage() {} func (x *DeleteSandboxResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[34] + mi := &file_openshell_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2713,7 +3506,7 @@ func (x *DeleteSandboxResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteSandboxResponse.ProtoReflect.Descriptor instead. func (*DeleteSandboxResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{34} + return file_openshell_proto_rawDescGZIP(), []int{48} } func (x *DeleteSandboxResponse) GetDeleted() bool { @@ -2734,7 +3527,7 @@ type CreateSshSessionRequest struct { func (x *CreateSshSessionRequest) Reset() { *x = CreateSshSessionRequest{} - mi := &file_openshell_proto_msgTypes[35] + mi := &file_openshell_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2746,7 +3539,7 @@ func (x *CreateSshSessionRequest) String() string { func (*CreateSshSessionRequest) ProtoMessage() {} func (x *CreateSshSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[35] + mi := &file_openshell_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2759,7 +3552,7 @@ func (x *CreateSshSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSshSessionRequest.ProtoReflect.Descriptor instead. func (*CreateSshSessionRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{35} + return file_openshell_proto_rawDescGZIP(), []int{49} } func (x *CreateSshSessionRequest) GetSandboxId() string { @@ -2802,7 +3595,7 @@ type CreateSshSessionResponse struct { func (x *CreateSshSessionResponse) Reset() { *x = CreateSshSessionResponse{} - mi := &file_openshell_proto_msgTypes[36] + mi := &file_openshell_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2814,7 +3607,7 @@ func (x *CreateSshSessionResponse) String() string { func (*CreateSshSessionResponse) ProtoMessage() {} func (x *CreateSshSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[36] + mi := &file_openshell_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2827,7 +3620,7 @@ func (x *CreateSshSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSshSessionResponse.ProtoReflect.Descriptor instead. func (*CreateSshSessionResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{36} + return file_openshell_proto_rawDescGZIP(), []int{50} } func (x *CreateSshSessionResponse) GetSandboxId() string { @@ -2898,7 +3691,7 @@ type ExposeServiceRequest struct { func (x *ExposeServiceRequest) Reset() { *x = ExposeServiceRequest{} - mi := &file_openshell_proto_msgTypes[37] + mi := &file_openshell_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2910,7 +3703,7 @@ func (x *ExposeServiceRequest) String() string { func (*ExposeServiceRequest) ProtoMessage() {} func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[37] + mi := &file_openshell_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2923,7 +3716,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead. func (*ExposeServiceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{37} + return file_openshell_proto_rawDescGZIP(), []int{51} } func (x *ExposeServiceRequest) GetSandbox() string { @@ -2976,7 +3769,7 @@ type GetServiceRequest struct { func (x *GetServiceRequest) Reset() { *x = GetServiceRequest{} - mi := &file_openshell_proto_msgTypes[38] + mi := &file_openshell_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2988,7 +3781,7 @@ func (x *GetServiceRequest) String() string { func (*GetServiceRequest) ProtoMessage() {} func (x *GetServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[38] + mi := &file_openshell_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3001,7 +3794,7 @@ func (x *GetServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetServiceRequest.ProtoReflect.Descriptor instead. func (*GetServiceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{38} + return file_openshell_proto_rawDescGZIP(), []int{52} } func (x *GetServiceRequest) GetSandbox() string { @@ -3044,7 +3837,7 @@ type ListServicesRequest struct { func (x *ListServicesRequest) Reset() { *x = ListServicesRequest{} - mi := &file_openshell_proto_msgTypes[39] + mi := &file_openshell_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3056,7 +3849,7 @@ func (x *ListServicesRequest) String() string { func (*ListServicesRequest) ProtoMessage() {} func (x *ListServicesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[39] + mi := &file_openshell_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3069,7 +3862,7 @@ func (x *ListServicesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListServicesRequest.ProtoReflect.Descriptor instead. func (*ListServicesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{39} + return file_openshell_proto_rawDescGZIP(), []int{53} } func (x *ListServicesRequest) GetSandbox() string { @@ -3117,7 +3910,7 @@ type ListServicesResponse struct { func (x *ListServicesResponse) Reset() { *x = ListServicesResponse{} - mi := &file_openshell_proto_msgTypes[40] + mi := &file_openshell_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3129,7 +3922,7 @@ func (x *ListServicesResponse) String() string { func (*ListServicesResponse) ProtoMessage() {} func (x *ListServicesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[40] + mi := &file_openshell_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3142,7 +3935,7 @@ func (x *ListServicesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListServicesResponse.ProtoReflect.Descriptor instead. func (*ListServicesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{40} + return file_openshell_proto_rawDescGZIP(), []int{54} } func (x *ListServicesResponse) GetServices() []*ServiceEndpointResponse { @@ -3167,7 +3960,7 @@ type DeleteServiceRequest struct { func (x *DeleteServiceRequest) Reset() { *x = DeleteServiceRequest{} - mi := &file_openshell_proto_msgTypes[41] + mi := &file_openshell_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3179,7 +3972,7 @@ func (x *DeleteServiceRequest) String() string { func (*DeleteServiceRequest) ProtoMessage() {} func (x *DeleteServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[41] + mi := &file_openshell_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3192,7 +3985,7 @@ func (x *DeleteServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteServiceRequest.ProtoReflect.Descriptor instead. func (*DeleteServiceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{41} + return file_openshell_proto_rawDescGZIP(), []int{55} } func (x *DeleteServiceRequest) GetSandbox() string { @@ -3227,7 +4020,7 @@ type DeleteServiceResponse struct { func (x *DeleteServiceResponse) Reset() { *x = DeleteServiceResponse{} - mi := &file_openshell_proto_msgTypes[42] + mi := &file_openshell_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3239,7 +4032,7 @@ func (x *DeleteServiceResponse) String() string { func (*DeleteServiceResponse) ProtoMessage() {} func (x *DeleteServiceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[42] + mi := &file_openshell_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3252,7 +4045,7 @@ func (x *DeleteServiceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteServiceResponse.ProtoReflect.Descriptor instead. func (*DeleteServiceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{42} + return file_openshell_proto_rawDescGZIP(), []int{56} } func (x *DeleteServiceResponse) GetDeleted() bool { @@ -3283,7 +4076,7 @@ type ServiceEndpoint struct { func (x *ServiceEndpoint) Reset() { *x = ServiceEndpoint{} - mi := &file_openshell_proto_msgTypes[43] + mi := &file_openshell_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3295,7 +4088,7 @@ func (x *ServiceEndpoint) String() string { func (*ServiceEndpoint) ProtoMessage() {} func (x *ServiceEndpoint) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[43] + mi := &file_openshell_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3308,7 +4101,7 @@ func (x *ServiceEndpoint) ProtoReflect() protoreflect.Message { // Deprecated: Use ServiceEndpoint.ProtoReflect.Descriptor instead. func (*ServiceEndpoint) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{43} + return file_openshell_proto_rawDescGZIP(), []int{57} } func (x *ServiceEndpoint) GetMetadata() *datamodelv1.ObjectMeta { @@ -3364,7 +4157,7 @@ type ServiceEndpointResponse struct { func (x *ServiceEndpointResponse) Reset() { *x = ServiceEndpointResponse{} - mi := &file_openshell_proto_msgTypes[44] + mi := &file_openshell_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3376,7 +4169,7 @@ func (x *ServiceEndpointResponse) String() string { func (*ServiceEndpointResponse) ProtoMessage() {} func (x *ServiceEndpointResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[44] + mi := &file_openshell_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3389,7 +4182,7 @@ func (x *ServiceEndpointResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ServiceEndpointResponse.ProtoReflect.Descriptor instead. func (*ServiceEndpointResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{44} + return file_openshell_proto_rawDescGZIP(), []int{58} } func (x *ServiceEndpointResponse) GetEndpoint() *ServiceEndpoint { @@ -3417,7 +4210,7 @@ type RevokeSshSessionRequest struct { func (x *RevokeSshSessionRequest) Reset() { *x = RevokeSshSessionRequest{} - mi := &file_openshell_proto_msgTypes[45] + mi := &file_openshell_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3429,7 +4222,7 @@ func (x *RevokeSshSessionRequest) String() string { func (*RevokeSshSessionRequest) ProtoMessage() {} func (x *RevokeSshSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[45] + mi := &file_openshell_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3442,7 +4235,7 @@ func (x *RevokeSshSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeSshSessionRequest.ProtoReflect.Descriptor instead. func (*RevokeSshSessionRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{45} + return file_openshell_proto_rawDescGZIP(), []int{59} } func (x *RevokeSshSessionRequest) GetToken() string { @@ -3463,7 +4256,7 @@ type RevokeSshSessionResponse struct { func (x *RevokeSshSessionResponse) Reset() { *x = RevokeSshSessionResponse{} - mi := &file_openshell_proto_msgTypes[46] + mi := &file_openshell_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3475,7 +4268,7 @@ func (x *RevokeSshSessionResponse) String() string { func (*RevokeSshSessionResponse) ProtoMessage() {} func (x *RevokeSshSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[46] + mi := &file_openshell_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3488,7 +4281,7 @@ func (x *RevokeSshSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeSshSessionResponse.ProtoReflect.Descriptor instead. func (*RevokeSshSessionResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{46} + return file_openshell_proto_rawDescGZIP(), []int{60} } func (x *RevokeSshSessionResponse) GetRevoked() bool { @@ -3531,7 +4324,7 @@ type ExecSandboxRequest struct { func (x *ExecSandboxRequest) Reset() { *x = ExecSandboxRequest{} - mi := &file_openshell_proto_msgTypes[47] + mi := &file_openshell_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3543,7 +4336,7 @@ func (x *ExecSandboxRequest) String() string { func (*ExecSandboxRequest) ProtoMessage() {} func (x *ExecSandboxRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[47] + mi := &file_openshell_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3556,7 +4349,7 @@ func (x *ExecSandboxRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecSandboxRequest.ProtoReflect.Descriptor instead. func (*ExecSandboxRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{47} + return file_openshell_proto_rawDescGZIP(), []int{61} } func (x *ExecSandboxRequest) GetSandboxId() string { @@ -3639,7 +4432,7 @@ type ExecSandboxStdout struct { func (x *ExecSandboxStdout) Reset() { *x = ExecSandboxStdout{} - mi := &file_openshell_proto_msgTypes[48] + mi := &file_openshell_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3651,7 +4444,7 @@ func (x *ExecSandboxStdout) String() string { func (*ExecSandboxStdout) ProtoMessage() {} func (x *ExecSandboxStdout) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[48] + mi := &file_openshell_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3664,7 +4457,7 @@ func (x *ExecSandboxStdout) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecSandboxStdout.ProtoReflect.Descriptor instead. func (*ExecSandboxStdout) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{48} + return file_openshell_proto_rawDescGZIP(), []int{62} } func (x *ExecSandboxStdout) GetData() []byte { @@ -3684,7 +4477,7 @@ type ExecSandboxStderr struct { func (x *ExecSandboxStderr) Reset() { *x = ExecSandboxStderr{} - mi := &file_openshell_proto_msgTypes[49] + mi := &file_openshell_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3696,7 +4489,7 @@ func (x *ExecSandboxStderr) String() string { func (*ExecSandboxStderr) ProtoMessage() {} func (x *ExecSandboxStderr) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[49] + mi := &file_openshell_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3709,7 +4502,7 @@ func (x *ExecSandboxStderr) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecSandboxStderr.ProtoReflect.Descriptor instead. func (*ExecSandboxStderr) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{49} + return file_openshell_proto_rawDescGZIP(), []int{63} } func (x *ExecSandboxStderr) GetData() []byte { @@ -3729,7 +4522,7 @@ type ExecSandboxExit struct { func (x *ExecSandboxExit) Reset() { *x = ExecSandboxExit{} - mi := &file_openshell_proto_msgTypes[50] + mi := &file_openshell_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3741,7 +4534,7 @@ func (x *ExecSandboxExit) String() string { func (*ExecSandboxExit) ProtoMessage() {} func (x *ExecSandboxExit) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[50] + mi := &file_openshell_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3754,7 +4547,7 @@ func (x *ExecSandboxExit) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecSandboxExit.ProtoReflect.Descriptor instead. func (*ExecSandboxExit) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{50} + return file_openshell_proto_rawDescGZIP(), []int{64} } func (x *ExecSandboxExit) GetExitCode() int32 { @@ -3779,7 +4572,7 @@ type ExecSandboxEvent struct { func (x *ExecSandboxEvent) Reset() { *x = ExecSandboxEvent{} - mi := &file_openshell_proto_msgTypes[51] + mi := &file_openshell_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3791,7 +4584,7 @@ func (x *ExecSandboxEvent) String() string { func (*ExecSandboxEvent) ProtoMessage() {} func (x *ExecSandboxEvent) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[51] + mi := &file_openshell_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3804,7 +4597,7 @@ func (x *ExecSandboxEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecSandboxEvent.ProtoReflect.Descriptor instead. func (*ExecSandboxEvent) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{51} + return file_openshell_proto_rawDescGZIP(), []int{65} } func (x *ExecSandboxEvent) GetPayload() isExecSandboxEvent_Payload { @@ -3886,7 +4679,7 @@ type TcpForwardInit struct { func (x *TcpForwardInit) Reset() { *x = TcpForwardInit{} - mi := &file_openshell_proto_msgTypes[52] + mi := &file_openshell_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3898,7 +4691,7 @@ func (x *TcpForwardInit) String() string { func (*TcpForwardInit) ProtoMessage() {} func (x *TcpForwardInit) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[52] + mi := &file_openshell_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3911,7 +4704,7 @@ func (x *TcpForwardInit) ProtoReflect() protoreflect.Message { // Deprecated: Use TcpForwardInit.ProtoReflect.Descriptor instead. func (*TcpForwardInit) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{52} + return file_openshell_proto_rawDescGZIP(), []int{66} } func (x *TcpForwardInit) GetSandboxId() string { @@ -3990,7 +4783,7 @@ type TcpForwardFrame struct { func (x *TcpForwardFrame) Reset() { *x = TcpForwardFrame{} - mi := &file_openshell_proto_msgTypes[53] + mi := &file_openshell_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4002,7 +4795,7 @@ func (x *TcpForwardFrame) String() string { func (*TcpForwardFrame) ProtoMessage() {} func (x *TcpForwardFrame) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[53] + mi := &file_openshell_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4015,7 +4808,7 @@ func (x *TcpForwardFrame) ProtoReflect() protoreflect.Message { // Deprecated: Use TcpForwardFrame.ProtoReflect.Descriptor instead. func (*TcpForwardFrame) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{53} + return file_openshell_proto_rawDescGZIP(), []int{67} } func (x *TcpForwardFrame) GetPayload() isTcpForwardFrame_Payload { @@ -4074,7 +4867,7 @@ type ExecSandboxInput struct { func (x *ExecSandboxInput) Reset() { *x = ExecSandboxInput{} - mi := &file_openshell_proto_msgTypes[54] + mi := &file_openshell_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4086,7 +4879,7 @@ func (x *ExecSandboxInput) String() string { func (*ExecSandboxInput) ProtoMessage() {} func (x *ExecSandboxInput) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[54] + mi := &file_openshell_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4099,7 +4892,7 @@ func (x *ExecSandboxInput) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecSandboxInput.ProtoReflect.Descriptor instead. func (*ExecSandboxInput) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{54} + return file_openshell_proto_rawDescGZIP(), []int{68} } func (x *ExecSandboxInput) GetPayload() isExecSandboxInput_Payload { @@ -4172,7 +4965,7 @@ type ExecSandboxWindowResize struct { func (x *ExecSandboxWindowResize) Reset() { *x = ExecSandboxWindowResize{} - mi := &file_openshell_proto_msgTypes[55] + mi := &file_openshell_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4184,7 +4977,7 @@ func (x *ExecSandboxWindowResize) String() string { func (*ExecSandboxWindowResize) ProtoMessage() {} func (x *ExecSandboxWindowResize) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[55] + mi := &file_openshell_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4197,7 +4990,7 @@ func (x *ExecSandboxWindowResize) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecSandboxWindowResize.ProtoReflect.Descriptor instead. func (*ExecSandboxWindowResize) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{55} + return file_openshell_proto_rawDescGZIP(), []int{69} } func (x *ExecSandboxWindowResize) GetCols() uint32 { @@ -4234,7 +5027,7 @@ type SshSession struct { func (x *SshSession) Reset() { *x = SshSession{} - mi := &file_openshell_proto_msgTypes[56] + mi := &file_openshell_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4246,7 +5039,7 @@ func (x *SshSession) String() string { func (*SshSession) ProtoMessage() {} func (x *SshSession) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[56] + mi := &file_openshell_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4259,7 +5052,7 @@ func (x *SshSession) ProtoReflect() protoreflect.Message { // Deprecated: Use SshSession.ProtoReflect.Descriptor instead. func (*SshSession) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{56} + return file_openshell_proto_rawDescGZIP(), []int{70} } func (x *SshSession) GetMetadata() *datamodelv1.ObjectMeta { @@ -4328,7 +5121,7 @@ type WatchSandboxRequest struct { func (x *WatchSandboxRequest) Reset() { *x = WatchSandboxRequest{} - mi := &file_openshell_proto_msgTypes[57] + mi := &file_openshell_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4340,7 +5133,7 @@ func (x *WatchSandboxRequest) String() string { func (*WatchSandboxRequest) ProtoMessage() {} func (x *WatchSandboxRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[57] + mi := &file_openshell_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4353,7 +5146,7 @@ func (x *WatchSandboxRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchSandboxRequest.ProtoReflect.Descriptor instead. func (*WatchSandboxRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{57} + return file_openshell_proto_rawDescGZIP(), []int{71} } func (x *WatchSandboxRequest) GetId() string { @@ -4443,7 +5236,7 @@ type SandboxStreamEvent struct { func (x *SandboxStreamEvent) Reset() { *x = SandboxStreamEvent{} - mi := &file_openshell_proto_msgTypes[58] + mi := &file_openshell_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4455,7 +5248,7 @@ func (x *SandboxStreamEvent) String() string { func (*SandboxStreamEvent) ProtoMessage() {} func (x *SandboxStreamEvent) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[58] + mi := &file_openshell_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4468,7 +5261,7 @@ func (x *SandboxStreamEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxStreamEvent.ProtoReflect.Descriptor instead. func (*SandboxStreamEvent) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{58} + return file_openshell_proto_rawDescGZIP(), []int{72} } func (x *SandboxStreamEvent) GetPayload() isSandboxStreamEvent_Payload { @@ -4581,7 +5374,7 @@ type SandboxLogLine struct { func (x *SandboxLogLine) Reset() { *x = SandboxLogLine{} - mi := &file_openshell_proto_msgTypes[59] + mi := &file_openshell_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4593,7 +5386,7 @@ func (x *SandboxLogLine) String() string { func (*SandboxLogLine) ProtoMessage() {} func (x *SandboxLogLine) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[59] + mi := &file_openshell_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4606,7 +5399,7 @@ func (x *SandboxLogLine) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxLogLine.ProtoReflect.Descriptor instead. func (*SandboxLogLine) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{59} + return file_openshell_proto_rawDescGZIP(), []int{73} } func (x *SandboxLogLine) GetSandboxId() string { @@ -4667,7 +5460,7 @@ type SandboxStreamWarning struct { func (x *SandboxStreamWarning) Reset() { *x = SandboxStreamWarning{} - mi := &file_openshell_proto_msgTypes[60] + mi := &file_openshell_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4679,7 +5472,7 @@ func (x *SandboxStreamWarning) String() string { func (*SandboxStreamWarning) ProtoMessage() {} func (x *SandboxStreamWarning) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[60] + mi := &file_openshell_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4692,7 +5485,7 @@ func (x *SandboxStreamWarning) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxStreamWarning.ProtoReflect.Descriptor instead. func (*SandboxStreamWarning) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{60} + return file_openshell_proto_rawDescGZIP(), []int{74} } func (x *SandboxStreamWarning) GetMessage() string { @@ -4714,7 +5507,7 @@ type CreateProviderRequest struct { func (x *CreateProviderRequest) Reset() { *x = CreateProviderRequest{} - mi := &file_openshell_proto_msgTypes[61] + mi := &file_openshell_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4726,7 +5519,7 @@ func (x *CreateProviderRequest) String() string { func (*CreateProviderRequest) ProtoMessage() {} func (x *CreateProviderRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[61] + mi := &file_openshell_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4739,7 +5532,7 @@ func (x *CreateProviderRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateProviderRequest.ProtoReflect.Descriptor instead. func (*CreateProviderRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{61} + return file_openshell_proto_rawDescGZIP(), []int{75} } func (x *CreateProviderRequest) GetProvider() *datamodelv1.Provider { @@ -4768,7 +5561,7 @@ type GetProviderRequest struct { func (x *GetProviderRequest) Reset() { *x = GetProviderRequest{} - mi := &file_openshell_proto_msgTypes[62] + mi := &file_openshell_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4780,7 +5573,7 @@ func (x *GetProviderRequest) String() string { func (*GetProviderRequest) ProtoMessage() {} func (x *GetProviderRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[62] + mi := &file_openshell_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4793,7 +5586,7 @@ func (x *GetProviderRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetProviderRequest.ProtoReflect.Descriptor instead. func (*GetProviderRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{62} + return file_openshell_proto_rawDescGZIP(), []int{76} } func (x *GetProviderRequest) GetName() string { @@ -4825,7 +5618,7 @@ type ListProvidersRequest struct { func (x *ListProvidersRequest) Reset() { *x = ListProvidersRequest{} - mi := &file_openshell_proto_msgTypes[63] + mi := &file_openshell_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4837,7 +5630,7 @@ func (x *ListProvidersRequest) String() string { func (*ListProvidersRequest) ProtoMessage() {} func (x *ListProvidersRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[63] + mi := &file_openshell_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4850,7 +5643,7 @@ func (x *ListProvidersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProvidersRequest.ProtoReflect.Descriptor instead. func (*ListProvidersRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{63} + return file_openshell_proto_rawDescGZIP(), []int{77} } func (x *ListProvidersRequest) GetLimit() uint32 { @@ -4896,7 +5689,7 @@ type UpdateProviderRequest struct { func (x *UpdateProviderRequest) Reset() { *x = UpdateProviderRequest{} - mi := &file_openshell_proto_msgTypes[64] + mi := &file_openshell_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4908,7 +5701,7 @@ func (x *UpdateProviderRequest) String() string { func (*UpdateProviderRequest) ProtoMessage() {} func (x *UpdateProviderRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[64] + mi := &file_openshell_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4921,7 +5714,7 @@ func (x *UpdateProviderRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateProviderRequest.ProtoReflect.Descriptor instead. func (*UpdateProviderRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{64} + return file_openshell_proto_rawDescGZIP(), []int{78} } func (x *UpdateProviderRequest) GetProvider() *datamodelv1.Provider { @@ -4957,7 +5750,7 @@ type DeleteProviderRequest struct { func (x *DeleteProviderRequest) Reset() { *x = DeleteProviderRequest{} - mi := &file_openshell_proto_msgTypes[65] + mi := &file_openshell_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4969,7 +5762,7 @@ func (x *DeleteProviderRequest) String() string { func (*DeleteProviderRequest) ProtoMessage() {} func (x *DeleteProviderRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[65] + mi := &file_openshell_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4982,7 +5775,7 @@ func (x *DeleteProviderRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteProviderRequest.ProtoReflect.Descriptor instead. func (*DeleteProviderRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{65} + return file_openshell_proto_rawDescGZIP(), []int{79} } func (x *DeleteProviderRequest) GetName() string { @@ -5009,7 +5802,7 @@ type ProviderResponse struct { func (x *ProviderResponse) Reset() { *x = ProviderResponse{} - mi := &file_openshell_proto_msgTypes[66] + mi := &file_openshell_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5021,7 +5814,7 @@ func (x *ProviderResponse) String() string { func (*ProviderResponse) ProtoMessage() {} func (x *ProviderResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[66] + mi := &file_openshell_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5034,7 +5827,7 @@ func (x *ProviderResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderResponse.ProtoReflect.Descriptor instead. func (*ProviderResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{66} + return file_openshell_proto_rawDescGZIP(), []int{80} } func (x *ProviderResponse) GetProvider() *datamodelv1.Provider { @@ -5054,7 +5847,7 @@ type ListProvidersResponse struct { func (x *ListProvidersResponse) Reset() { *x = ListProvidersResponse{} - mi := &file_openshell_proto_msgTypes[67] + mi := &file_openshell_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5066,7 +5859,7 @@ func (x *ListProvidersResponse) String() string { func (*ListProvidersResponse) ProtoMessage() {} func (x *ListProvidersResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[67] + mi := &file_openshell_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5079,7 +5872,7 @@ func (x *ListProvidersResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProvidersResponse.ProtoReflect.Descriptor instead. func (*ListProvidersResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{67} + return file_openshell_proto_rawDescGZIP(), []int{81} } func (x *ListProvidersResponse) GetProviders() []*datamodelv1.Provider { @@ -5103,7 +5896,7 @@ type ListProviderProfilesRequest struct { func (x *ListProviderProfilesRequest) Reset() { *x = ListProviderProfilesRequest{} - mi := &file_openshell_proto_msgTypes[68] + mi := &file_openshell_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5115,7 +5908,7 @@ func (x *ListProviderProfilesRequest) String() string { func (*ListProviderProfilesRequest) ProtoMessage() {} func (x *ListProviderProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[68] + mi := &file_openshell_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5128,7 +5921,7 @@ func (x *ListProviderProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProviderProfilesRequest.ProtoReflect.Descriptor instead. func (*ListProviderProfilesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{68} + return file_openshell_proto_rawDescGZIP(), []int{82} } func (x *ListProviderProfilesRequest) GetLimit() uint32 { @@ -5166,7 +5959,7 @@ type GetProviderProfileRequest struct { func (x *GetProviderProfileRequest) Reset() { *x = GetProviderProfileRequest{} - mi := &file_openshell_proto_msgTypes[69] + mi := &file_openshell_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5178,7 +5971,7 @@ func (x *GetProviderProfileRequest) String() string { func (*GetProviderProfileRequest) ProtoMessage() {} func (x *GetProviderProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[69] + mi := &file_openshell_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5191,7 +5984,7 @@ func (x *GetProviderProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetProviderProfileRequest.ProtoReflect.Descriptor instead. func (*GetProviderProfileRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{69} + return file_openshell_proto_rawDescGZIP(), []int{83} } func (x *GetProviderProfileRequest) GetId() string { @@ -5219,7 +6012,7 @@ type ProviderProfileImportItem struct { func (x *ProviderProfileImportItem) Reset() { *x = ProviderProfileImportItem{} - mi := &file_openshell_proto_msgTypes[70] + mi := &file_openshell_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5231,7 +6024,7 @@ func (x *ProviderProfileImportItem) String() string { func (*ProviderProfileImportItem) ProtoMessage() {} func (x *ProviderProfileImportItem) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[70] + mi := &file_openshell_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5244,7 +6037,7 @@ func (x *ProviderProfileImportItem) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderProfileImportItem.ProtoReflect.Descriptor instead. func (*ProviderProfileImportItem) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{70} + return file_openshell_proto_rawDescGZIP(), []int{84} } func (x *ProviderProfileImportItem) GetProfile() *ProviderProfile { @@ -5275,7 +6068,7 @@ type ProviderProfileDiagnostic struct { func (x *ProviderProfileDiagnostic) Reset() { *x = ProviderProfileDiagnostic{} - mi := &file_openshell_proto_msgTypes[71] + mi := &file_openshell_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5287,7 +6080,7 @@ func (x *ProviderProfileDiagnostic) String() string { func (*ProviderProfileDiagnostic) ProtoMessage() {} func (x *ProviderProfileDiagnostic) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[71] + mi := &file_openshell_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5300,7 +6093,7 @@ func (x *ProviderProfileDiagnostic) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderProfileDiagnostic.ProtoReflect.Descriptor instead. func (*ProviderProfileDiagnostic) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{71} + return file_openshell_proto_rawDescGZIP(), []int{85} } func (x *ProviderProfileDiagnostic) GetSource() string { @@ -5357,7 +6150,7 @@ type ProviderCredentialTokenGrantAudienceOverride struct { func (x *ProviderCredentialTokenGrantAudienceOverride) Reset() { *x = ProviderCredentialTokenGrantAudienceOverride{} - mi := &file_openshell_proto_msgTypes[72] + mi := &file_openshell_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5369,7 +6162,7 @@ func (x *ProviderCredentialTokenGrantAudienceOverride) String() string { func (*ProviderCredentialTokenGrantAudienceOverride) ProtoMessage() {} func (x *ProviderCredentialTokenGrantAudienceOverride) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[72] + mi := &file_openshell_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5382,7 +6175,7 @@ func (x *ProviderCredentialTokenGrantAudienceOverride) ProtoReflect() protorefle // Deprecated: Use ProviderCredentialTokenGrantAudienceOverride.ProtoReflect.Descriptor instead. func (*ProviderCredentialTokenGrantAudienceOverride) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{72} + return file_openshell_proto_rawDescGZIP(), []int{86} } func (x *ProviderCredentialTokenGrantAudienceOverride) GetHost() string { @@ -5436,7 +6229,7 @@ type ProviderCredentialTokenGrantSubjectToken struct { func (x *ProviderCredentialTokenGrantSubjectToken) Reset() { *x = ProviderCredentialTokenGrantSubjectToken{} - mi := &file_openshell_proto_msgTypes[73] + mi := &file_openshell_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5448,7 +6241,7 @@ func (x *ProviderCredentialTokenGrantSubjectToken) String() string { func (*ProviderCredentialTokenGrantSubjectToken) ProtoMessage() {} func (x *ProviderCredentialTokenGrantSubjectToken) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[73] + mi := &file_openshell_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5461,7 +6254,7 @@ func (x *ProviderCredentialTokenGrantSubjectToken) ProtoReflect() protoreflect.M // Deprecated: Use ProviderCredentialTokenGrantSubjectToken.ProtoReflect.Descriptor instead. func (*ProviderCredentialTokenGrantSubjectToken) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{73} + return file_openshell_proto_rawDescGZIP(), []int{87} } func (x *ProviderCredentialTokenGrantSubjectToken) GetSource() string { @@ -5518,7 +6311,7 @@ type ProviderCredentialTokenGrant struct { func (x *ProviderCredentialTokenGrant) Reset() { *x = ProviderCredentialTokenGrant{} - mi := &file_openshell_proto_msgTypes[74] + mi := &file_openshell_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5530,7 +6323,7 @@ func (x *ProviderCredentialTokenGrant) String() string { func (*ProviderCredentialTokenGrant) ProtoMessage() {} func (x *ProviderCredentialTokenGrant) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[74] + mi := &file_openshell_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5543,7 +6336,7 @@ func (x *ProviderCredentialTokenGrant) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderCredentialTokenGrant.ProtoReflect.Descriptor instead. func (*ProviderCredentialTokenGrant) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{74} + return file_openshell_proto_rawDescGZIP(), []int{88} } func (x *ProviderCredentialTokenGrant) GetTokenEndpoint() string { @@ -5635,7 +6428,7 @@ type ProviderProfileCredential struct { func (x *ProviderProfileCredential) Reset() { *x = ProviderProfileCredential{} - mi := &file_openshell_proto_msgTypes[75] + mi := &file_openshell_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5647,7 +6440,7 @@ func (x *ProviderProfileCredential) String() string { func (*ProviderProfileCredential) ProtoMessage() {} func (x *ProviderProfileCredential) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[75] + mi := &file_openshell_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5660,7 +6453,7 @@ func (x *ProviderProfileCredential) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderProfileCredential.ProtoReflect.Descriptor instead. func (*ProviderProfileCredential) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{75} + return file_openshell_proto_rawDescGZIP(), []int{89} } func (x *ProviderProfileCredential) GetName() string { @@ -5745,7 +6538,7 @@ type ProviderCredentialRefreshMaterial struct { func (x *ProviderCredentialRefreshMaterial) Reset() { *x = ProviderCredentialRefreshMaterial{} - mi := &file_openshell_proto_msgTypes[76] + mi := &file_openshell_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5757,7 +6550,7 @@ func (x *ProviderCredentialRefreshMaterial) String() string { func (*ProviderCredentialRefreshMaterial) ProtoMessage() {} func (x *ProviderCredentialRefreshMaterial) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[76] + mi := &file_openshell_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5770,7 +6563,7 @@ func (x *ProviderCredentialRefreshMaterial) ProtoReflect() protoreflect.Message // Deprecated: Use ProviderCredentialRefreshMaterial.ProtoReflect.Descriptor instead. func (*ProviderCredentialRefreshMaterial) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{76} + return file_openshell_proto_rawDescGZIP(), []int{90} } func (x *ProviderCredentialRefreshMaterial) GetName() string { @@ -5815,7 +6608,7 @@ type ProviderCredentialRefreshOutput struct { func (x *ProviderCredentialRefreshOutput) Reset() { *x = ProviderCredentialRefreshOutput{} - mi := &file_openshell_proto_msgTypes[77] + mi := &file_openshell_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5827,7 +6620,7 @@ func (x *ProviderCredentialRefreshOutput) String() string { func (*ProviderCredentialRefreshOutput) ProtoMessage() {} func (x *ProviderCredentialRefreshOutput) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[77] + mi := &file_openshell_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5840,7 +6633,7 @@ func (x *ProviderCredentialRefreshOutput) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderCredentialRefreshOutput.ProtoReflect.Descriptor instead. func (*ProviderCredentialRefreshOutput) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{77} + return file_openshell_proto_rawDescGZIP(), []int{91} } func (x *ProviderCredentialRefreshOutput) GetOutput() string { @@ -5872,7 +6665,7 @@ type ProviderCredentialRefresh struct { func (x *ProviderCredentialRefresh) Reset() { *x = ProviderCredentialRefresh{} - mi := &file_openshell_proto_msgTypes[78] + mi := &file_openshell_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5884,7 +6677,7 @@ func (x *ProviderCredentialRefresh) String() string { func (*ProviderCredentialRefresh) ProtoMessage() {} func (x *ProviderCredentialRefresh) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[78] + mi := &file_openshell_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5897,7 +6690,7 @@ func (x *ProviderCredentialRefresh) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderCredentialRefresh.ProtoReflect.Descriptor instead. func (*ProviderCredentialRefresh) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{78} + return file_openshell_proto_rawDescGZIP(), []int{92} } func (x *ProviderCredentialRefresh) GetStrategy() ProviderCredentialRefreshStrategy { @@ -5980,7 +6773,7 @@ type ProviderCredentialRefreshStatus struct { func (x *ProviderCredentialRefreshStatus) Reset() { *x = ProviderCredentialRefreshStatus{} - mi := &file_openshell_proto_msgTypes[79] + mi := &file_openshell_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5992,7 +6785,7 @@ func (x *ProviderCredentialRefreshStatus) String() string { func (*ProviderCredentialRefreshStatus) ProtoMessage() {} func (x *ProviderCredentialRefreshStatus) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[79] + mi := &file_openshell_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6005,7 +6798,7 @@ func (x *ProviderCredentialRefreshStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderCredentialRefreshStatus.ProtoReflect.Descriptor instead. func (*ProviderCredentialRefreshStatus) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{79} + return file_openshell_proto_rawDescGZIP(), []int{93} } func (x *ProviderCredentialRefreshStatus) GetProviderName() string { @@ -6110,7 +6903,7 @@ type ProviderProfileDiscovery struct { func (x *ProviderProfileDiscovery) Reset() { *x = ProviderProfileDiscovery{} - mi := &file_openshell_proto_msgTypes[80] + mi := &file_openshell_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6122,7 +6915,7 @@ func (x *ProviderProfileDiscovery) String() string { func (*ProviderProfileDiscovery) ProtoMessage() {} func (x *ProviderProfileDiscovery) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[80] + mi := &file_openshell_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6135,7 +6928,7 @@ func (x *ProviderProfileDiscovery) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderProfileDiscovery.ProtoReflect.Descriptor instead. func (*ProviderProfileDiscovery) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{80} + return file_openshell_proto_rawDescGZIP(), []int{94} } func (x *ProviderProfileDiscovery) GetCredentials() []string { @@ -6199,7 +6992,7 @@ type StoredProviderCredentialRefreshState struct { func (x *StoredProviderCredentialRefreshState) Reset() { *x = StoredProviderCredentialRefreshState{} - mi := &file_openshell_proto_msgTypes[81] + mi := &file_openshell_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6211,7 +7004,7 @@ func (x *StoredProviderCredentialRefreshState) String() string { func (*StoredProviderCredentialRefreshState) ProtoMessage() {} func (x *StoredProviderCredentialRefreshState) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[81] + mi := &file_openshell_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6224,7 +7017,7 @@ func (x *StoredProviderCredentialRefreshState) ProtoReflect() protoreflect.Messa // Deprecated: Use StoredProviderCredentialRefreshState.ProtoReflect.Descriptor instead. func (*StoredProviderCredentialRefreshState) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{81} + return file_openshell_proto_rawDescGZIP(), []int{95} } func (x *StoredProviderCredentialRefreshState) GetMetadata() *datamodelv1.ObjectMeta { @@ -6407,7 +7200,7 @@ type StoredRefreshMaterialDeletion struct { func (x *StoredRefreshMaterialDeletion) Reset() { *x = StoredRefreshMaterialDeletion{} - mi := &file_openshell_proto_msgTypes[82] + mi := &file_openshell_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6419,7 +7212,7 @@ func (x *StoredRefreshMaterialDeletion) String() string { func (*StoredRefreshMaterialDeletion) ProtoMessage() {} func (x *StoredRefreshMaterialDeletion) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[82] + mi := &file_openshell_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6432,7 +7225,7 @@ func (x *StoredRefreshMaterialDeletion) ProtoReflect() protoreflect.Message { // Deprecated: Use StoredRefreshMaterialDeletion.ProtoReflect.Descriptor instead. func (*StoredRefreshMaterialDeletion) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{82} + return file_openshell_proto_rawDescGZIP(), []int{96} } func (x *StoredRefreshMaterialDeletion) GetMaterialKey() string { @@ -6461,7 +7254,7 @@ type GetProviderRefreshStatusRequest struct { func (x *GetProviderRefreshStatusRequest) Reset() { *x = GetProviderRefreshStatusRequest{} - mi := &file_openshell_proto_msgTypes[83] + mi := &file_openshell_proto_msgTypes[97] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6473,7 +7266,7 @@ func (x *GetProviderRefreshStatusRequest) String() string { func (*GetProviderRefreshStatusRequest) ProtoMessage() {} func (x *GetProviderRefreshStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[83] + mi := &file_openshell_proto_msgTypes[97] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6486,7 +7279,7 @@ func (x *GetProviderRefreshStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetProviderRefreshStatusRequest.ProtoReflect.Descriptor instead. func (*GetProviderRefreshStatusRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{83} + return file_openshell_proto_rawDescGZIP(), []int{97} } func (x *GetProviderRefreshStatusRequest) GetProvider() string { @@ -6519,7 +7312,7 @@ type GetProviderRefreshStatusResponse struct { func (x *GetProviderRefreshStatusResponse) Reset() { *x = GetProviderRefreshStatusResponse{} - mi := &file_openshell_proto_msgTypes[84] + mi := &file_openshell_proto_msgTypes[98] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6531,7 +7324,7 @@ func (x *GetProviderRefreshStatusResponse) String() string { func (*GetProviderRefreshStatusResponse) ProtoMessage() {} func (x *GetProviderRefreshStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[84] + mi := &file_openshell_proto_msgTypes[98] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6544,7 +7337,7 @@ func (x *GetProviderRefreshStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetProviderRefreshStatusResponse.ProtoReflect.Descriptor instead. func (*GetProviderRefreshStatusResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{84} + return file_openshell_proto_rawDescGZIP(), []int{98} } func (x *GetProviderRefreshStatusResponse) GetCredentials() []*ProviderCredentialRefreshStatus { @@ -6573,7 +7366,7 @@ type ConfigureProviderRefreshRequest struct { func (x *ConfigureProviderRefreshRequest) Reset() { *x = ConfigureProviderRefreshRequest{} - mi := &file_openshell_proto_msgTypes[85] + mi := &file_openshell_proto_msgTypes[99] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6585,7 +7378,7 @@ func (x *ConfigureProviderRefreshRequest) String() string { func (*ConfigureProviderRefreshRequest) ProtoMessage() {} func (x *ConfigureProviderRefreshRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[85] + mi := &file_openshell_proto_msgTypes[99] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6598,7 +7391,7 @@ func (x *ConfigureProviderRefreshRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigureProviderRefreshRequest.ProtoReflect.Descriptor instead. func (*ConfigureProviderRefreshRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{85} + return file_openshell_proto_rawDescGZIP(), []int{99} } func (x *ConfigureProviderRefreshRequest) GetProvider() string { @@ -6659,7 +7452,7 @@ type ConfigureProviderRefreshResponse struct { func (x *ConfigureProviderRefreshResponse) Reset() { *x = ConfigureProviderRefreshResponse{} - mi := &file_openshell_proto_msgTypes[86] + mi := &file_openshell_proto_msgTypes[100] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6671,7 +7464,7 @@ func (x *ConfigureProviderRefreshResponse) String() string { func (*ConfigureProviderRefreshResponse) ProtoMessage() {} func (x *ConfigureProviderRefreshResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[86] + mi := &file_openshell_proto_msgTypes[100] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6684,7 +7477,7 @@ func (x *ConfigureProviderRefreshResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigureProviderRefreshResponse.ProtoReflect.Descriptor instead. func (*ConfigureProviderRefreshResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{86} + return file_openshell_proto_rawDescGZIP(), []int{100} } func (x *ConfigureProviderRefreshResponse) GetStatus() *ProviderCredentialRefreshStatus { @@ -6706,7 +7499,7 @@ type RotateProviderCredentialRequest struct { func (x *RotateProviderCredentialRequest) Reset() { *x = RotateProviderCredentialRequest{} - mi := &file_openshell_proto_msgTypes[87] + mi := &file_openshell_proto_msgTypes[101] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6718,7 +7511,7 @@ func (x *RotateProviderCredentialRequest) String() string { func (*RotateProviderCredentialRequest) ProtoMessage() {} func (x *RotateProviderCredentialRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[87] + mi := &file_openshell_proto_msgTypes[101] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6731,7 +7524,7 @@ func (x *RotateProviderCredentialRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RotateProviderCredentialRequest.ProtoReflect.Descriptor instead. func (*RotateProviderCredentialRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{87} + return file_openshell_proto_rawDescGZIP(), []int{101} } func (x *RotateProviderCredentialRequest) GetProvider() string { @@ -6764,7 +7557,7 @@ type RotateProviderCredentialResponse struct { func (x *RotateProviderCredentialResponse) Reset() { *x = RotateProviderCredentialResponse{} - mi := &file_openshell_proto_msgTypes[88] + mi := &file_openshell_proto_msgTypes[102] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6776,7 +7569,7 @@ func (x *RotateProviderCredentialResponse) String() string { func (*RotateProviderCredentialResponse) ProtoMessage() {} func (x *RotateProviderCredentialResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[88] + mi := &file_openshell_proto_msgTypes[102] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6789,7 +7582,7 @@ func (x *RotateProviderCredentialResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RotateProviderCredentialResponse.ProtoReflect.Descriptor instead. func (*RotateProviderCredentialResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{88} + return file_openshell_proto_rawDescGZIP(), []int{102} } func (x *RotateProviderCredentialResponse) GetStatus() *ProviderCredentialRefreshStatus { @@ -6811,7 +7604,7 @@ type DeleteProviderRefreshRequest struct { func (x *DeleteProviderRefreshRequest) Reset() { *x = DeleteProviderRefreshRequest{} - mi := &file_openshell_proto_msgTypes[89] + mi := &file_openshell_proto_msgTypes[103] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6823,7 +7616,7 @@ func (x *DeleteProviderRefreshRequest) String() string { func (*DeleteProviderRefreshRequest) ProtoMessage() {} func (x *DeleteProviderRefreshRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[89] + mi := &file_openshell_proto_msgTypes[103] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6836,7 +7629,7 @@ func (x *DeleteProviderRefreshRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteProviderRefreshRequest.ProtoReflect.Descriptor instead. func (*DeleteProviderRefreshRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{89} + return file_openshell_proto_rawDescGZIP(), []int{103} } func (x *DeleteProviderRefreshRequest) GetProvider() string { @@ -6869,7 +7662,7 @@ type DeleteProviderRefreshResponse struct { func (x *DeleteProviderRefreshResponse) Reset() { *x = DeleteProviderRefreshResponse{} - mi := &file_openshell_proto_msgTypes[90] + mi := &file_openshell_proto_msgTypes[104] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6881,7 +7674,7 @@ func (x *DeleteProviderRefreshResponse) String() string { func (*DeleteProviderRefreshResponse) ProtoMessage() {} func (x *DeleteProviderRefreshResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[90] + mi := &file_openshell_proto_msgTypes[104] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6894,7 +7687,7 @@ func (x *DeleteProviderRefreshResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteProviderRefreshResponse.ProtoReflect.Descriptor instead. func (*DeleteProviderRefreshResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{90} + return file_openshell_proto_rawDescGZIP(), []int{104} } func (x *DeleteProviderRefreshResponse) GetDeleted() bool { @@ -6934,7 +7727,7 @@ type ProviderProfile struct { func (x *ProviderProfile) Reset() { *x = ProviderProfile{} - mi := &file_openshell_proto_msgTypes[91] + mi := &file_openshell_proto_msgTypes[105] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6946,7 +7739,7 @@ func (x *ProviderProfile) String() string { func (*ProviderProfile) ProtoMessage() {} func (x *ProviderProfile) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[91] + mi := &file_openshell_proto_msgTypes[105] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6959,7 +7752,7 @@ func (x *ProviderProfile) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderProfile.ProtoReflect.Descriptor instead. func (*ProviderProfile) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{91} + return file_openshell_proto_rawDescGZIP(), []int{105} } func (x *ProviderProfile) GetId() string { @@ -7064,7 +7857,7 @@ type StoredProviderProfile struct { func (x *StoredProviderProfile) Reset() { *x = StoredProviderProfile{} - mi := &file_openshell_proto_msgTypes[92] + mi := &file_openshell_proto_msgTypes[106] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7076,7 +7869,7 @@ func (x *StoredProviderProfile) String() string { func (*StoredProviderProfile) ProtoMessage() {} func (x *StoredProviderProfile) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[92] + mi := &file_openshell_proto_msgTypes[106] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7089,7 +7882,7 @@ func (x *StoredProviderProfile) ProtoReflect() protoreflect.Message { // Deprecated: Use StoredProviderProfile.ProtoReflect.Descriptor instead. func (*StoredProviderProfile) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{92} + return file_openshell_proto_rawDescGZIP(), []int{106} } func (x *StoredProviderProfile) GetMetadata() *datamodelv1.ObjectMeta { @@ -7116,7 +7909,7 @@ type ProviderProfileResponse struct { func (x *ProviderProfileResponse) Reset() { *x = ProviderProfileResponse{} - mi := &file_openshell_proto_msgTypes[93] + mi := &file_openshell_proto_msgTypes[107] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7128,7 +7921,7 @@ func (x *ProviderProfileResponse) String() string { func (*ProviderProfileResponse) ProtoMessage() {} func (x *ProviderProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[93] + mi := &file_openshell_proto_msgTypes[107] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7141,7 +7934,7 @@ func (x *ProviderProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderProfileResponse.ProtoReflect.Descriptor instead. func (*ProviderProfileResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{93} + return file_openshell_proto_rawDescGZIP(), []int{107} } func (x *ProviderProfileResponse) GetProfile() *ProviderProfile { @@ -7161,7 +7954,7 @@ type ListProviderProfilesResponse struct { func (x *ListProviderProfilesResponse) Reset() { *x = ListProviderProfilesResponse{} - mi := &file_openshell_proto_msgTypes[94] + mi := &file_openshell_proto_msgTypes[108] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7173,7 +7966,7 @@ func (x *ListProviderProfilesResponse) String() string { func (*ListProviderProfilesResponse) ProtoMessage() {} func (x *ListProviderProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[94] + mi := &file_openshell_proto_msgTypes[108] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7186,7 +7979,7 @@ func (x *ListProviderProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProviderProfilesResponse.ProtoReflect.Descriptor instead. func (*ListProviderProfilesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{94} + return file_openshell_proto_rawDescGZIP(), []int{108} } func (x *ListProviderProfilesResponse) GetProfiles() []*ProviderProfile { @@ -7209,7 +8002,7 @@ type ImportProviderProfilesRequest struct { func (x *ImportProviderProfilesRequest) Reset() { *x = ImportProviderProfilesRequest{} - mi := &file_openshell_proto_msgTypes[95] + mi := &file_openshell_proto_msgTypes[109] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7221,7 +8014,7 @@ func (x *ImportProviderProfilesRequest) String() string { func (*ImportProviderProfilesRequest) ProtoMessage() {} func (x *ImportProviderProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[95] + mi := &file_openshell_proto_msgTypes[109] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7234,7 +8027,7 @@ func (x *ImportProviderProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ImportProviderProfilesRequest.ProtoReflect.Descriptor instead. func (*ImportProviderProfilesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{95} + return file_openshell_proto_rawDescGZIP(), []int{109} } func (x *ImportProviderProfilesRequest) GetProfiles() []*ProviderProfileImportItem { @@ -7263,7 +8056,7 @@ type ImportProviderProfilesResponse struct { func (x *ImportProviderProfilesResponse) Reset() { *x = ImportProviderProfilesResponse{} - mi := &file_openshell_proto_msgTypes[96] + mi := &file_openshell_proto_msgTypes[110] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7275,7 +8068,7 @@ func (x *ImportProviderProfilesResponse) String() string { func (*ImportProviderProfilesResponse) ProtoMessage() {} func (x *ImportProviderProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[96] + mi := &file_openshell_proto_msgTypes[110] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7288,7 +8081,7 @@ func (x *ImportProviderProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ImportProviderProfilesResponse.ProtoReflect.Descriptor instead. func (*ImportProviderProfilesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{96} + return file_openshell_proto_rawDescGZIP(), []int{110} } func (x *ImportProviderProfilesResponse) GetDiagnostics() []*ProviderProfileDiagnostic { @@ -7332,7 +8125,7 @@ type UpdateProviderProfilesRequest struct { func (x *UpdateProviderProfilesRequest) Reset() { *x = UpdateProviderProfilesRequest{} - mi := &file_openshell_proto_msgTypes[97] + mi := &file_openshell_proto_msgTypes[111] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7344,7 +8137,7 @@ func (x *UpdateProviderProfilesRequest) String() string { func (*UpdateProviderProfilesRequest) ProtoMessage() {} func (x *UpdateProviderProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[97] + mi := &file_openshell_proto_msgTypes[111] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7357,7 +8150,7 @@ func (x *UpdateProviderProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateProviderProfilesRequest.ProtoReflect.Descriptor instead. func (*UpdateProviderProfilesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{97} + return file_openshell_proto_rawDescGZIP(), []int{111} } func (x *UpdateProviderProfilesRequest) GetProfile() *ProviderProfileImportItem { @@ -7400,7 +8193,7 @@ type UpdateProviderProfilesResponse struct { func (x *UpdateProviderProfilesResponse) Reset() { *x = UpdateProviderProfilesResponse{} - mi := &file_openshell_proto_msgTypes[98] + mi := &file_openshell_proto_msgTypes[112] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7412,7 +8205,7 @@ func (x *UpdateProviderProfilesResponse) String() string { func (*UpdateProviderProfilesResponse) ProtoMessage() {} func (x *UpdateProviderProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[98] + mi := &file_openshell_proto_msgTypes[112] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7425,7 +8218,7 @@ func (x *UpdateProviderProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateProviderProfilesResponse.ProtoReflect.Descriptor instead. func (*UpdateProviderProfilesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{98} + return file_openshell_proto_rawDescGZIP(), []int{112} } func (x *UpdateProviderProfilesResponse) GetDiagnostics() []*ProviderProfileDiagnostic { @@ -7462,7 +8255,7 @@ type LintProviderProfilesRequest struct { func (x *LintProviderProfilesRequest) Reset() { *x = LintProviderProfilesRequest{} - mi := &file_openshell_proto_msgTypes[99] + mi := &file_openshell_proto_msgTypes[113] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7474,7 +8267,7 @@ func (x *LintProviderProfilesRequest) String() string { func (*LintProviderProfilesRequest) ProtoMessage() {} func (x *LintProviderProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[99] + mi := &file_openshell_proto_msgTypes[113] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7487,7 +8280,7 @@ func (x *LintProviderProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LintProviderProfilesRequest.ProtoReflect.Descriptor instead. func (*LintProviderProfilesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{99} + return file_openshell_proto_rawDescGZIP(), []int{113} } func (x *LintProviderProfilesRequest) GetProfiles() []*ProviderProfileImportItem { @@ -7515,7 +8308,7 @@ type LintProviderProfilesResponse struct { func (x *LintProviderProfilesResponse) Reset() { *x = LintProviderProfilesResponse{} - mi := &file_openshell_proto_msgTypes[100] + mi := &file_openshell_proto_msgTypes[114] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7527,7 +8320,7 @@ func (x *LintProviderProfilesResponse) String() string { func (*LintProviderProfilesResponse) ProtoMessage() {} func (x *LintProviderProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[100] + mi := &file_openshell_proto_msgTypes[114] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7540,7 +8333,7 @@ func (x *LintProviderProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LintProviderProfilesResponse.ProtoReflect.Descriptor instead. func (*LintProviderProfilesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{100} + return file_openshell_proto_rawDescGZIP(), []int{114} } func (x *LintProviderProfilesResponse) GetDiagnostics() []*ProviderProfileDiagnostic { @@ -7567,7 +8360,7 @@ type DeleteProviderResponse struct { func (x *DeleteProviderResponse) Reset() { *x = DeleteProviderResponse{} - mi := &file_openshell_proto_msgTypes[101] + mi := &file_openshell_proto_msgTypes[115] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7579,7 +8372,7 @@ func (x *DeleteProviderResponse) String() string { func (*DeleteProviderResponse) ProtoMessage() {} func (x *DeleteProviderResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[101] + mi := &file_openshell_proto_msgTypes[115] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7592,7 +8385,7 @@ func (x *DeleteProviderResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteProviderResponse.ProtoReflect.Descriptor instead. func (*DeleteProviderResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{101} + return file_openshell_proto_rawDescGZIP(), []int{115} } func (x *DeleteProviderResponse) GetDeleted() bool { @@ -7615,7 +8408,7 @@ type DeleteProviderProfileRequest struct { func (x *DeleteProviderProfileRequest) Reset() { *x = DeleteProviderProfileRequest{} - mi := &file_openshell_proto_msgTypes[102] + mi := &file_openshell_proto_msgTypes[116] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7627,7 +8420,7 @@ func (x *DeleteProviderProfileRequest) String() string { func (*DeleteProviderProfileRequest) ProtoMessage() {} func (x *DeleteProviderProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[102] + mi := &file_openshell_proto_msgTypes[116] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7640,7 +8433,7 @@ func (x *DeleteProviderProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteProviderProfileRequest.ProtoReflect.Descriptor instead. func (*DeleteProviderProfileRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{102} + return file_openshell_proto_rawDescGZIP(), []int{116} } func (x *DeleteProviderProfileRequest) GetId() string { @@ -7667,7 +8460,7 @@ type DeleteProviderProfileResponse struct { func (x *DeleteProviderProfileResponse) Reset() { *x = DeleteProviderProfileResponse{} - mi := &file_openshell_proto_msgTypes[103] + mi := &file_openshell_proto_msgTypes[117] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7679,7 +8472,7 @@ func (x *DeleteProviderProfileResponse) String() string { func (*DeleteProviderProfileResponse) ProtoMessage() {} func (x *DeleteProviderProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[103] + mi := &file_openshell_proto_msgTypes[117] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7692,7 +8485,7 @@ func (x *DeleteProviderProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteProviderProfileResponse.ProtoReflect.Descriptor instead. func (*DeleteProviderProfileResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{103} + return file_openshell_proto_rawDescGZIP(), []int{117} } func (x *DeleteProviderProfileResponse) GetDeleted() bool { @@ -7717,7 +8510,7 @@ type GetSandboxProviderEnvironmentRequest struct { func (x *GetSandboxProviderEnvironmentRequest) Reset() { *x = GetSandboxProviderEnvironmentRequest{} - mi := &file_openshell_proto_msgTypes[104] + mi := &file_openshell_proto_msgTypes[118] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7729,7 +8522,7 @@ func (x *GetSandboxProviderEnvironmentRequest) String() string { func (*GetSandboxProviderEnvironmentRequest) ProtoMessage() {} func (x *GetSandboxProviderEnvironmentRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[104] + mi := &file_openshell_proto_msgTypes[118] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7742,7 +8535,7 @@ func (x *GetSandboxProviderEnvironmentRequest) ProtoReflect() protoreflect.Messa // Deprecated: Use GetSandboxProviderEnvironmentRequest.ProtoReflect.Descriptor instead. func (*GetSandboxProviderEnvironmentRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{104} + return file_openshell_proto_rawDescGZIP(), []int{118} } func (x *GetSandboxProviderEnvironmentRequest) GetSandboxId() string { @@ -7771,7 +8564,7 @@ type StaticCredentialEndpointBinding struct { func (x *StaticCredentialEndpointBinding) Reset() { *x = StaticCredentialEndpointBinding{} - mi := &file_openshell_proto_msgTypes[105] + mi := &file_openshell_proto_msgTypes[119] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7783,7 +8576,7 @@ func (x *StaticCredentialEndpointBinding) String() string { func (*StaticCredentialEndpointBinding) ProtoMessage() {} func (x *StaticCredentialEndpointBinding) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[105] + mi := &file_openshell_proto_msgTypes[119] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7796,7 +8589,7 @@ func (x *StaticCredentialEndpointBinding) ProtoReflect() protoreflect.Message { // Deprecated: Use StaticCredentialEndpointBinding.ProtoReflect.Descriptor instead. func (*StaticCredentialEndpointBinding) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{105} + return file_openshell_proto_rawDescGZIP(), []int{119} } func (x *StaticCredentialEndpointBinding) GetHost() string { @@ -7840,7 +8633,7 @@ type StaticCredentialBinding struct { func (x *StaticCredentialBinding) Reset() { *x = StaticCredentialBinding{} - mi := &file_openshell_proto_msgTypes[106] + mi := &file_openshell_proto_msgTypes[120] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7852,7 +8645,7 @@ func (x *StaticCredentialBinding) String() string { func (*StaticCredentialBinding) ProtoMessage() {} func (x *StaticCredentialBinding) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[106] + mi := &file_openshell_proto_msgTypes[120] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7865,7 +8658,7 @@ func (x *StaticCredentialBinding) ProtoReflect() protoreflect.Message { // Deprecated: Use StaticCredentialBinding.ProtoReflect.Descriptor instead. func (*StaticCredentialBinding) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{106} + return file_openshell_proto_rawDescGZIP(), []int{120} } func (x *StaticCredentialBinding) GetEndpoints() []*StaticCredentialEndpointBinding { @@ -7916,7 +8709,7 @@ type GetSandboxProviderEnvironmentResponse struct { func (x *GetSandboxProviderEnvironmentResponse) Reset() { *x = GetSandboxProviderEnvironmentResponse{} - mi := &file_openshell_proto_msgTypes[107] + mi := &file_openshell_proto_msgTypes[121] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7928,7 +8721,7 @@ func (x *GetSandboxProviderEnvironmentResponse) String() string { func (*GetSandboxProviderEnvironmentResponse) ProtoMessage() {} func (x *GetSandboxProviderEnvironmentResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[107] + mi := &file_openshell_proto_msgTypes[121] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7941,7 +8734,7 @@ func (x *GetSandboxProviderEnvironmentResponse) ProtoReflect() protoreflect.Mess // Deprecated: Use GetSandboxProviderEnvironmentResponse.ProtoReflect.Descriptor instead. func (*GetSandboxProviderEnvironmentResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{107} + return file_openshell_proto_rawDescGZIP(), []int{121} } func (x *GetSandboxProviderEnvironmentResponse) GetEnvironment() map[string]string { @@ -8003,7 +8796,7 @@ type ExchangeProviderSubjectTokenRequest struct { func (x *ExchangeProviderSubjectTokenRequest) Reset() { *x = ExchangeProviderSubjectTokenRequest{} - mi := &file_openshell_proto_msgTypes[108] + mi := &file_openshell_proto_msgTypes[122] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8015,7 +8808,7 @@ func (x *ExchangeProviderSubjectTokenRequest) String() string { func (*ExchangeProviderSubjectTokenRequest) ProtoMessage() {} func (x *ExchangeProviderSubjectTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[108] + mi := &file_openshell_proto_msgTypes[122] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8028,7 +8821,7 @@ func (x *ExchangeProviderSubjectTokenRequest) ProtoReflect() protoreflect.Messag // Deprecated: Use ExchangeProviderSubjectTokenRequest.ProtoReflect.Descriptor instead. func (*ExchangeProviderSubjectTokenRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{108} + return file_openshell_proto_rawDescGZIP(), []int{122} } func (x *ExchangeProviderSubjectTokenRequest) GetSandboxId() string { @@ -8070,7 +8863,7 @@ type ExchangeProviderSubjectTokenResponse struct { func (x *ExchangeProviderSubjectTokenResponse) Reset() { *x = ExchangeProviderSubjectTokenResponse{} - mi := &file_openshell_proto_msgTypes[109] + mi := &file_openshell_proto_msgTypes[123] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8082,7 +8875,7 @@ func (x *ExchangeProviderSubjectTokenResponse) String() string { func (*ExchangeProviderSubjectTokenResponse) ProtoMessage() {} func (x *ExchangeProviderSubjectTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[109] + mi := &file_openshell_proto_msgTypes[123] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8095,7 +8888,7 @@ func (x *ExchangeProviderSubjectTokenResponse) ProtoReflect() protoreflect.Messa // Deprecated: Use ExchangeProviderSubjectTokenResponse.ProtoReflect.Descriptor instead. func (*ExchangeProviderSubjectTokenResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{109} + return file_openshell_proto_rawDescGZIP(), []int{123} } func (x *ExchangeProviderSubjectTokenResponse) GetAccessToken() string { @@ -8166,7 +8959,7 @@ type UpdateConfigRequest struct { func (x *UpdateConfigRequest) Reset() { *x = UpdateConfigRequest{} - mi := &file_openshell_proto_msgTypes[110] + mi := &file_openshell_proto_msgTypes[124] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8178,7 +8971,7 @@ func (x *UpdateConfigRequest) String() string { func (*UpdateConfigRequest) ProtoMessage() {} func (x *UpdateConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[110] + mi := &file_openshell_proto_msgTypes[124] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8191,7 +8984,7 @@ func (x *UpdateConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateConfigRequest.ProtoReflect.Descriptor instead. func (*UpdateConfigRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{110} + return file_openshell_proto_rawDescGZIP(), []int{124} } func (x *UpdateConfigRequest) GetName() string { @@ -8281,7 +9074,7 @@ type PolicyMergeOperation struct { func (x *PolicyMergeOperation) Reset() { *x = PolicyMergeOperation{} - mi := &file_openshell_proto_msgTypes[111] + mi := &file_openshell_proto_msgTypes[125] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8293,7 +9086,7 @@ func (x *PolicyMergeOperation) String() string { func (*PolicyMergeOperation) ProtoMessage() {} func (x *PolicyMergeOperation) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[111] + mi := &file_openshell_proto_msgTypes[125] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8306,7 +9099,7 @@ func (x *PolicyMergeOperation) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyMergeOperation.ProtoReflect.Descriptor instead. func (*PolicyMergeOperation) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{111} + return file_openshell_proto_rawDescGZIP(), []int{125} } func (x *PolicyMergeOperation) GetOperation() isPolicyMergeOperation_Operation { @@ -8420,7 +9213,7 @@ type AddNetworkRule struct { func (x *AddNetworkRule) Reset() { *x = AddNetworkRule{} - mi := &file_openshell_proto_msgTypes[112] + mi := &file_openshell_proto_msgTypes[126] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8432,7 +9225,7 @@ func (x *AddNetworkRule) String() string { func (*AddNetworkRule) ProtoMessage() {} func (x *AddNetworkRule) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[112] + mi := &file_openshell_proto_msgTypes[126] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8445,7 +9238,7 @@ func (x *AddNetworkRule) ProtoReflect() protoreflect.Message { // Deprecated: Use AddNetworkRule.ProtoReflect.Descriptor instead. func (*AddNetworkRule) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{112} + return file_openshell_proto_rawDescGZIP(), []int{126} } func (x *AddNetworkRule) GetRuleName() string { @@ -8473,7 +9266,7 @@ type RemoveNetworkEndpoint struct { func (x *RemoveNetworkEndpoint) Reset() { *x = RemoveNetworkEndpoint{} - mi := &file_openshell_proto_msgTypes[113] + mi := &file_openshell_proto_msgTypes[127] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8485,7 +9278,7 @@ func (x *RemoveNetworkEndpoint) String() string { func (*RemoveNetworkEndpoint) ProtoMessage() {} func (x *RemoveNetworkEndpoint) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[113] + mi := &file_openshell_proto_msgTypes[127] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8498,7 +9291,7 @@ func (x *RemoveNetworkEndpoint) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveNetworkEndpoint.ProtoReflect.Descriptor instead. func (*RemoveNetworkEndpoint) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{113} + return file_openshell_proto_rawDescGZIP(), []int{127} } func (x *RemoveNetworkEndpoint) GetRuleName() string { @@ -8531,7 +9324,7 @@ type RemoveNetworkRule struct { func (x *RemoveNetworkRule) Reset() { *x = RemoveNetworkRule{} - mi := &file_openshell_proto_msgTypes[114] + mi := &file_openshell_proto_msgTypes[128] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8543,7 +9336,7 @@ func (x *RemoveNetworkRule) String() string { func (*RemoveNetworkRule) ProtoMessage() {} func (x *RemoveNetworkRule) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[114] + mi := &file_openshell_proto_msgTypes[128] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8556,7 +9349,7 @@ func (x *RemoveNetworkRule) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveNetworkRule.ProtoReflect.Descriptor instead. func (*RemoveNetworkRule) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{114} + return file_openshell_proto_rawDescGZIP(), []int{128} } func (x *RemoveNetworkRule) GetRuleName() string { @@ -8577,7 +9370,7 @@ type AddDenyRules struct { func (x *AddDenyRules) Reset() { *x = AddDenyRules{} - mi := &file_openshell_proto_msgTypes[115] + mi := &file_openshell_proto_msgTypes[129] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8589,7 +9382,7 @@ func (x *AddDenyRules) String() string { func (*AddDenyRules) ProtoMessage() {} func (x *AddDenyRules) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[115] + mi := &file_openshell_proto_msgTypes[129] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8602,7 +9395,7 @@ func (x *AddDenyRules) ProtoReflect() protoreflect.Message { // Deprecated: Use AddDenyRules.ProtoReflect.Descriptor instead. func (*AddDenyRules) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{115} + return file_openshell_proto_rawDescGZIP(), []int{129} } func (x *AddDenyRules) GetHost() string { @@ -8637,7 +9430,7 @@ type AddAllowRules struct { func (x *AddAllowRules) Reset() { *x = AddAllowRules{} - mi := &file_openshell_proto_msgTypes[116] + mi := &file_openshell_proto_msgTypes[130] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8649,7 +9442,7 @@ func (x *AddAllowRules) String() string { func (*AddAllowRules) ProtoMessage() {} func (x *AddAllowRules) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[116] + mi := &file_openshell_proto_msgTypes[130] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8662,7 +9455,7 @@ func (x *AddAllowRules) ProtoReflect() protoreflect.Message { // Deprecated: Use AddAllowRules.ProtoReflect.Descriptor instead. func (*AddAllowRules) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{116} + return file_openshell_proto_rawDescGZIP(), []int{130} } func (x *AddAllowRules) GetHost() string { @@ -8696,7 +9489,7 @@ type RemoveNetworkBinary struct { func (x *RemoveNetworkBinary) Reset() { *x = RemoveNetworkBinary{} - mi := &file_openshell_proto_msgTypes[117] + mi := &file_openshell_proto_msgTypes[131] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8708,7 +9501,7 @@ func (x *RemoveNetworkBinary) String() string { func (*RemoveNetworkBinary) ProtoMessage() {} func (x *RemoveNetworkBinary) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[117] + mi := &file_openshell_proto_msgTypes[131] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8721,7 +9514,7 @@ func (x *RemoveNetworkBinary) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveNetworkBinary.ProtoReflect.Descriptor instead. func (*RemoveNetworkBinary) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{117} + return file_openshell_proto_rawDescGZIP(), []int{131} } func (x *RemoveNetworkBinary) GetRuleName() string { @@ -8757,7 +9550,7 @@ type UpdateConfigResponse struct { func (x *UpdateConfigResponse) Reset() { *x = UpdateConfigResponse{} - mi := &file_openshell_proto_msgTypes[118] + mi := &file_openshell_proto_msgTypes[132] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8769,7 +9562,7 @@ func (x *UpdateConfigResponse) String() string { func (*UpdateConfigResponse) ProtoMessage() {} func (x *UpdateConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[118] + mi := &file_openshell_proto_msgTypes[132] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8782,7 +9575,7 @@ func (x *UpdateConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateConfigResponse.ProtoReflect.Descriptor instead. func (*UpdateConfigResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{118} + return file_openshell_proto_rawDescGZIP(), []int{132} } func (x *UpdateConfigResponse) GetVersion() uint32 { @@ -8837,7 +9630,7 @@ type GetSandboxPolicyStatusRequest struct { func (x *GetSandboxPolicyStatusRequest) Reset() { *x = GetSandboxPolicyStatusRequest{} - mi := &file_openshell_proto_msgTypes[119] + mi := &file_openshell_proto_msgTypes[133] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8849,7 +9642,7 @@ func (x *GetSandboxPolicyStatusRequest) String() string { func (*GetSandboxPolicyStatusRequest) ProtoMessage() {} func (x *GetSandboxPolicyStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[119] + mi := &file_openshell_proto_msgTypes[133] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8862,7 +9655,7 @@ func (x *GetSandboxPolicyStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxPolicyStatusRequest.ProtoReflect.Descriptor instead. func (*GetSandboxPolicyStatusRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{119} + return file_openshell_proto_rawDescGZIP(), []int{133} } func (x *GetSandboxPolicyStatusRequest) GetName() string { @@ -8906,7 +9699,7 @@ type GetSandboxPolicyStatusResponse struct { func (x *GetSandboxPolicyStatusResponse) Reset() { *x = GetSandboxPolicyStatusResponse{} - mi := &file_openshell_proto_msgTypes[120] + mi := &file_openshell_proto_msgTypes[134] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8918,7 +9711,7 @@ func (x *GetSandboxPolicyStatusResponse) String() string { func (*GetSandboxPolicyStatusResponse) ProtoMessage() {} func (x *GetSandboxPolicyStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[120] + mi := &file_openshell_proto_msgTypes[134] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8931,7 +9724,7 @@ func (x *GetSandboxPolicyStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxPolicyStatusResponse.ProtoReflect.Descriptor instead. func (*GetSandboxPolicyStatusResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{120} + return file_openshell_proto_rawDescGZIP(), []int{134} } func (x *GetSandboxPolicyStatusResponse) GetRevision() *SandboxPolicyRevision { @@ -8965,7 +9758,7 @@ type ListSandboxPoliciesRequest struct { func (x *ListSandboxPoliciesRequest) Reset() { *x = ListSandboxPoliciesRequest{} - mi := &file_openshell_proto_msgTypes[121] + mi := &file_openshell_proto_msgTypes[135] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8977,7 +9770,7 @@ func (x *ListSandboxPoliciesRequest) String() string { func (*ListSandboxPoliciesRequest) ProtoMessage() {} func (x *ListSandboxPoliciesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[121] + mi := &file_openshell_proto_msgTypes[135] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8990,7 +9783,7 @@ func (x *ListSandboxPoliciesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSandboxPoliciesRequest.ProtoReflect.Descriptor instead. func (*ListSandboxPoliciesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{121} + return file_openshell_proto_rawDescGZIP(), []int{135} } func (x *ListSandboxPoliciesRequest) GetName() string { @@ -9038,7 +9831,7 @@ type ListSandboxPoliciesResponse struct { func (x *ListSandboxPoliciesResponse) Reset() { *x = ListSandboxPoliciesResponse{} - mi := &file_openshell_proto_msgTypes[122] + mi := &file_openshell_proto_msgTypes[136] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9050,7 +9843,7 @@ func (x *ListSandboxPoliciesResponse) String() string { func (*ListSandboxPoliciesResponse) ProtoMessage() {} func (x *ListSandboxPoliciesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[122] + mi := &file_openshell_proto_msgTypes[136] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9063,7 +9856,7 @@ func (x *ListSandboxPoliciesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSandboxPoliciesResponse.ProtoReflect.Descriptor instead. func (*ListSandboxPoliciesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{122} + return file_openshell_proto_rawDescGZIP(), []int{136} } func (x *ListSandboxPoliciesResponse) GetRevisions() []*SandboxPolicyRevision { @@ -9090,7 +9883,7 @@ type ReportPolicyStatusRequest struct { func (x *ReportPolicyStatusRequest) Reset() { *x = ReportPolicyStatusRequest{} - mi := &file_openshell_proto_msgTypes[123] + mi := &file_openshell_proto_msgTypes[137] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9102,7 +9895,7 @@ func (x *ReportPolicyStatusRequest) String() string { func (*ReportPolicyStatusRequest) ProtoMessage() {} func (x *ReportPolicyStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[123] + mi := &file_openshell_proto_msgTypes[137] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9115,7 +9908,7 @@ func (x *ReportPolicyStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportPolicyStatusRequest.ProtoReflect.Descriptor instead. func (*ReportPolicyStatusRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{123} + return file_openshell_proto_rawDescGZIP(), []int{137} } func (x *ReportPolicyStatusRequest) GetSandboxId() string { @@ -9155,7 +9948,7 @@ type ReportPolicyStatusResponse struct { func (x *ReportPolicyStatusResponse) Reset() { *x = ReportPolicyStatusResponse{} - mi := &file_openshell_proto_msgTypes[124] + mi := &file_openshell_proto_msgTypes[138] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9167,7 +9960,7 @@ func (x *ReportPolicyStatusResponse) String() string { func (*ReportPolicyStatusResponse) ProtoMessage() {} func (x *ReportPolicyStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[124] + mi := &file_openshell_proto_msgTypes[138] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9180,7 +9973,7 @@ func (x *ReportPolicyStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportPolicyStatusResponse.ProtoReflect.Descriptor instead. func (*ReportPolicyStatusResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{124} + return file_openshell_proto_rawDescGZIP(), []int{138} } // A versioned policy revision with metadata. @@ -9208,7 +10001,7 @@ type SandboxPolicyRevision struct { func (x *SandboxPolicyRevision) Reset() { *x = SandboxPolicyRevision{} - mi := &file_openshell_proto_msgTypes[125] + mi := &file_openshell_proto_msgTypes[139] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9220,7 +10013,7 @@ func (x *SandboxPolicyRevision) String() string { func (*SandboxPolicyRevision) ProtoMessage() {} func (x *SandboxPolicyRevision) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[125] + mi := &file_openshell_proto_msgTypes[139] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9233,7 +10026,7 @@ func (x *SandboxPolicyRevision) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxPolicyRevision.ProtoReflect.Descriptor instead. func (*SandboxPolicyRevision) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{125} + return file_openshell_proto_rawDescGZIP(), []int{139} } func (x *SandboxPolicyRevision) GetVersion() uint32 { @@ -9313,7 +10106,7 @@ type GetSandboxLogsRequest struct { func (x *GetSandboxLogsRequest) Reset() { *x = GetSandboxLogsRequest{} - mi := &file_openshell_proto_msgTypes[126] + mi := &file_openshell_proto_msgTypes[140] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9325,7 +10118,7 @@ func (x *GetSandboxLogsRequest) String() string { func (*GetSandboxLogsRequest) ProtoMessage() {} func (x *GetSandboxLogsRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[126] + mi := &file_openshell_proto_msgTypes[140] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9338,7 +10131,7 @@ func (x *GetSandboxLogsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxLogsRequest.ProtoReflect.Descriptor instead. func (*GetSandboxLogsRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{126} + return file_openshell_proto_rawDescGZIP(), []int{140} } func (x *GetSandboxLogsRequest) GetSandboxId() string { @@ -9396,7 +10189,7 @@ type PushSandboxLogsRequest struct { func (x *PushSandboxLogsRequest) Reset() { *x = PushSandboxLogsRequest{} - mi := &file_openshell_proto_msgTypes[127] + mi := &file_openshell_proto_msgTypes[141] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9408,7 +10201,7 @@ func (x *PushSandboxLogsRequest) String() string { func (*PushSandboxLogsRequest) ProtoMessage() {} func (x *PushSandboxLogsRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[127] + mi := &file_openshell_proto_msgTypes[141] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9421,7 +10214,7 @@ func (x *PushSandboxLogsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PushSandboxLogsRequest.ProtoReflect.Descriptor instead. func (*PushSandboxLogsRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{127} + return file_openshell_proto_rawDescGZIP(), []int{141} } func (x *PushSandboxLogsRequest) GetSandboxId() string { @@ -9447,7 +10240,7 @@ type PushSandboxLogsResponse struct { func (x *PushSandboxLogsResponse) Reset() { *x = PushSandboxLogsResponse{} - mi := &file_openshell_proto_msgTypes[128] + mi := &file_openshell_proto_msgTypes[142] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9459,7 +10252,7 @@ func (x *PushSandboxLogsResponse) String() string { func (*PushSandboxLogsResponse) ProtoMessage() {} func (x *PushSandboxLogsResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[128] + mi := &file_openshell_proto_msgTypes[142] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9472,7 +10265,7 @@ func (x *PushSandboxLogsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PushSandboxLogsResponse.ProtoReflect.Descriptor instead. func (*PushSandboxLogsResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{128} + return file_openshell_proto_rawDescGZIP(), []int{142} } // Get sandbox logs response. @@ -9488,7 +10281,7 @@ type GetSandboxLogsResponse struct { func (x *GetSandboxLogsResponse) Reset() { *x = GetSandboxLogsResponse{} - mi := &file_openshell_proto_msgTypes[129] + mi := &file_openshell_proto_msgTypes[143] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9500,7 +10293,7 @@ func (x *GetSandboxLogsResponse) String() string { func (*GetSandboxLogsResponse) ProtoMessage() {} func (x *GetSandboxLogsResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[129] + mi := &file_openshell_proto_msgTypes[143] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9513,7 +10306,7 @@ func (x *GetSandboxLogsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxLogsResponse.ProtoReflect.Descriptor instead. func (*GetSandboxLogsResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{129} + return file_openshell_proto_rawDescGZIP(), []int{143} } func (x *GetSandboxLogsResponse) GetLogs() []*SandboxLogLine { @@ -9546,7 +10339,7 @@ type SupervisorMessage struct { func (x *SupervisorMessage) Reset() { *x = SupervisorMessage{} - mi := &file_openshell_proto_msgTypes[130] + mi := &file_openshell_proto_msgTypes[144] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9558,7 +10351,7 @@ func (x *SupervisorMessage) String() string { func (*SupervisorMessage) ProtoMessage() {} func (x *SupervisorMessage) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[130] + mi := &file_openshell_proto_msgTypes[144] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9571,7 +10364,7 @@ func (x *SupervisorMessage) ProtoReflect() protoreflect.Message { // Deprecated: Use SupervisorMessage.ProtoReflect.Descriptor instead. func (*SupervisorMessage) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{130} + return file_openshell_proto_rawDescGZIP(), []int{144} } func (x *SupervisorMessage) GetPayload() isSupervisorMessage_Payload { @@ -9662,7 +10455,7 @@ type GatewayMessage struct { func (x *GatewayMessage) Reset() { *x = GatewayMessage{} - mi := &file_openshell_proto_msgTypes[131] + mi := &file_openshell_proto_msgTypes[145] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9674,7 +10467,7 @@ func (x *GatewayMessage) String() string { func (*GatewayMessage) ProtoMessage() {} func (x *GatewayMessage) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[131] + mi := &file_openshell_proto_msgTypes[145] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9687,7 +10480,7 @@ func (x *GatewayMessage) ProtoReflect() protoreflect.Message { // Deprecated: Use GatewayMessage.ProtoReflect.Descriptor instead. func (*GatewayMessage) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{131} + return file_openshell_proto_rawDescGZIP(), []int{145} } func (x *GatewayMessage) GetPayload() isGatewayMessage_Payload { @@ -9789,7 +10582,7 @@ type SupervisorHello struct { func (x *SupervisorHello) Reset() { *x = SupervisorHello{} - mi := &file_openshell_proto_msgTypes[132] + mi := &file_openshell_proto_msgTypes[146] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9801,7 +10594,7 @@ func (x *SupervisorHello) String() string { func (*SupervisorHello) ProtoMessage() {} func (x *SupervisorHello) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[132] + mi := &file_openshell_proto_msgTypes[146] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9814,7 +10607,7 @@ func (x *SupervisorHello) ProtoReflect() protoreflect.Message { // Deprecated: Use SupervisorHello.ProtoReflect.Descriptor instead. func (*SupervisorHello) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{132} + return file_openshell_proto_rawDescGZIP(), []int{146} } func (x *SupervisorHello) GetSandboxId() string { @@ -9844,7 +10637,7 @@ type SessionAccepted struct { func (x *SessionAccepted) Reset() { *x = SessionAccepted{} - mi := &file_openshell_proto_msgTypes[133] + mi := &file_openshell_proto_msgTypes[147] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9856,7 +10649,7 @@ func (x *SessionAccepted) String() string { func (*SessionAccepted) ProtoMessage() {} func (x *SessionAccepted) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[133] + mi := &file_openshell_proto_msgTypes[147] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9869,7 +10662,7 @@ func (x *SessionAccepted) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionAccepted.ProtoReflect.Descriptor instead. func (*SessionAccepted) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{133} + return file_openshell_proto_rawDescGZIP(), []int{147} } func (x *SessionAccepted) GetSessionId() string { @@ -9897,7 +10690,7 @@ type SessionRejected struct { func (x *SessionRejected) Reset() { *x = SessionRejected{} - mi := &file_openshell_proto_msgTypes[134] + mi := &file_openshell_proto_msgTypes[148] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9909,7 +10702,7 @@ func (x *SessionRejected) String() string { func (*SessionRejected) ProtoMessage() {} func (x *SessionRejected) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[134] + mi := &file_openshell_proto_msgTypes[148] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9922,7 +10715,7 @@ func (x *SessionRejected) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionRejected.ProtoReflect.Descriptor instead. func (*SessionRejected) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{134} + return file_openshell_proto_rawDescGZIP(), []int{148} } func (x *SessionRejected) GetReason() string { @@ -9941,7 +10734,7 @@ type SupervisorHeartbeat struct { func (x *SupervisorHeartbeat) Reset() { *x = SupervisorHeartbeat{} - mi := &file_openshell_proto_msgTypes[135] + mi := &file_openshell_proto_msgTypes[149] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9953,7 +10746,7 @@ func (x *SupervisorHeartbeat) String() string { func (*SupervisorHeartbeat) ProtoMessage() {} func (x *SupervisorHeartbeat) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[135] + mi := &file_openshell_proto_msgTypes[149] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9966,7 +10759,7 @@ func (x *SupervisorHeartbeat) ProtoReflect() protoreflect.Message { // Deprecated: Use SupervisorHeartbeat.ProtoReflect.Descriptor instead. func (*SupervisorHeartbeat) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{135} + return file_openshell_proto_rawDescGZIP(), []int{149} } // Gateway heartbeat. @@ -9978,7 +10771,7 @@ type GatewayHeartbeat struct { func (x *GatewayHeartbeat) Reset() { *x = GatewayHeartbeat{} - mi := &file_openshell_proto_msgTypes[136] + mi := &file_openshell_proto_msgTypes[150] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9990,7 +10783,7 @@ func (x *GatewayHeartbeat) String() string { func (*GatewayHeartbeat) ProtoMessage() {} func (x *GatewayHeartbeat) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[136] + mi := &file_openshell_proto_msgTypes[150] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10003,7 +10796,7 @@ func (x *GatewayHeartbeat) ProtoReflect() protoreflect.Message { // Deprecated: Use GatewayHeartbeat.ProtoReflect.Descriptor instead. func (*GatewayHeartbeat) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{136} + return file_openshell_proto_rawDescGZIP(), []int{150} } // Terminal result reported before the supervisor shuts down. A successful RPC @@ -10020,7 +10813,7 @@ type ReportMainProcessExitRequest struct { func (x *ReportMainProcessExitRequest) Reset() { *x = ReportMainProcessExitRequest{} - mi := &file_openshell_proto_msgTypes[137] + mi := &file_openshell_proto_msgTypes[151] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10032,7 +10825,7 @@ func (x *ReportMainProcessExitRequest) String() string { func (*ReportMainProcessExitRequest) ProtoMessage() {} func (x *ReportMainProcessExitRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[137] + mi := &file_openshell_proto_msgTypes[151] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10045,7 +10838,7 @@ func (x *ReportMainProcessExitRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportMainProcessExitRequest.ProtoReflect.Descriptor instead. func (*ReportMainProcessExitRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{137} + return file_openshell_proto_rawDescGZIP(), []int{151} } func (x *ReportMainProcessExitRequest) GetSandboxId() string { @@ -10077,7 +10870,7 @@ type ReportMainProcessExitResponse struct { func (x *ReportMainProcessExitResponse) Reset() { *x = ReportMainProcessExitResponse{} - mi := &file_openshell_proto_msgTypes[138] + mi := &file_openshell_proto_msgTypes[152] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10089,7 +10882,7 @@ func (x *ReportMainProcessExitResponse) String() string { func (*ReportMainProcessExitResponse) ProtoMessage() {} func (x *ReportMainProcessExitResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[138] + mi := &file_openshell_proto_msgTypes[152] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10102,7 +10895,7 @@ func (x *ReportMainProcessExitResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportMainProcessExitResponse.ProtoReflect.Descriptor instead. func (*ReportMainProcessExitResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{138} + return file_openshell_proto_rawDescGZIP(), []int{152} } // Terminal-delivery completion reported after all expected foreground SSH @@ -10117,7 +10910,7 @@ type FinalizeMainProcessExitRequest struct { func (x *FinalizeMainProcessExitRequest) Reset() { *x = FinalizeMainProcessExitRequest{} - mi := &file_openshell_proto_msgTypes[139] + mi := &file_openshell_proto_msgTypes[153] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10129,7 +10922,7 @@ func (x *FinalizeMainProcessExitRequest) String() string { func (*FinalizeMainProcessExitRequest) ProtoMessage() {} func (x *FinalizeMainProcessExitRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[139] + mi := &file_openshell_proto_msgTypes[153] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10142,7 +10935,7 @@ func (x *FinalizeMainProcessExitRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FinalizeMainProcessExitRequest.ProtoReflect.Descriptor instead. func (*FinalizeMainProcessExitRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{139} + return file_openshell_proto_rawDescGZIP(), []int{153} } func (x *FinalizeMainProcessExitRequest) GetSandboxId() string { @@ -10167,7 +10960,7 @@ type FinalizeMainProcessExitResponse struct { func (x *FinalizeMainProcessExitResponse) Reset() { *x = FinalizeMainProcessExitResponse{} - mi := &file_openshell_proto_msgTypes[140] + mi := &file_openshell_proto_msgTypes[154] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10179,7 +10972,7 @@ func (x *FinalizeMainProcessExitResponse) String() string { func (*FinalizeMainProcessExitResponse) ProtoMessage() {} func (x *FinalizeMainProcessExitResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[140] + mi := &file_openshell_proto_msgTypes[154] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10192,7 +10985,7 @@ func (x *FinalizeMainProcessExitResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FinalizeMainProcessExitResponse.ProtoReflect.Descriptor instead. func (*FinalizeMainProcessExitResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{140} + return file_openshell_proto_rawDescGZIP(), []int{154} } // Gateway requests the supervisor to open a relay channel. @@ -10221,7 +11014,7 @@ type RelayOpen struct { func (x *RelayOpen) Reset() { *x = RelayOpen{} - mi := &file_openshell_proto_msgTypes[141] + mi := &file_openshell_proto_msgTypes[155] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10233,7 +11026,7 @@ func (x *RelayOpen) String() string { func (*RelayOpen) ProtoMessage() {} func (x *RelayOpen) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[141] + mi := &file_openshell_proto_msgTypes[155] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10246,7 +11039,7 @@ func (x *RelayOpen) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayOpen.ProtoReflect.Descriptor instead. func (*RelayOpen) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{141} + return file_openshell_proto_rawDescGZIP(), []int{155} } func (x *RelayOpen) GetChannelId() string { @@ -10313,7 +11106,7 @@ type SshRelayTarget struct { func (x *SshRelayTarget) Reset() { *x = SshRelayTarget{} - mi := &file_openshell_proto_msgTypes[142] + mi := &file_openshell_proto_msgTypes[156] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10325,7 +11118,7 @@ func (x *SshRelayTarget) String() string { func (*SshRelayTarget) ProtoMessage() {} func (x *SshRelayTarget) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[142] + mi := &file_openshell_proto_msgTypes[156] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10338,7 +11131,7 @@ func (x *SshRelayTarget) ProtoReflect() protoreflect.Message { // Deprecated: Use SshRelayTarget.ProtoReflect.Descriptor instead. func (*SshRelayTarget) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{142} + return file_openshell_proto_rawDescGZIP(), []int{156} } // TCP target dialed by the supervisor from inside the sandbox. @@ -10354,7 +11147,7 @@ type TcpRelayTarget struct { func (x *TcpRelayTarget) Reset() { *x = TcpRelayTarget{} - mi := &file_openshell_proto_msgTypes[143] + mi := &file_openshell_proto_msgTypes[157] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10366,7 +11159,7 @@ func (x *TcpRelayTarget) String() string { func (*TcpRelayTarget) ProtoMessage() {} func (x *TcpRelayTarget) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[143] + mi := &file_openshell_proto_msgTypes[157] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10379,7 +11172,7 @@ func (x *TcpRelayTarget) ProtoReflect() protoreflect.Message { // Deprecated: Use TcpRelayTarget.ProtoReflect.Descriptor instead. func (*TcpRelayTarget) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{143} + return file_openshell_proto_rawDescGZIP(), []int{157} } func (x *TcpRelayTarget) GetHost() string { @@ -10407,7 +11200,7 @@ type RelayInit struct { func (x *RelayInit) Reset() { *x = RelayInit{} - mi := &file_openshell_proto_msgTypes[144] + mi := &file_openshell_proto_msgTypes[158] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10419,7 +11212,7 @@ func (x *RelayInit) String() string { func (*RelayInit) ProtoMessage() {} func (x *RelayInit) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[144] + mi := &file_openshell_proto_msgTypes[158] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10432,7 +11225,7 @@ func (x *RelayInit) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayInit.ProtoReflect.Descriptor instead. func (*RelayInit) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{144} + return file_openshell_proto_rawDescGZIP(), []int{158} } func (x *RelayInit) GetChannelId() string { @@ -10459,7 +11252,7 @@ type RelayFrame struct { func (x *RelayFrame) Reset() { *x = RelayFrame{} - mi := &file_openshell_proto_msgTypes[145] + mi := &file_openshell_proto_msgTypes[159] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10471,7 +11264,7 @@ func (x *RelayFrame) String() string { func (*RelayFrame) ProtoMessage() {} func (x *RelayFrame) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[145] + mi := &file_openshell_proto_msgTypes[159] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10484,7 +11277,7 @@ func (x *RelayFrame) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayFrame.ProtoReflect.Descriptor instead. func (*RelayFrame) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{145} + return file_openshell_proto_rawDescGZIP(), []int{159} } func (x *RelayFrame) GetPayload() isRelayFrame_Payload { @@ -10543,7 +11336,7 @@ type RelayOpenResult struct { func (x *RelayOpenResult) Reset() { *x = RelayOpenResult{} - mi := &file_openshell_proto_msgTypes[146] + mi := &file_openshell_proto_msgTypes[160] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10555,7 +11348,7 @@ func (x *RelayOpenResult) String() string { func (*RelayOpenResult) ProtoMessage() {} func (x *RelayOpenResult) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[146] + mi := &file_openshell_proto_msgTypes[160] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10568,7 +11361,7 @@ func (x *RelayOpenResult) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayOpenResult.ProtoReflect.Descriptor instead. func (*RelayOpenResult) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{146} + return file_openshell_proto_rawDescGZIP(), []int{160} } func (x *RelayOpenResult) GetChannelId() string { @@ -10605,7 +11398,7 @@ type RelayClose struct { func (x *RelayClose) Reset() { *x = RelayClose{} - mi := &file_openshell_proto_msgTypes[147] + mi := &file_openshell_proto_msgTypes[161] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10617,7 +11410,7 @@ func (x *RelayClose) String() string { func (*RelayClose) ProtoMessage() {} func (x *RelayClose) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[147] + mi := &file_openshell_proto_msgTypes[161] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10630,7 +11423,7 @@ func (x *RelayClose) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayClose.ProtoReflect.Descriptor instead. func (*RelayClose) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{147} + return file_openshell_proto_rawDescGZIP(), []int{161} } func (x *RelayClose) GetChannelId() string { @@ -10664,7 +11457,7 @@ type L7RequestSample struct { func (x *L7RequestSample) Reset() { *x = L7RequestSample{} - mi := &file_openshell_proto_msgTypes[148] + mi := &file_openshell_proto_msgTypes[162] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10676,7 +11469,7 @@ func (x *L7RequestSample) String() string { func (*L7RequestSample) ProtoMessage() {} func (x *L7RequestSample) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[148] + mi := &file_openshell_proto_msgTypes[162] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10689,7 +11482,7 @@ func (x *L7RequestSample) ProtoReflect() protoreflect.Message { // Deprecated: Use L7RequestSample.ProtoReflect.Descriptor instead. func (*L7RequestSample) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{148} + return file_openshell_proto_rawDescGZIP(), []int{162} } func (x *L7RequestSample) GetMethod() string { @@ -10763,7 +11556,7 @@ type DenialSummary struct { func (x *DenialSummary) Reset() { *x = DenialSummary{} - mi := &file_openshell_proto_msgTypes[149] + mi := &file_openshell_proto_msgTypes[163] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10775,7 +11568,7 @@ func (x *DenialSummary) String() string { func (*DenialSummary) ProtoMessage() {} func (x *DenialSummary) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[149] + mi := &file_openshell_proto_msgTypes[163] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10788,7 +11581,7 @@ func (x *DenialSummary) ProtoReflect() protoreflect.Message { // Deprecated: Use DenialSummary.ProtoReflect.Descriptor instead. func (*DenialSummary) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{149} + return file_openshell_proto_rawDescGZIP(), []int{163} } func (x *DenialSummary) GetSandboxId() string { @@ -10923,7 +11716,7 @@ type DenialGroupCount struct { func (x *DenialGroupCount) Reset() { *x = DenialGroupCount{} - mi := &file_openshell_proto_msgTypes[150] + mi := &file_openshell_proto_msgTypes[164] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10935,7 +11728,7 @@ func (x *DenialGroupCount) String() string { func (*DenialGroupCount) ProtoMessage() {} func (x *DenialGroupCount) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[150] + mi := &file_openshell_proto_msgTypes[164] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10948,7 +11741,7 @@ func (x *DenialGroupCount) ProtoReflect() protoreflect.Message { // Deprecated: Use DenialGroupCount.ProtoReflect.Descriptor instead. func (*DenialGroupCount) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{150} + return file_openshell_proto_rawDescGZIP(), []int{164} } func (x *DenialGroupCount) GetDenyGroup() string { @@ -10981,7 +11774,7 @@ type NetworkActivitySummary struct { func (x *NetworkActivitySummary) Reset() { *x = NetworkActivitySummary{} - mi := &file_openshell_proto_msgTypes[151] + mi := &file_openshell_proto_msgTypes[165] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10993,7 +11786,7 @@ func (x *NetworkActivitySummary) String() string { func (*NetworkActivitySummary) ProtoMessage() {} func (x *NetworkActivitySummary) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[151] + mi := &file_openshell_proto_msgTypes[165] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11006,7 +11799,7 @@ func (x *NetworkActivitySummary) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkActivitySummary.ProtoReflect.Descriptor instead. func (*NetworkActivitySummary) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{151} + return file_openshell_proto_rawDescGZIP(), []int{165} } func (x *NetworkActivitySummary) GetNetworkActivityCount() uint32 { @@ -11094,7 +11887,7 @@ type PolicyChunk struct { func (x *PolicyChunk) Reset() { *x = PolicyChunk{} - mi := &file_openshell_proto_msgTypes[152] + mi := &file_openshell_proto_msgTypes[166] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11106,7 +11899,7 @@ func (x *PolicyChunk) String() string { func (*PolicyChunk) ProtoMessage() {} func (x *PolicyChunk) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[152] + mi := &file_openshell_proto_msgTypes[166] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11119,7 +11912,7 @@ func (x *PolicyChunk) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyChunk.ProtoReflect.Descriptor instead. func (*PolicyChunk) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{152} + return file_openshell_proto_rawDescGZIP(), []int{166} } func (x *PolicyChunk) GetId() string { @@ -11307,7 +12100,7 @@ type DraftPolicyUpdate struct { func (x *DraftPolicyUpdate) Reset() { *x = DraftPolicyUpdate{} - mi := &file_openshell_proto_msgTypes[153] + mi := &file_openshell_proto_msgTypes[167] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11319,7 +12112,7 @@ func (x *DraftPolicyUpdate) String() string { func (*DraftPolicyUpdate) ProtoMessage() {} func (x *DraftPolicyUpdate) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[153] + mi := &file_openshell_proto_msgTypes[167] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11332,7 +12125,7 @@ func (x *DraftPolicyUpdate) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftPolicyUpdate.ProtoReflect.Descriptor instead. func (*DraftPolicyUpdate) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{153} + return file_openshell_proto_rawDescGZIP(), []int{167} } func (x *DraftPolicyUpdate) GetDraftVersion() uint64 { @@ -11390,7 +12183,7 @@ type SubmitPolicyAnalysisRequest struct { func (x *SubmitPolicyAnalysisRequest) Reset() { *x = SubmitPolicyAnalysisRequest{} - mi := &file_openshell_proto_msgTypes[154] + mi := &file_openshell_proto_msgTypes[168] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11402,7 +12195,7 @@ func (x *SubmitPolicyAnalysisRequest) String() string { func (*SubmitPolicyAnalysisRequest) ProtoMessage() {} func (x *SubmitPolicyAnalysisRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[154] + mi := &file_openshell_proto_msgTypes[168] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11415,7 +12208,7 @@ func (x *SubmitPolicyAnalysisRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitPolicyAnalysisRequest.ProtoReflect.Descriptor instead. func (*SubmitPolicyAnalysisRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{154} + return file_openshell_proto_rawDescGZIP(), []int{168} } func (x *SubmitPolicyAnalysisRequest) GetSummaries() []*DenialSummary { @@ -11478,7 +12271,7 @@ type SubmitPolicyAnalysisResponse struct { func (x *SubmitPolicyAnalysisResponse) Reset() { *x = SubmitPolicyAnalysisResponse{} - mi := &file_openshell_proto_msgTypes[155] + mi := &file_openshell_proto_msgTypes[169] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11490,7 +12283,7 @@ func (x *SubmitPolicyAnalysisResponse) String() string { func (*SubmitPolicyAnalysisResponse) ProtoMessage() {} func (x *SubmitPolicyAnalysisResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[155] + mi := &file_openshell_proto_msgTypes[169] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11503,7 +12296,7 @@ func (x *SubmitPolicyAnalysisResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitPolicyAnalysisResponse.ProtoReflect.Descriptor instead. func (*SubmitPolicyAnalysisResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{155} + return file_openshell_proto_rawDescGZIP(), []int{169} } func (x *SubmitPolicyAnalysisResponse) GetAcceptedChunks() uint32 { @@ -11549,7 +12342,7 @@ type GetDraftPolicyRequest struct { func (x *GetDraftPolicyRequest) Reset() { *x = GetDraftPolicyRequest{} - mi := &file_openshell_proto_msgTypes[156] + mi := &file_openshell_proto_msgTypes[170] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11561,7 +12354,7 @@ func (x *GetDraftPolicyRequest) String() string { func (*GetDraftPolicyRequest) ProtoMessage() {} func (x *GetDraftPolicyRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[156] + mi := &file_openshell_proto_msgTypes[170] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11574,7 +12367,7 @@ func (x *GetDraftPolicyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftPolicyRequest.ProtoReflect.Descriptor instead. func (*GetDraftPolicyRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{156} + return file_openshell_proto_rawDescGZIP(), []int{170} } func (x *GetDraftPolicyRequest) GetName() string { @@ -11614,7 +12407,7 @@ type GetDraftPolicyResponse struct { func (x *GetDraftPolicyResponse) Reset() { *x = GetDraftPolicyResponse{} - mi := &file_openshell_proto_msgTypes[157] + mi := &file_openshell_proto_msgTypes[171] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11626,7 +12419,7 @@ func (x *GetDraftPolicyResponse) String() string { func (*GetDraftPolicyResponse) ProtoMessage() {} func (x *GetDraftPolicyResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[157] + mi := &file_openshell_proto_msgTypes[171] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11639,7 +12432,7 @@ func (x *GetDraftPolicyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftPolicyResponse.ProtoReflect.Descriptor instead. func (*GetDraftPolicyResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{157} + return file_openshell_proto_rawDescGZIP(), []int{171} } func (x *GetDraftPolicyResponse) GetChunks() []*PolicyChunk { @@ -11688,7 +12481,7 @@ type ApproveDraftChunkRequest struct { func (x *ApproveDraftChunkRequest) Reset() { *x = ApproveDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[158] + mi := &file_openshell_proto_msgTypes[172] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11700,7 +12493,7 @@ func (x *ApproveDraftChunkRequest) String() string { func (*ApproveDraftChunkRequest) ProtoMessage() {} func (x *ApproveDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[158] + mi := &file_openshell_proto_msgTypes[172] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11713,7 +12506,7 @@ func (x *ApproveDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveDraftChunkRequest.ProtoReflect.Descriptor instead. func (*ApproveDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{158} + return file_openshell_proto_rawDescGZIP(), []int{172} } func (x *ApproveDraftChunkRequest) GetName() string { @@ -11756,7 +12549,7 @@ type ApproveDraftChunkResponse struct { func (x *ApproveDraftChunkResponse) Reset() { *x = ApproveDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[159] + mi := &file_openshell_proto_msgTypes[173] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11768,7 +12561,7 @@ func (x *ApproveDraftChunkResponse) String() string { func (*ApproveDraftChunkResponse) ProtoMessage() {} func (x *ApproveDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[159] + mi := &file_openshell_proto_msgTypes[173] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11781,7 +12574,7 @@ func (x *ApproveDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveDraftChunkResponse.ProtoReflect.Descriptor instead. func (*ApproveDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{159} + return file_openshell_proto_rawDescGZIP(), []int{173} } func (x *ApproveDraftChunkResponse) GetPolicyVersion() uint32 { @@ -11815,7 +12608,7 @@ type RejectDraftChunkRequest struct { func (x *RejectDraftChunkRequest) Reset() { *x = RejectDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[160] + mi := &file_openshell_proto_msgTypes[174] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11827,7 +12620,7 @@ func (x *RejectDraftChunkRequest) String() string { func (*RejectDraftChunkRequest) ProtoMessage() {} func (x *RejectDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[160] + mi := &file_openshell_proto_msgTypes[174] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11840,7 +12633,7 @@ func (x *RejectDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RejectDraftChunkRequest.ProtoReflect.Descriptor instead. func (*RejectDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{160} + return file_openshell_proto_rawDescGZIP(), []int{174} } func (x *RejectDraftChunkRequest) GetName() string { @@ -11879,7 +12672,7 @@ type RejectDraftChunkResponse struct { func (x *RejectDraftChunkResponse) Reset() { *x = RejectDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[161] + mi := &file_openshell_proto_msgTypes[175] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11891,7 +12684,7 @@ func (x *RejectDraftChunkResponse) String() string { func (*RejectDraftChunkResponse) ProtoMessage() {} func (x *RejectDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[161] + mi := &file_openshell_proto_msgTypes[175] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11904,7 +12697,7 @@ func (x *RejectDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RejectDraftChunkResponse.ProtoReflect.Descriptor instead. func (*RejectDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{161} + return file_openshell_proto_rawDescGZIP(), []int{175} } // Approve all pending chunks. @@ -11918,7 +12711,7 @@ type DraftChunkApproval struct { func (x *DraftChunkApproval) Reset() { *x = DraftChunkApproval{} - mi := &file_openshell_proto_msgTypes[162] + mi := &file_openshell_proto_msgTypes[176] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11930,7 +12723,7 @@ func (x *DraftChunkApproval) String() string { func (*DraftChunkApproval) ProtoMessage() {} func (x *DraftChunkApproval) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[162] + mi := &file_openshell_proto_msgTypes[176] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11943,7 +12736,7 @@ func (x *DraftChunkApproval) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftChunkApproval.ProtoReflect.Descriptor instead. func (*DraftChunkApproval) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{162} + return file_openshell_proto_rawDescGZIP(), []int{176} } func (x *DraftChunkApproval) GetChunkId() string { @@ -11977,7 +12770,7 @@ type ApproveAllDraftChunksRequest struct { func (x *ApproveAllDraftChunksRequest) Reset() { *x = ApproveAllDraftChunksRequest{} - mi := &file_openshell_proto_msgTypes[163] + mi := &file_openshell_proto_msgTypes[177] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11989,7 +12782,7 @@ func (x *ApproveAllDraftChunksRequest) String() string { func (*ApproveAllDraftChunksRequest) ProtoMessage() {} func (x *ApproveAllDraftChunksRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[163] + mi := &file_openshell_proto_msgTypes[177] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12002,7 +12795,7 @@ func (x *ApproveAllDraftChunksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveAllDraftChunksRequest.ProtoReflect.Descriptor instead. func (*ApproveAllDraftChunksRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{163} + return file_openshell_proto_rawDescGZIP(), []int{177} } func (x *ApproveAllDraftChunksRequest) GetName() string { @@ -12050,7 +12843,7 @@ type ApproveAllDraftChunksResponse struct { func (x *ApproveAllDraftChunksResponse) Reset() { *x = ApproveAllDraftChunksResponse{} - mi := &file_openshell_proto_msgTypes[164] + mi := &file_openshell_proto_msgTypes[178] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12062,7 +12855,7 @@ func (x *ApproveAllDraftChunksResponse) String() string { func (*ApproveAllDraftChunksResponse) ProtoMessage() {} func (x *ApproveAllDraftChunksResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[164] + mi := &file_openshell_proto_msgTypes[178] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12075,7 +12868,7 @@ func (x *ApproveAllDraftChunksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveAllDraftChunksResponse.ProtoReflect.Descriptor instead. func (*ApproveAllDraftChunksResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{164} + return file_openshell_proto_rawDescGZIP(), []int{178} } func (x *ApproveAllDraftChunksResponse) GetPolicyVersion() uint32 { @@ -12123,7 +12916,7 @@ type EditDraftChunkRequest struct { func (x *EditDraftChunkRequest) Reset() { *x = EditDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[165] + mi := &file_openshell_proto_msgTypes[179] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12135,7 +12928,7 @@ func (x *EditDraftChunkRequest) String() string { func (*EditDraftChunkRequest) ProtoMessage() {} func (x *EditDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[165] + mi := &file_openshell_proto_msgTypes[179] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12148,7 +12941,7 @@ func (x *EditDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EditDraftChunkRequest.ProtoReflect.Descriptor instead. func (*EditDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{165} + return file_openshell_proto_rawDescGZIP(), []int{179} } func (x *EditDraftChunkRequest) GetName() string { @@ -12187,7 +12980,7 @@ type EditDraftChunkResponse struct { func (x *EditDraftChunkResponse) Reset() { *x = EditDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[166] + mi := &file_openshell_proto_msgTypes[180] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12199,7 +12992,7 @@ func (x *EditDraftChunkResponse) String() string { func (*EditDraftChunkResponse) ProtoMessage() {} func (x *EditDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[166] + mi := &file_openshell_proto_msgTypes[180] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12212,7 +13005,7 @@ func (x *EditDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EditDraftChunkResponse.ProtoReflect.Descriptor instead. func (*EditDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{166} + return file_openshell_proto_rawDescGZIP(), []int{180} } // Reverse an approval (remove merged rule from active policy). @@ -12230,7 +13023,7 @@ type UndoDraftChunkRequest struct { func (x *UndoDraftChunkRequest) Reset() { *x = UndoDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[167] + mi := &file_openshell_proto_msgTypes[181] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12242,7 +13035,7 @@ func (x *UndoDraftChunkRequest) String() string { func (*UndoDraftChunkRequest) ProtoMessage() {} func (x *UndoDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[167] + mi := &file_openshell_proto_msgTypes[181] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12255,7 +13048,7 @@ func (x *UndoDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UndoDraftChunkRequest.ProtoReflect.Descriptor instead. func (*UndoDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{167} + return file_openshell_proto_rawDescGZIP(), []int{181} } func (x *UndoDraftChunkRequest) GetName() string { @@ -12291,7 +13084,7 @@ type UndoDraftChunkResponse struct { func (x *UndoDraftChunkResponse) Reset() { *x = UndoDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[168] + mi := &file_openshell_proto_msgTypes[182] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12303,7 +13096,7 @@ func (x *UndoDraftChunkResponse) String() string { func (*UndoDraftChunkResponse) ProtoMessage() {} func (x *UndoDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[168] + mi := &file_openshell_proto_msgTypes[182] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12316,7 +13109,7 @@ func (x *UndoDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UndoDraftChunkResponse.ProtoReflect.Descriptor instead. func (*UndoDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{168} + return file_openshell_proto_rawDescGZIP(), []int{182} } func (x *UndoDraftChunkResponse) GetPolicyVersion() uint32 { @@ -12346,7 +13139,7 @@ type ClearDraftChunksRequest struct { func (x *ClearDraftChunksRequest) Reset() { *x = ClearDraftChunksRequest{} - mi := &file_openshell_proto_msgTypes[169] + mi := &file_openshell_proto_msgTypes[183] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12358,7 +13151,7 @@ func (x *ClearDraftChunksRequest) String() string { func (*ClearDraftChunksRequest) ProtoMessage() {} func (x *ClearDraftChunksRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[169] + mi := &file_openshell_proto_msgTypes[183] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12371,7 +13164,7 @@ func (x *ClearDraftChunksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ClearDraftChunksRequest.ProtoReflect.Descriptor instead. func (*ClearDraftChunksRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{169} + return file_openshell_proto_rawDescGZIP(), []int{183} } func (x *ClearDraftChunksRequest) GetName() string { @@ -12398,7 +13191,7 @@ type ClearDraftChunksResponse struct { func (x *ClearDraftChunksResponse) Reset() { *x = ClearDraftChunksResponse{} - mi := &file_openshell_proto_msgTypes[170] + mi := &file_openshell_proto_msgTypes[184] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12410,7 +13203,7 @@ func (x *ClearDraftChunksResponse) String() string { func (*ClearDraftChunksResponse) ProtoMessage() {} func (x *ClearDraftChunksResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[170] + mi := &file_openshell_proto_msgTypes[184] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12423,7 +13216,7 @@ func (x *ClearDraftChunksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ClearDraftChunksResponse.ProtoReflect.Descriptor instead. func (*ClearDraftChunksResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{170} + return file_openshell_proto_rawDescGZIP(), []int{184} } func (x *ClearDraftChunksResponse) GetChunksCleared() uint32 { @@ -12446,7 +13239,7 @@ type GetDraftHistoryRequest struct { func (x *GetDraftHistoryRequest) Reset() { *x = GetDraftHistoryRequest{} - mi := &file_openshell_proto_msgTypes[171] + mi := &file_openshell_proto_msgTypes[185] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12458,7 +13251,7 @@ func (x *GetDraftHistoryRequest) String() string { func (*GetDraftHistoryRequest) ProtoMessage() {} func (x *GetDraftHistoryRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[171] + mi := &file_openshell_proto_msgTypes[185] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12471,7 +13264,7 @@ func (x *GetDraftHistoryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftHistoryRequest.ProtoReflect.Descriptor instead. func (*GetDraftHistoryRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{171} + return file_openshell_proto_rawDescGZIP(), []int{185} } func (x *GetDraftHistoryRequest) GetName() string { @@ -12505,7 +13298,7 @@ type DraftHistoryEntry struct { func (x *DraftHistoryEntry) Reset() { *x = DraftHistoryEntry{} - mi := &file_openshell_proto_msgTypes[172] + mi := &file_openshell_proto_msgTypes[186] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12517,7 +13310,7 @@ func (x *DraftHistoryEntry) String() string { func (*DraftHistoryEntry) ProtoMessage() {} func (x *DraftHistoryEntry) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[172] + mi := &file_openshell_proto_msgTypes[186] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12530,7 +13323,7 @@ func (x *DraftHistoryEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftHistoryEntry.ProtoReflect.Descriptor instead. func (*DraftHistoryEntry) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{172} + return file_openshell_proto_rawDescGZIP(), []int{186} } func (x *DraftHistoryEntry) GetTimestampMs() int64 { @@ -12571,7 +13364,7 @@ type GetDraftHistoryResponse struct { func (x *GetDraftHistoryResponse) Reset() { *x = GetDraftHistoryResponse{} - mi := &file_openshell_proto_msgTypes[173] + mi := &file_openshell_proto_msgTypes[187] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12583,7 +13376,7 @@ func (x *GetDraftHistoryResponse) String() string { func (*GetDraftHistoryResponse) ProtoMessage() {} func (x *GetDraftHistoryResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[173] + mi := &file_openshell_proto_msgTypes[187] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12596,7 +13389,7 @@ func (x *GetDraftHistoryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftHistoryResponse.ProtoReflect.Descriptor instead. func (*GetDraftHistoryResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{173} + return file_openshell_proto_rawDescGZIP(), []int{187} } func (x *GetDraftHistoryResponse) GetEntries() []*DraftHistoryEntry { @@ -12625,7 +13418,7 @@ type PolicyRevisionPayload struct { func (x *PolicyRevisionPayload) Reset() { *x = PolicyRevisionPayload{} - mi := &file_openshell_proto_msgTypes[174] + mi := &file_openshell_proto_msgTypes[188] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12637,7 +13430,7 @@ func (x *PolicyRevisionPayload) String() string { func (*PolicyRevisionPayload) ProtoMessage() {} func (x *PolicyRevisionPayload) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[174] + mi := &file_openshell_proto_msgTypes[188] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12650,7 +13443,7 @@ func (x *PolicyRevisionPayload) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyRevisionPayload.ProtoReflect.Descriptor instead. func (*PolicyRevisionPayload) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{174} + return file_openshell_proto_rawDescGZIP(), []int{188} } func (x *PolicyRevisionPayload) GetPolicy() *sandboxv1.SandboxPolicy { @@ -12729,7 +13522,7 @@ type DraftChunkPayload struct { func (x *DraftChunkPayload) Reset() { *x = DraftChunkPayload{} - mi := &file_openshell_proto_msgTypes[175] + mi := &file_openshell_proto_msgTypes[189] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12741,7 +13534,7 @@ func (x *DraftChunkPayload) String() string { func (*DraftChunkPayload) ProtoMessage() {} func (x *DraftChunkPayload) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[175] + mi := &file_openshell_proto_msgTypes[189] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12754,7 +13547,7 @@ func (x *DraftChunkPayload) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftChunkPayload.ProtoReflect.Descriptor instead. func (*DraftChunkPayload) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{175} + return file_openshell_proto_rawDescGZIP(), []int{189} } func (x *DraftChunkPayload) GetRuleName() string { @@ -12902,7 +13695,7 @@ type StoredPolicyRevision struct { func (x *StoredPolicyRevision) Reset() { *x = StoredPolicyRevision{} - mi := &file_openshell_proto_msgTypes[176] + mi := &file_openshell_proto_msgTypes[190] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12914,7 +13707,7 @@ func (x *StoredPolicyRevision) String() string { func (*StoredPolicyRevision) ProtoMessage() {} func (x *StoredPolicyRevision) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[176] + mi := &file_openshell_proto_msgTypes[190] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12927,7 +13720,7 @@ func (x *StoredPolicyRevision) ProtoReflect() protoreflect.Message { // Deprecated: Use StoredPolicyRevision.ProtoReflect.Descriptor instead. func (*StoredPolicyRevision) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{176} + return file_openshell_proto_rawDescGZIP(), []int{190} } func (x *StoredPolicyRevision) GetId() string { @@ -13036,7 +13829,7 @@ type StoredDraftChunk struct { func (x *StoredDraftChunk) Reset() { *x = StoredDraftChunk{} - mi := &file_openshell_proto_msgTypes[177] + mi := &file_openshell_proto_msgTypes[191] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13048,7 +13841,7 @@ func (x *StoredDraftChunk) String() string { func (*StoredDraftChunk) ProtoMessage() {} func (x *StoredDraftChunk) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[177] + mi := &file_openshell_proto_msgTypes[191] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13061,7 +13854,7 @@ func (x *StoredDraftChunk) ProtoReflect() protoreflect.Message { // Deprecated: Use StoredDraftChunk.ProtoReflect.Descriptor instead. func (*StoredDraftChunk) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{177} + return file_openshell_proto_rawDescGZIP(), []int{191} } func (x *StoredDraftChunk) GetId() string { @@ -13252,7 +14045,7 @@ type CreateWorkspaceRequest struct { func (x *CreateWorkspaceRequest) Reset() { *x = CreateWorkspaceRequest{} - mi := &file_openshell_proto_msgTypes[178] + mi := &file_openshell_proto_msgTypes[192] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13264,7 +14057,7 @@ func (x *CreateWorkspaceRequest) String() string { func (*CreateWorkspaceRequest) ProtoMessage() {} func (x *CreateWorkspaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[178] + mi := &file_openshell_proto_msgTypes[192] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13277,7 +14070,7 @@ func (x *CreateWorkspaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateWorkspaceRequest.ProtoReflect.Descriptor instead. func (*CreateWorkspaceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{178} + return file_openshell_proto_rawDescGZIP(), []int{192} } func (x *CreateWorkspaceRequest) GetName() string { @@ -13304,7 +14097,7 @@ type CreateWorkspaceResponse struct { func (x *CreateWorkspaceResponse) Reset() { *x = CreateWorkspaceResponse{} - mi := &file_openshell_proto_msgTypes[179] + mi := &file_openshell_proto_msgTypes[193] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13316,7 +14109,7 @@ func (x *CreateWorkspaceResponse) String() string { func (*CreateWorkspaceResponse) ProtoMessage() {} func (x *CreateWorkspaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[179] + mi := &file_openshell_proto_msgTypes[193] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13329,7 +14122,7 @@ func (x *CreateWorkspaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateWorkspaceResponse.ProtoReflect.Descriptor instead. func (*CreateWorkspaceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{179} + return file_openshell_proto_rawDescGZIP(), []int{193} } func (x *CreateWorkspaceResponse) GetWorkspace() *datamodelv1.Workspace { @@ -13350,7 +14143,7 @@ type GetWorkspaceRequest struct { func (x *GetWorkspaceRequest) Reset() { *x = GetWorkspaceRequest{} - mi := &file_openshell_proto_msgTypes[180] + mi := &file_openshell_proto_msgTypes[194] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13362,7 +14155,7 @@ func (x *GetWorkspaceRequest) String() string { func (*GetWorkspaceRequest) ProtoMessage() {} func (x *GetWorkspaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[180] + mi := &file_openshell_proto_msgTypes[194] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13375,7 +14168,7 @@ func (x *GetWorkspaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetWorkspaceRequest.ProtoReflect.Descriptor instead. func (*GetWorkspaceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{180} + return file_openshell_proto_rawDescGZIP(), []int{194} } func (x *GetWorkspaceRequest) GetName() string { @@ -13395,7 +14188,7 @@ type GetWorkspaceResponse struct { func (x *GetWorkspaceResponse) Reset() { *x = GetWorkspaceResponse{} - mi := &file_openshell_proto_msgTypes[181] + mi := &file_openshell_proto_msgTypes[195] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13407,7 +14200,7 @@ func (x *GetWorkspaceResponse) String() string { func (*GetWorkspaceResponse) ProtoMessage() {} func (x *GetWorkspaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[181] + mi := &file_openshell_proto_msgTypes[195] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13420,7 +14213,7 @@ func (x *GetWorkspaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetWorkspaceResponse.ProtoReflect.Descriptor instead. func (*GetWorkspaceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{181} + return file_openshell_proto_rawDescGZIP(), []int{195} } func (x *GetWorkspaceResponse) GetWorkspace() *datamodelv1.Workspace { @@ -13443,7 +14236,7 @@ type ListWorkspacesRequest struct { func (x *ListWorkspacesRequest) Reset() { *x = ListWorkspacesRequest{} - mi := &file_openshell_proto_msgTypes[182] + mi := &file_openshell_proto_msgTypes[196] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13455,7 +14248,7 @@ func (x *ListWorkspacesRequest) String() string { func (*ListWorkspacesRequest) ProtoMessage() {} func (x *ListWorkspacesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[182] + mi := &file_openshell_proto_msgTypes[196] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13468,7 +14261,7 @@ func (x *ListWorkspacesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspacesRequest.ProtoReflect.Descriptor instead. func (*ListWorkspacesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{182} + return file_openshell_proto_rawDescGZIP(), []int{196} } func (x *ListWorkspacesRequest) GetLimit() uint32 { @@ -13502,7 +14295,7 @@ type ListWorkspacesResponse struct { func (x *ListWorkspacesResponse) Reset() { *x = ListWorkspacesResponse{} - mi := &file_openshell_proto_msgTypes[183] + mi := &file_openshell_proto_msgTypes[197] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13514,7 +14307,7 @@ func (x *ListWorkspacesResponse) String() string { func (*ListWorkspacesResponse) ProtoMessage() {} func (x *ListWorkspacesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[183] + mi := &file_openshell_proto_msgTypes[197] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13527,7 +14320,7 @@ func (x *ListWorkspacesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspacesResponse.ProtoReflect.Descriptor instead. func (*ListWorkspacesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{183} + return file_openshell_proto_rawDescGZIP(), []int{197} } func (x *ListWorkspacesResponse) GetWorkspaces() []*datamodelv1.Workspace { @@ -13548,7 +14341,7 @@ type DeleteWorkspaceRequest struct { func (x *DeleteWorkspaceRequest) Reset() { *x = DeleteWorkspaceRequest{} - mi := &file_openshell_proto_msgTypes[184] + mi := &file_openshell_proto_msgTypes[198] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13560,7 +14353,7 @@ func (x *DeleteWorkspaceRequest) String() string { func (*DeleteWorkspaceRequest) ProtoMessage() {} func (x *DeleteWorkspaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[184] + mi := &file_openshell_proto_msgTypes[198] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13573,7 +14366,7 @@ func (x *DeleteWorkspaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteWorkspaceRequest.ProtoReflect.Descriptor instead. func (*DeleteWorkspaceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{184} + return file_openshell_proto_rawDescGZIP(), []int{198} } func (x *DeleteWorkspaceRequest) GetName() string { @@ -13593,7 +14386,7 @@ type DeleteWorkspaceResponse struct { func (x *DeleteWorkspaceResponse) Reset() { *x = DeleteWorkspaceResponse{} - mi := &file_openshell_proto_msgTypes[185] + mi := &file_openshell_proto_msgTypes[199] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13605,7 +14398,7 @@ func (x *DeleteWorkspaceResponse) String() string { func (*DeleteWorkspaceResponse) ProtoMessage() {} func (x *DeleteWorkspaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[185] + mi := &file_openshell_proto_msgTypes[199] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13618,7 +14411,7 @@ func (x *DeleteWorkspaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteWorkspaceResponse.ProtoReflect.Descriptor instead. func (*DeleteWorkspaceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{185} + return file_openshell_proto_rawDescGZIP(), []int{199} } func (x *DeleteWorkspaceResponse) GetDeleted() bool { @@ -13642,7 +14435,7 @@ type WorkspaceMember struct { func (x *WorkspaceMember) Reset() { *x = WorkspaceMember{} - mi := &file_openshell_proto_msgTypes[186] + mi := &file_openshell_proto_msgTypes[200] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13654,7 +14447,7 @@ func (x *WorkspaceMember) String() string { func (*WorkspaceMember) ProtoMessage() {} func (x *WorkspaceMember) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[186] + mi := &file_openshell_proto_msgTypes[200] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13667,7 +14460,7 @@ func (x *WorkspaceMember) ProtoReflect() protoreflect.Message { // Deprecated: Use WorkspaceMember.ProtoReflect.Descriptor instead. func (*WorkspaceMember) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{186} + return file_openshell_proto_rawDescGZIP(), []int{200} } func (x *WorkspaceMember) GetMetadata() *datamodelv1.ObjectMeta { @@ -13706,7 +14499,7 @@ type AddWorkspaceMemberRequest struct { func (x *AddWorkspaceMemberRequest) Reset() { *x = AddWorkspaceMemberRequest{} - mi := &file_openshell_proto_msgTypes[187] + mi := &file_openshell_proto_msgTypes[201] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13718,7 +14511,7 @@ func (x *AddWorkspaceMemberRequest) String() string { func (*AddWorkspaceMemberRequest) ProtoMessage() {} func (x *AddWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[187] + mi := &file_openshell_proto_msgTypes[201] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13731,7 +14524,7 @@ func (x *AddWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AddWorkspaceMemberRequest.ProtoReflect.Descriptor instead. func (*AddWorkspaceMemberRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{187} + return file_openshell_proto_rawDescGZIP(), []int{201} } func (x *AddWorkspaceMemberRequest) GetWorkspace() string { @@ -13765,7 +14558,7 @@ type AddWorkspaceMemberResponse struct { func (x *AddWorkspaceMemberResponse) Reset() { *x = AddWorkspaceMemberResponse{} - mi := &file_openshell_proto_msgTypes[188] + mi := &file_openshell_proto_msgTypes[202] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13777,7 +14570,7 @@ func (x *AddWorkspaceMemberResponse) String() string { func (*AddWorkspaceMemberResponse) ProtoMessage() {} func (x *AddWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[188] + mi := &file_openshell_proto_msgTypes[202] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13790,7 +14583,7 @@ func (x *AddWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AddWorkspaceMemberResponse.ProtoReflect.Descriptor instead. func (*AddWorkspaceMemberResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{188} + return file_openshell_proto_rawDescGZIP(), []int{202} } func (x *AddWorkspaceMemberResponse) GetMember() *WorkspaceMember { @@ -13813,7 +14606,7 @@ type RemoveWorkspaceMemberRequest struct { func (x *RemoveWorkspaceMemberRequest) Reset() { *x = RemoveWorkspaceMemberRequest{} - mi := &file_openshell_proto_msgTypes[189] + mi := &file_openshell_proto_msgTypes[203] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13825,7 +14618,7 @@ func (x *RemoveWorkspaceMemberRequest) String() string { func (*RemoveWorkspaceMemberRequest) ProtoMessage() {} func (x *RemoveWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[189] + mi := &file_openshell_proto_msgTypes[203] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13838,7 +14631,7 @@ func (x *RemoveWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveWorkspaceMemberRequest.ProtoReflect.Descriptor instead. func (*RemoveWorkspaceMemberRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{189} + return file_openshell_proto_rawDescGZIP(), []int{203} } func (x *RemoveWorkspaceMemberRequest) GetWorkspace() string { @@ -13865,7 +14658,7 @@ type RemoveWorkspaceMemberResponse struct { func (x *RemoveWorkspaceMemberResponse) Reset() { *x = RemoveWorkspaceMemberResponse{} - mi := &file_openshell_proto_msgTypes[190] + mi := &file_openshell_proto_msgTypes[204] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13877,7 +14670,7 @@ func (x *RemoveWorkspaceMemberResponse) String() string { func (*RemoveWorkspaceMemberResponse) ProtoMessage() {} func (x *RemoveWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[190] + mi := &file_openshell_proto_msgTypes[204] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13890,7 +14683,7 @@ func (x *RemoveWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveWorkspaceMemberResponse.ProtoReflect.Descriptor instead. func (*RemoveWorkspaceMemberResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{190} + return file_openshell_proto_rawDescGZIP(), []int{204} } func (x *RemoveWorkspaceMemberResponse) GetRemoved() bool { @@ -13913,7 +14706,7 @@ type ListWorkspaceMembersRequest struct { func (x *ListWorkspaceMembersRequest) Reset() { *x = ListWorkspaceMembersRequest{} - mi := &file_openshell_proto_msgTypes[191] + mi := &file_openshell_proto_msgTypes[205] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13925,7 +14718,7 @@ func (x *ListWorkspaceMembersRequest) String() string { func (*ListWorkspaceMembersRequest) ProtoMessage() {} func (x *ListWorkspaceMembersRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[191] + mi := &file_openshell_proto_msgTypes[205] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13938,7 +14731,7 @@ func (x *ListWorkspaceMembersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspaceMembersRequest.ProtoReflect.Descriptor instead. func (*ListWorkspaceMembersRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{191} + return file_openshell_proto_rawDescGZIP(), []int{205} } func (x *ListWorkspaceMembersRequest) GetWorkspace() string { @@ -13972,7 +14765,7 @@ type ListWorkspaceMembersResponse struct { func (x *ListWorkspaceMembersResponse) Reset() { *x = ListWorkspaceMembersResponse{} - mi := &file_openshell_proto_msgTypes[192] + mi := &file_openshell_proto_msgTypes[206] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13984,7 +14777,7 @@ func (x *ListWorkspaceMembersResponse) String() string { func (*ListWorkspaceMembersResponse) ProtoMessage() {} func (x *ListWorkspaceMembersResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[192] + mi := &file_openshell_proto_msgTypes[206] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13997,7 +14790,7 @@ func (x *ListWorkspaceMembersResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspaceMembersResponse.ProtoReflect.Descriptor instead. func (*ListWorkspaceMembersResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{192} + return file_openshell_proto_rawDescGZIP(), []int{206} } func (x *ListWorkspaceMembersResponse) GetMembers() []*WorkspaceMember { @@ -14025,7 +14818,7 @@ type ExtensionServiceCredential struct { func (x *ExtensionServiceCredential) Reset() { *x = ExtensionServiceCredential{} - mi := &file_openshell_proto_msgTypes[193] + mi := &file_openshell_proto_msgTypes[207] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14037,7 +14830,7 @@ func (x *ExtensionServiceCredential) String() string { func (*ExtensionServiceCredential) ProtoMessage() {} func (x *ExtensionServiceCredential) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[193] + mi := &file_openshell_proto_msgTypes[207] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14050,7 +14843,7 @@ func (x *ExtensionServiceCredential) ProtoReflect() protoreflect.Message { // Deprecated: Use ExtensionServiceCredential.ProtoReflect.Descriptor instead. func (*ExtensionServiceCredential) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{193} + return file_openshell_proto_rawDescGZIP(), []int{207} } func (x *ExtensionServiceCredential) GetServiceName() string { @@ -14078,7 +14871,7 @@ var File_openshell_proto protoreflect.FileDescriptor const file_openshell_proto_rawDesc = "" + "\n" + - "\x0fopenshell.proto\x12\fopenshell.v1\x1a\x0fdatamodel.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\roptions.proto\x1a\rsandbox.proto\"\x1a\n" + + "\x0fopenshell.proto\x12\fopenshell.v1\x1a\x0fdatamodel.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\roptions.proto\x1a\rsandbox.proto\"\x1a\n" + "\x18IssueSandboxTokenRequest\"[\n" + "\x19IssueSandboxTokenResponse\x12\x1a\n" + "\x05token\x18\x01 \x01(\tB\x04\x88\xb5\x18\x01R\x05token\x12\"\n" + @@ -14111,11 +14904,12 @@ const file_openshell_proto_rawDesc = "" + "\x19ComputeDriverCapabilities\x12\x1f\n" + "\vdriver_name\x18\x01 \x01(\tR\n" + "driverName\x12%\n" + - "\x0edriver_version\x18\x02 \x01(\tR\rdriverVersion\"\xd8\x01\n" + + "\x0edriver_version\x18\x02 \x01(\tR\rdriverVersion\"\xce\x02\n" + "\aSandbox\x12>\n" + "\bmetadata\x18\x01 \x01(\v2\".openshell.datamodel.v1.ObjectMetaR\bmetadata\x12-\n" + "\x04spec\x18\x02 \x01(\v2\x19.openshell.v1.SandboxSpecR\x04spec\x123\n" + - "\x06status\x18\x03 \x01(\v2\x1b.openshell.v1.SandboxStatusR\x06statusJ\x04\b\x04\x10\x05J\x04\b\x05\x10\x06R\x05phaseR\x16current_policy_version\"\x83\x04\n" + + "\x06status\x18\x03 \x01(\v2\x1b.openshell.v1.SandboxStatusR\x06status\x12t\n" + + "\x1ecreated_from_workload_template\x18\x14 \x01(\v2/.openshell.v1.SandboxWorkloadTemplateProvenanceR\x1bcreatedFromWorkloadTemplateJ\x04\b\x04\x10\x05J\x04\b\x05\x10\x06R\x05phaseR\x16current_policy_version\"\x83\x04\n" + "\vSandboxSpec\x12\x1b\n" + "\tlog_level\x18\x01 \x01(\tR\blogLevel\x12L\n" + "\venvironment\x18\x05 \x03(\v2*.openshell.v1.SandboxSpec.EnvironmentEntryR\venvironment\x129\n" + @@ -14156,7 +14950,33 @@ const file_openshell_proto_rawDesc = "" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x12\n" + "\x10_user_namespacesJ\x04\b\t\x10\n" + - "R\x16volume_claim_templates\"\x9a\x03\n" + + "R\x16volume_claim_templates\"\x98\x01\n" + + "\x17SandboxWorkloadTemplate\x12>\n" + + "\bmetadata\x18\x01 \x01(\v2\".openshell.datamodel.v1.ObjectMetaR\bmetadata\x12=\n" + + "\x04spec\x18\x02 \x01(\v2).openshell.v1.SandboxWorkloadTemplateSpecR\x04spec\"\xf3\x01\n" + + "\x1bSandboxWorkloadTemplateSpec\x12?\n" + + "\bworkload\x18\x01 \x01(\v2#.openshell.v1.SandboxWorkloadConfigR\bworkload\x12<\n" + + "\rdriver_config\x18\x02 \x01(\v2\x17.google.protobuf.StructR\fdriverConfig\x12U\n" + + "\x15desired_service_level\x18\x03 \x01(\v2!.openshell.v1.SandboxServiceLevelR\x13desiredServiceLevel\"\x83\x02\n" + + "\x15SandboxWorkloadConfig\x12\x14\n" + + "\x05image\x18\x01 \x01(\tR\x05image\x12V\n" + + "\venvironment\x18\x02 \x03(\v24.openshell.v1.SandboxWorkloadConfig.EnvironmentEntryR\venvironment\x12<\n" + + "\tresources\x18\x03 \x01(\v2\x1e.openshell.v1.SandboxResourcesR\tresources\x1a>\n" + + "\x10EnvironmentEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"u\n" + + "\x10SandboxResources\x12\x10\n" + + "\x03cpu\x18\x01 \x01(\tR\x03cpu\x12\x16\n" + + "\x06memory\x18\x02 \x01(\tR\x06memory\x127\n" + + "\x03gpu\x18\x03 \x01(\v2%.openshell.v1.GpuResourceRequirementsR\x03gpu\"M\n" + + "\x13SandboxServiceLevel\x126\n" + + "\astartup\x18\x01 \x01(\v2\x1c.openshell.v1.SandboxStartupR\astartup\"k\n" + + "\x0eSandboxStartup\x12<\n" + + "\fready_within\x18\x01 \x01(\v2\x19.google.protobuf.DurationR\vreadyWithin\x12\x1b\n" + + "\tmax_burst\x18\x02 \x01(\rR\bmaxBurst\"b\n" + + "!SandboxWorkloadTemplateProvenance\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12)\n" + + "\x10resource_version\x18\x02 \x01(\tR\x0fresourceVersion\"\x9a\x03\n" + "\rSandboxStatus\x12!\n" + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12\x1b\n" + "\tagent_pod\x18\x02 \x01(\tR\bagentPod\x12\x19\n" + @@ -14187,20 +15007,42 @@ const file_openshell_proto_rawDesc = "" + "\bmetadata\x18\x06 \x03(\v2).openshell.v1.PlatformEvent.MetadataEntryR\bmetadata\x1a;\n" + "\rMetadataEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xd4\x03\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x8a\x04\n" + "\x14CreateSandboxRequest\x12-\n" + "\x04spec\x18\x01 \x01(\v2\x19.openshell.v1.SandboxSpecR\x04spec\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12F\n" + "\x06labels\x18\x03 \x03(\v2..openshell.v1.CreateSandboxRequest.LabelsEntryR\x06labels\x12U\n" + "\vannotations\x18\x04 \x03(\v23.openshell.v1.CreateSandboxRequest.AnnotationsEntryR\vannotations\x12\x1c\n" + "\tworkspace\x18\x05 \x01(\tR\tworkspace\x12A\n" + - "\x1dawait_main_process_attachment\x18\x06 \x01(\bR\x1aawaitMainProcessAttachment\x1a9\n" + + "\x1dawait_main_process_attachment\x18\x06 \x01(\bR\x1aawaitMainProcessAttachment\x124\n" + + "\x16workload_template_name\x18\a \x01(\tR\x14workloadTemplateName\x1a9\n" + "\vLabelsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a>\n" + "\x10AnnotationsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"E\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x7f\n" + + "\x1cCreateSandboxTemplateRequest\x12A\n" + + "\btemplate\x18\x01 \x01(\v2%.openshell.v1.SandboxWorkloadTemplateR\btemplate\x12\x1c\n" + + "\tworkspace\x18\x02 \x01(\tR\tworkspace\"M\n" + + "\x19GetSandboxTemplateRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1c\n" + + "\tworkspace\x18\x02 \x01(\tR\tworkspace\"\xb7\x01\n" + + "\x1bListSandboxTemplatesRequest\x12\x14\n" + + "\x05limit\x18\x01 \x01(\rR\x05limit\x12\x16\n" + + "\x06offset\x18\x02 \x01(\rR\x06offset\x12\x1c\n" + + "\tworkspace\x18\x03 \x01(\tR\tworkspace\x12%\n" + + "\x0eall_workspaces\x18\x04 \x01(\bR\rallWorkspaces\x12%\n" + + "\x0elabel_selector\x18\x05 \x01(\tR\rlabelSelector\"P\n" + + "\x1cDeleteSandboxTemplateRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1c\n" + + "\tworkspace\x18\x02 \x01(\tR\tworkspace\"\\\n" + + "\x17SandboxTemplateResponse\x12A\n" + + "\btemplate\x18\x01 \x01(\v2%.openshell.v1.SandboxWorkloadTemplateR\btemplate\"c\n" + + "\x1cListSandboxTemplatesResponse\x12C\n" + + "\ttemplates\x18\x01 \x03(\v2%.openshell.v1.SandboxWorkloadTemplateR\ttemplates\"9\n" + + "\x1dDeleteSandboxTemplateResponse\x12\x18\n" + + "\adeleted\x18\x01 \x01(\bR\adeleted\"E\n" + "\x11GetSandboxRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1c\n" + "\tworkspace\x18\x02 \x01(\tR\tworkspace\"\xb0\x01\n" + @@ -15211,7 +16053,7 @@ const file_openshell_proto_rawDesc = "" + "1PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_RETRY\x10\x01\x12;\n" + "7PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_REAUTHORIZE\x10\x02\x12A\n" + "=PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_FIX_CONFIGURATION\x10\x03\x12;\n" + - "7PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_INVESTIGATE\x10\x042\xb4G\n" + + "7PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_INVESTIGATE\x10\x042\xf7K\n" + "\tOpenShell\x12Z\n" + "\x06Health\x12\x1b.openshell.v1.HealthRequest\x1a\x1c.openshell.v1.HealthResponse\"\x15\x82\xb5\x18\x11\n" + "\x0funauthenticated\x12i\n" + @@ -15225,7 +16067,15 @@ const file_openshell_proto_rawDesc = "" + "GetSandbox\x12\x1f.openshell.v1.GetSandboxRequest\x1a\x1d.openshell.v1.SandboxResponse\" \x82\xb5\x18\x1c\n" + "\x06bearer\x12\x04user\"\fsandbox:read\x12z\n" + "\rListSandboxes\x12\".openshell.v1.ListSandboxesRequest\x1a#.openshell.v1.ListSandboxesResponse\" \x82\xb5\x18\x1c\n" + + "\x06bearer\x12\x04user\"\fsandbox:read\x12\x8e\x01\n" + + "\x15CreateSandboxTemplate\x12*.openshell.v1.CreateSandboxTemplateRequest\x1a%.openshell.v1.SandboxTemplateResponse\"\"\x82\xb5\x18\x1e\n" + + "\x06bearer\x12\x05admin\"\rsandbox:write\x12\x86\x01\n" + + "\x12GetSandboxTemplate\x12'.openshell.v1.GetSandboxTemplateRequest\x1a%.openshell.v1.SandboxTemplateResponse\" \x82\xb5\x18\x1c\n" + "\x06bearer\x12\x04user\"\fsandbox:read\x12\x8f\x01\n" + + "\x14ListSandboxTemplates\x12).openshell.v1.ListSandboxTemplatesRequest\x1a*.openshell.v1.ListSandboxTemplatesResponse\" \x82\xb5\x18\x1c\n" + + "\x06bearer\x12\x04user\"\fsandbox:read\x12\x94\x01\n" + + "\x15DeleteSandboxTemplate\x12*.openshell.v1.DeleteSandboxTemplateRequest\x1a+.openshell.v1.DeleteSandboxTemplateResponse\"\"\x82\xb5\x18\x1e\n" + + "\x06bearer\x12\x05admin\"\rsandbox:write\x12\x8f\x01\n" + "\x14ListSandboxProviders\x12).openshell.v1.ListSandboxProvidersRequest\x1a*.openshell.v1.ListSandboxProvidersResponse\" \x82\xb5\x18\x1c\n" + "\x06bearer\x12\x04user\"\fsandbox:read\x12\x93\x01\n" + "\x15AttachSandboxProvider\x12*.openshell.v1.AttachSandboxProviderRequest\x1a+.openshell.v1.AttachSandboxProviderResponse\"!\x82\xb5\x18\x1d\n" + @@ -15368,7 +16218,7 @@ func file_openshell_proto_rawDescGZIP() []byte { } var file_openshell_proto_enumTypes = make([]protoimpl.EnumInfo, 8) -var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 219) +var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 234) var file_openshell_proto_goTypes = []any{ (SandboxPhase)(0), // 0: openshell.v1.SandboxPhase (ProviderCredentialTokenGrantType)(0), // 1: openshell.v1.ProviderCredentialTokenGrantType @@ -15395,535 +16245,573 @@ var file_openshell_proto_goTypes = []any{ (*ResourceRequirements)(nil), // 22: openshell.v1.ResourceRequirements (*GpuResourceRequirements)(nil), // 23: openshell.v1.GpuResourceRequirements (*SandboxTemplate)(nil), // 24: openshell.v1.SandboxTemplate - (*SandboxStatus)(nil), // 25: openshell.v1.SandboxStatus - (*SandboxCondition)(nil), // 26: openshell.v1.SandboxCondition - (*PlatformEvent)(nil), // 27: openshell.v1.PlatformEvent - (*CreateSandboxRequest)(nil), // 28: openshell.v1.CreateSandboxRequest - (*GetSandboxRequest)(nil), // 29: openshell.v1.GetSandboxRequest - (*ListSandboxesRequest)(nil), // 30: openshell.v1.ListSandboxesRequest - (*ListSandboxProvidersRequest)(nil), // 31: openshell.v1.ListSandboxProvidersRequest - (*AttachSandboxProviderRequest)(nil), // 32: openshell.v1.AttachSandboxProviderRequest - (*DetachSandboxProviderRequest)(nil), // 33: openshell.v1.DetachSandboxProviderRequest - (*DeleteSandboxRequest)(nil), // 34: openshell.v1.DeleteSandboxRequest - (*StopSandboxRequest)(nil), // 35: openshell.v1.StopSandboxRequest - (*StartSandboxRequest)(nil), // 36: openshell.v1.StartSandboxRequest - (*SandboxResponse)(nil), // 37: openshell.v1.SandboxResponse - (*ListSandboxesResponse)(nil), // 38: openshell.v1.ListSandboxesResponse - (*ListSandboxProvidersResponse)(nil), // 39: openshell.v1.ListSandboxProvidersResponse - (*AttachSandboxProviderResponse)(nil), // 40: openshell.v1.AttachSandboxProviderResponse - (*DetachSandboxProviderResponse)(nil), // 41: openshell.v1.DetachSandboxProviderResponse - (*DeleteSandboxResponse)(nil), // 42: openshell.v1.DeleteSandboxResponse - (*CreateSshSessionRequest)(nil), // 43: openshell.v1.CreateSshSessionRequest - (*CreateSshSessionResponse)(nil), // 44: openshell.v1.CreateSshSessionResponse - (*ExposeServiceRequest)(nil), // 45: openshell.v1.ExposeServiceRequest - (*GetServiceRequest)(nil), // 46: openshell.v1.GetServiceRequest - (*ListServicesRequest)(nil), // 47: openshell.v1.ListServicesRequest - (*ListServicesResponse)(nil), // 48: openshell.v1.ListServicesResponse - (*DeleteServiceRequest)(nil), // 49: openshell.v1.DeleteServiceRequest - (*DeleteServiceResponse)(nil), // 50: openshell.v1.DeleteServiceResponse - (*ServiceEndpoint)(nil), // 51: openshell.v1.ServiceEndpoint - (*ServiceEndpointResponse)(nil), // 52: openshell.v1.ServiceEndpointResponse - (*RevokeSshSessionRequest)(nil), // 53: openshell.v1.RevokeSshSessionRequest - (*RevokeSshSessionResponse)(nil), // 54: openshell.v1.RevokeSshSessionResponse - (*ExecSandboxRequest)(nil), // 55: openshell.v1.ExecSandboxRequest - (*ExecSandboxStdout)(nil), // 56: openshell.v1.ExecSandboxStdout - (*ExecSandboxStderr)(nil), // 57: openshell.v1.ExecSandboxStderr - (*ExecSandboxExit)(nil), // 58: openshell.v1.ExecSandboxExit - (*ExecSandboxEvent)(nil), // 59: openshell.v1.ExecSandboxEvent - (*TcpForwardInit)(nil), // 60: openshell.v1.TcpForwardInit - (*TcpForwardFrame)(nil), // 61: openshell.v1.TcpForwardFrame - (*ExecSandboxInput)(nil), // 62: openshell.v1.ExecSandboxInput - (*ExecSandboxWindowResize)(nil), // 63: openshell.v1.ExecSandboxWindowResize - (*SshSession)(nil), // 64: openshell.v1.SshSession - (*WatchSandboxRequest)(nil), // 65: openshell.v1.WatchSandboxRequest - (*SandboxStreamEvent)(nil), // 66: openshell.v1.SandboxStreamEvent - (*SandboxLogLine)(nil), // 67: openshell.v1.SandboxLogLine - (*SandboxStreamWarning)(nil), // 68: openshell.v1.SandboxStreamWarning - (*CreateProviderRequest)(nil), // 69: openshell.v1.CreateProviderRequest - (*GetProviderRequest)(nil), // 70: openshell.v1.GetProviderRequest - (*ListProvidersRequest)(nil), // 71: openshell.v1.ListProvidersRequest - (*UpdateProviderRequest)(nil), // 72: openshell.v1.UpdateProviderRequest - (*DeleteProviderRequest)(nil), // 73: openshell.v1.DeleteProviderRequest - (*ProviderResponse)(nil), // 74: openshell.v1.ProviderResponse - (*ListProvidersResponse)(nil), // 75: openshell.v1.ListProvidersResponse - (*ListProviderProfilesRequest)(nil), // 76: openshell.v1.ListProviderProfilesRequest - (*GetProviderProfileRequest)(nil), // 77: openshell.v1.GetProviderProfileRequest - (*ProviderProfileImportItem)(nil), // 78: openshell.v1.ProviderProfileImportItem - (*ProviderProfileDiagnostic)(nil), // 79: openshell.v1.ProviderProfileDiagnostic - (*ProviderCredentialTokenGrantAudienceOverride)(nil), // 80: openshell.v1.ProviderCredentialTokenGrantAudienceOverride - (*ProviderCredentialTokenGrantSubjectToken)(nil), // 81: openshell.v1.ProviderCredentialTokenGrantSubjectToken - (*ProviderCredentialTokenGrant)(nil), // 82: openshell.v1.ProviderCredentialTokenGrant - (*ProviderProfileCredential)(nil), // 83: openshell.v1.ProviderProfileCredential - (*ProviderCredentialRefreshMaterial)(nil), // 84: openshell.v1.ProviderCredentialRefreshMaterial - (*ProviderCredentialRefreshOutput)(nil), // 85: openshell.v1.ProviderCredentialRefreshOutput - (*ProviderCredentialRefresh)(nil), // 86: openshell.v1.ProviderCredentialRefresh - (*ProviderCredentialRefreshStatus)(nil), // 87: openshell.v1.ProviderCredentialRefreshStatus - (*ProviderProfileDiscovery)(nil), // 88: openshell.v1.ProviderProfileDiscovery - (*StoredProviderCredentialRefreshState)(nil), // 89: openshell.v1.StoredProviderCredentialRefreshState - (*StoredRefreshMaterialDeletion)(nil), // 90: openshell.v1.StoredRefreshMaterialDeletion - (*GetProviderRefreshStatusRequest)(nil), // 91: openshell.v1.GetProviderRefreshStatusRequest - (*GetProviderRefreshStatusResponse)(nil), // 92: openshell.v1.GetProviderRefreshStatusResponse - (*ConfigureProviderRefreshRequest)(nil), // 93: openshell.v1.ConfigureProviderRefreshRequest - (*ConfigureProviderRefreshResponse)(nil), // 94: openshell.v1.ConfigureProviderRefreshResponse - (*RotateProviderCredentialRequest)(nil), // 95: openshell.v1.RotateProviderCredentialRequest - (*RotateProviderCredentialResponse)(nil), // 96: openshell.v1.RotateProviderCredentialResponse - (*DeleteProviderRefreshRequest)(nil), // 97: openshell.v1.DeleteProviderRefreshRequest - (*DeleteProviderRefreshResponse)(nil), // 98: openshell.v1.DeleteProviderRefreshResponse - (*ProviderProfile)(nil), // 99: openshell.v1.ProviderProfile - (*StoredProviderProfile)(nil), // 100: openshell.v1.StoredProviderProfile - (*ProviderProfileResponse)(nil), // 101: openshell.v1.ProviderProfileResponse - (*ListProviderProfilesResponse)(nil), // 102: openshell.v1.ListProviderProfilesResponse - (*ImportProviderProfilesRequest)(nil), // 103: openshell.v1.ImportProviderProfilesRequest - (*ImportProviderProfilesResponse)(nil), // 104: openshell.v1.ImportProviderProfilesResponse - (*UpdateProviderProfilesRequest)(nil), // 105: openshell.v1.UpdateProviderProfilesRequest - (*UpdateProviderProfilesResponse)(nil), // 106: openshell.v1.UpdateProviderProfilesResponse - (*LintProviderProfilesRequest)(nil), // 107: openshell.v1.LintProviderProfilesRequest - (*LintProviderProfilesResponse)(nil), // 108: openshell.v1.LintProviderProfilesResponse - (*DeleteProviderResponse)(nil), // 109: openshell.v1.DeleteProviderResponse - (*DeleteProviderProfileRequest)(nil), // 110: openshell.v1.DeleteProviderProfileRequest - (*DeleteProviderProfileResponse)(nil), // 111: openshell.v1.DeleteProviderProfileResponse - (*GetSandboxProviderEnvironmentRequest)(nil), // 112: openshell.v1.GetSandboxProviderEnvironmentRequest - (*StaticCredentialEndpointBinding)(nil), // 113: openshell.v1.StaticCredentialEndpointBinding - (*StaticCredentialBinding)(nil), // 114: openshell.v1.StaticCredentialBinding - (*GetSandboxProviderEnvironmentResponse)(nil), // 115: openshell.v1.GetSandboxProviderEnvironmentResponse - (*ExchangeProviderSubjectTokenRequest)(nil), // 116: openshell.v1.ExchangeProviderSubjectTokenRequest - (*ExchangeProviderSubjectTokenResponse)(nil), // 117: openshell.v1.ExchangeProviderSubjectTokenResponse - (*UpdateConfigRequest)(nil), // 118: openshell.v1.UpdateConfigRequest - (*PolicyMergeOperation)(nil), // 119: openshell.v1.PolicyMergeOperation - (*AddNetworkRule)(nil), // 120: openshell.v1.AddNetworkRule - (*RemoveNetworkEndpoint)(nil), // 121: openshell.v1.RemoveNetworkEndpoint - (*RemoveNetworkRule)(nil), // 122: openshell.v1.RemoveNetworkRule - (*AddDenyRules)(nil), // 123: openshell.v1.AddDenyRules - (*AddAllowRules)(nil), // 124: openshell.v1.AddAllowRules - (*RemoveNetworkBinary)(nil), // 125: openshell.v1.RemoveNetworkBinary - (*UpdateConfigResponse)(nil), // 126: openshell.v1.UpdateConfigResponse - (*GetSandboxPolicyStatusRequest)(nil), // 127: openshell.v1.GetSandboxPolicyStatusRequest - (*GetSandboxPolicyStatusResponse)(nil), // 128: openshell.v1.GetSandboxPolicyStatusResponse - (*ListSandboxPoliciesRequest)(nil), // 129: openshell.v1.ListSandboxPoliciesRequest - (*ListSandboxPoliciesResponse)(nil), // 130: openshell.v1.ListSandboxPoliciesResponse - (*ReportPolicyStatusRequest)(nil), // 131: openshell.v1.ReportPolicyStatusRequest - (*ReportPolicyStatusResponse)(nil), // 132: openshell.v1.ReportPolicyStatusResponse - (*SandboxPolicyRevision)(nil), // 133: openshell.v1.SandboxPolicyRevision - (*GetSandboxLogsRequest)(nil), // 134: openshell.v1.GetSandboxLogsRequest - (*PushSandboxLogsRequest)(nil), // 135: openshell.v1.PushSandboxLogsRequest - (*PushSandboxLogsResponse)(nil), // 136: openshell.v1.PushSandboxLogsResponse - (*GetSandboxLogsResponse)(nil), // 137: openshell.v1.GetSandboxLogsResponse - (*SupervisorMessage)(nil), // 138: openshell.v1.SupervisorMessage - (*GatewayMessage)(nil), // 139: openshell.v1.GatewayMessage - (*SupervisorHello)(nil), // 140: openshell.v1.SupervisorHello - (*SessionAccepted)(nil), // 141: openshell.v1.SessionAccepted - (*SessionRejected)(nil), // 142: openshell.v1.SessionRejected - (*SupervisorHeartbeat)(nil), // 143: openshell.v1.SupervisorHeartbeat - (*GatewayHeartbeat)(nil), // 144: openshell.v1.GatewayHeartbeat - (*ReportMainProcessExitRequest)(nil), // 145: openshell.v1.ReportMainProcessExitRequest - (*ReportMainProcessExitResponse)(nil), // 146: openshell.v1.ReportMainProcessExitResponse - (*FinalizeMainProcessExitRequest)(nil), // 147: openshell.v1.FinalizeMainProcessExitRequest - (*FinalizeMainProcessExitResponse)(nil), // 148: openshell.v1.FinalizeMainProcessExitResponse - (*RelayOpen)(nil), // 149: openshell.v1.RelayOpen - (*SshRelayTarget)(nil), // 150: openshell.v1.SshRelayTarget - (*TcpRelayTarget)(nil), // 151: openshell.v1.TcpRelayTarget - (*RelayInit)(nil), // 152: openshell.v1.RelayInit - (*RelayFrame)(nil), // 153: openshell.v1.RelayFrame - (*RelayOpenResult)(nil), // 154: openshell.v1.RelayOpenResult - (*RelayClose)(nil), // 155: openshell.v1.RelayClose - (*L7RequestSample)(nil), // 156: openshell.v1.L7RequestSample - (*DenialSummary)(nil), // 157: openshell.v1.DenialSummary - (*DenialGroupCount)(nil), // 158: openshell.v1.DenialGroupCount - (*NetworkActivitySummary)(nil), // 159: openshell.v1.NetworkActivitySummary - (*PolicyChunk)(nil), // 160: openshell.v1.PolicyChunk - (*DraftPolicyUpdate)(nil), // 161: openshell.v1.DraftPolicyUpdate - (*SubmitPolicyAnalysisRequest)(nil), // 162: openshell.v1.SubmitPolicyAnalysisRequest - (*SubmitPolicyAnalysisResponse)(nil), // 163: openshell.v1.SubmitPolicyAnalysisResponse - (*GetDraftPolicyRequest)(nil), // 164: openshell.v1.GetDraftPolicyRequest - (*GetDraftPolicyResponse)(nil), // 165: openshell.v1.GetDraftPolicyResponse - (*ApproveDraftChunkRequest)(nil), // 166: openshell.v1.ApproveDraftChunkRequest - (*ApproveDraftChunkResponse)(nil), // 167: openshell.v1.ApproveDraftChunkResponse - (*RejectDraftChunkRequest)(nil), // 168: openshell.v1.RejectDraftChunkRequest - (*RejectDraftChunkResponse)(nil), // 169: openshell.v1.RejectDraftChunkResponse - (*DraftChunkApproval)(nil), // 170: openshell.v1.DraftChunkApproval - (*ApproveAllDraftChunksRequest)(nil), // 171: openshell.v1.ApproveAllDraftChunksRequest - (*ApproveAllDraftChunksResponse)(nil), // 172: openshell.v1.ApproveAllDraftChunksResponse - (*EditDraftChunkRequest)(nil), // 173: openshell.v1.EditDraftChunkRequest - (*EditDraftChunkResponse)(nil), // 174: openshell.v1.EditDraftChunkResponse - (*UndoDraftChunkRequest)(nil), // 175: openshell.v1.UndoDraftChunkRequest - (*UndoDraftChunkResponse)(nil), // 176: openshell.v1.UndoDraftChunkResponse - (*ClearDraftChunksRequest)(nil), // 177: openshell.v1.ClearDraftChunksRequest - (*ClearDraftChunksResponse)(nil), // 178: openshell.v1.ClearDraftChunksResponse - (*GetDraftHistoryRequest)(nil), // 179: openshell.v1.GetDraftHistoryRequest - (*DraftHistoryEntry)(nil), // 180: openshell.v1.DraftHistoryEntry - (*GetDraftHistoryResponse)(nil), // 181: openshell.v1.GetDraftHistoryResponse - (*PolicyRevisionPayload)(nil), // 182: openshell.v1.PolicyRevisionPayload - (*DraftChunkPayload)(nil), // 183: openshell.v1.DraftChunkPayload - (*StoredPolicyRevision)(nil), // 184: openshell.v1.StoredPolicyRevision - (*StoredDraftChunk)(nil), // 185: openshell.v1.StoredDraftChunk - (*CreateWorkspaceRequest)(nil), // 186: openshell.v1.CreateWorkspaceRequest - (*CreateWorkspaceResponse)(nil), // 187: openshell.v1.CreateWorkspaceResponse - (*GetWorkspaceRequest)(nil), // 188: openshell.v1.GetWorkspaceRequest - (*GetWorkspaceResponse)(nil), // 189: openshell.v1.GetWorkspaceResponse - (*ListWorkspacesRequest)(nil), // 190: openshell.v1.ListWorkspacesRequest - (*ListWorkspacesResponse)(nil), // 191: openshell.v1.ListWorkspacesResponse - (*DeleteWorkspaceRequest)(nil), // 192: openshell.v1.DeleteWorkspaceRequest - (*DeleteWorkspaceResponse)(nil), // 193: openshell.v1.DeleteWorkspaceResponse - (*WorkspaceMember)(nil), // 194: openshell.v1.WorkspaceMember - (*AddWorkspaceMemberRequest)(nil), // 195: openshell.v1.AddWorkspaceMemberRequest - (*AddWorkspaceMemberResponse)(nil), // 196: openshell.v1.AddWorkspaceMemberResponse - (*RemoveWorkspaceMemberRequest)(nil), // 197: openshell.v1.RemoveWorkspaceMemberRequest - (*RemoveWorkspaceMemberResponse)(nil), // 198: openshell.v1.RemoveWorkspaceMemberResponse - (*ListWorkspaceMembersRequest)(nil), // 199: openshell.v1.ListWorkspaceMembersRequest - (*ListWorkspaceMembersResponse)(nil), // 200: openshell.v1.ListWorkspaceMembersResponse - (*ExtensionServiceCredential)(nil), // 201: openshell.v1.ExtensionServiceCredential - nil, // 202: openshell.v1.SandboxSpec.EnvironmentEntry - nil, // 203: openshell.v1.SandboxTemplate.LabelsEntry - nil, // 204: openshell.v1.SandboxTemplate.AnnotationsEntry - nil, // 205: openshell.v1.SandboxTemplate.EnvironmentEntry - nil, // 206: openshell.v1.PlatformEvent.MetadataEntry - nil, // 207: openshell.v1.CreateSandboxRequest.LabelsEntry - nil, // 208: openshell.v1.CreateSandboxRequest.AnnotationsEntry - nil, // 209: openshell.v1.ExecSandboxRequest.EnvironmentEntry - nil, // 210: openshell.v1.SandboxLogLine.FieldsEntry - nil, // 211: openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry - nil, // 212: openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry - nil, // 213: openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry - nil, // 214: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry - nil, // 215: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry - nil, // 216: openshell.v1.ProviderProfile.AnnotationsEntry - nil, // 217: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - nil, // 218: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry - nil, // 219: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - nil, // 220: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry - nil, // 221: openshell.v1.UpdateConfigRequest.AnnotationsEntry - nil, // 222: openshell.v1.UpdateConfigResponse.AnnotationsEntry - nil, // 223: openshell.v1.SandboxPolicyRevision.ProvenanceEntry - nil, // 224: openshell.v1.PolicyRevisionPayload.ProvenanceEntry - nil, // 225: openshell.v1.StoredPolicyRevision.ProvenanceEntry - nil, // 226: openshell.v1.CreateWorkspaceRequest.LabelsEntry - (*datamodelv1.ObjectMeta)(nil), // 227: openshell.datamodel.v1.ObjectMeta - (*sandboxv1.SandboxPolicy)(nil), // 228: openshell.sandbox.v1.SandboxPolicy - (*structpb.Struct)(nil), // 229: google.protobuf.Struct - (*datamodelv1.Provider)(nil), // 230: openshell.datamodel.v1.Provider - (*datamodelv1.CredentialHandle)(nil), // 231: openshell.datamodel.v1.CredentialHandle - (*sandboxv1.NetworkEndpoint)(nil), // 232: openshell.sandbox.v1.NetworkEndpoint - (*sandboxv1.NetworkBinary)(nil), // 233: openshell.sandbox.v1.NetworkBinary - (*sandboxv1.SettingValue)(nil), // 234: openshell.sandbox.v1.SettingValue - (*sandboxv1.NetworkPolicyRule)(nil), // 235: openshell.sandbox.v1.NetworkPolicyRule - (*sandboxv1.L7DenyRule)(nil), // 236: openshell.sandbox.v1.L7DenyRule - (*sandboxv1.L7Rule)(nil), // 237: openshell.sandbox.v1.L7Rule - (*datamodelv1.Workspace)(nil), // 238: openshell.datamodel.v1.Workspace - (*sandboxv1.GetSandboxConfigRequest)(nil), // 239: openshell.sandbox.v1.GetSandboxConfigRequest - (*sandboxv1.GetGatewayConfigRequest)(nil), // 240: openshell.sandbox.v1.GetGatewayConfigRequest - (*sandboxv1.GetSandboxConfigResponse)(nil), // 241: openshell.sandbox.v1.GetSandboxConfigResponse - (*sandboxv1.GetGatewayConfigResponse)(nil), // 242: openshell.sandbox.v1.GetGatewayConfigResponse + (*SandboxWorkloadTemplate)(nil), // 25: openshell.v1.SandboxWorkloadTemplate + (*SandboxWorkloadTemplateSpec)(nil), // 26: openshell.v1.SandboxWorkloadTemplateSpec + (*SandboxWorkloadConfig)(nil), // 27: openshell.v1.SandboxWorkloadConfig + (*SandboxResources)(nil), // 28: openshell.v1.SandboxResources + (*SandboxServiceLevel)(nil), // 29: openshell.v1.SandboxServiceLevel + (*SandboxStartup)(nil), // 30: openshell.v1.SandboxStartup + (*SandboxWorkloadTemplateProvenance)(nil), // 31: openshell.v1.SandboxWorkloadTemplateProvenance + (*SandboxStatus)(nil), // 32: openshell.v1.SandboxStatus + (*SandboxCondition)(nil), // 33: openshell.v1.SandboxCondition + (*PlatformEvent)(nil), // 34: openshell.v1.PlatformEvent + (*CreateSandboxRequest)(nil), // 35: openshell.v1.CreateSandboxRequest + (*CreateSandboxTemplateRequest)(nil), // 36: openshell.v1.CreateSandboxTemplateRequest + (*GetSandboxTemplateRequest)(nil), // 37: openshell.v1.GetSandboxTemplateRequest + (*ListSandboxTemplatesRequest)(nil), // 38: openshell.v1.ListSandboxTemplatesRequest + (*DeleteSandboxTemplateRequest)(nil), // 39: openshell.v1.DeleteSandboxTemplateRequest + (*SandboxTemplateResponse)(nil), // 40: openshell.v1.SandboxTemplateResponse + (*ListSandboxTemplatesResponse)(nil), // 41: openshell.v1.ListSandboxTemplatesResponse + (*DeleteSandboxTemplateResponse)(nil), // 42: openshell.v1.DeleteSandboxTemplateResponse + (*GetSandboxRequest)(nil), // 43: openshell.v1.GetSandboxRequest + (*ListSandboxesRequest)(nil), // 44: openshell.v1.ListSandboxesRequest + (*ListSandboxProvidersRequest)(nil), // 45: openshell.v1.ListSandboxProvidersRequest + (*AttachSandboxProviderRequest)(nil), // 46: openshell.v1.AttachSandboxProviderRequest + (*DetachSandboxProviderRequest)(nil), // 47: openshell.v1.DetachSandboxProviderRequest + (*DeleteSandboxRequest)(nil), // 48: openshell.v1.DeleteSandboxRequest + (*StopSandboxRequest)(nil), // 49: openshell.v1.StopSandboxRequest + (*StartSandboxRequest)(nil), // 50: openshell.v1.StartSandboxRequest + (*SandboxResponse)(nil), // 51: openshell.v1.SandboxResponse + (*ListSandboxesResponse)(nil), // 52: openshell.v1.ListSandboxesResponse + (*ListSandboxProvidersResponse)(nil), // 53: openshell.v1.ListSandboxProvidersResponse + (*AttachSandboxProviderResponse)(nil), // 54: openshell.v1.AttachSandboxProviderResponse + (*DetachSandboxProviderResponse)(nil), // 55: openshell.v1.DetachSandboxProviderResponse + (*DeleteSandboxResponse)(nil), // 56: openshell.v1.DeleteSandboxResponse + (*CreateSshSessionRequest)(nil), // 57: openshell.v1.CreateSshSessionRequest + (*CreateSshSessionResponse)(nil), // 58: openshell.v1.CreateSshSessionResponse + (*ExposeServiceRequest)(nil), // 59: openshell.v1.ExposeServiceRequest + (*GetServiceRequest)(nil), // 60: openshell.v1.GetServiceRequest + (*ListServicesRequest)(nil), // 61: openshell.v1.ListServicesRequest + (*ListServicesResponse)(nil), // 62: openshell.v1.ListServicesResponse + (*DeleteServiceRequest)(nil), // 63: openshell.v1.DeleteServiceRequest + (*DeleteServiceResponse)(nil), // 64: openshell.v1.DeleteServiceResponse + (*ServiceEndpoint)(nil), // 65: openshell.v1.ServiceEndpoint + (*ServiceEndpointResponse)(nil), // 66: openshell.v1.ServiceEndpointResponse + (*RevokeSshSessionRequest)(nil), // 67: openshell.v1.RevokeSshSessionRequest + (*RevokeSshSessionResponse)(nil), // 68: openshell.v1.RevokeSshSessionResponse + (*ExecSandboxRequest)(nil), // 69: openshell.v1.ExecSandboxRequest + (*ExecSandboxStdout)(nil), // 70: openshell.v1.ExecSandboxStdout + (*ExecSandboxStderr)(nil), // 71: openshell.v1.ExecSandboxStderr + (*ExecSandboxExit)(nil), // 72: openshell.v1.ExecSandboxExit + (*ExecSandboxEvent)(nil), // 73: openshell.v1.ExecSandboxEvent + (*TcpForwardInit)(nil), // 74: openshell.v1.TcpForwardInit + (*TcpForwardFrame)(nil), // 75: openshell.v1.TcpForwardFrame + (*ExecSandboxInput)(nil), // 76: openshell.v1.ExecSandboxInput + (*ExecSandboxWindowResize)(nil), // 77: openshell.v1.ExecSandboxWindowResize + (*SshSession)(nil), // 78: openshell.v1.SshSession + (*WatchSandboxRequest)(nil), // 79: openshell.v1.WatchSandboxRequest + (*SandboxStreamEvent)(nil), // 80: openshell.v1.SandboxStreamEvent + (*SandboxLogLine)(nil), // 81: openshell.v1.SandboxLogLine + (*SandboxStreamWarning)(nil), // 82: openshell.v1.SandboxStreamWarning + (*CreateProviderRequest)(nil), // 83: openshell.v1.CreateProviderRequest + (*GetProviderRequest)(nil), // 84: openshell.v1.GetProviderRequest + (*ListProvidersRequest)(nil), // 85: openshell.v1.ListProvidersRequest + (*UpdateProviderRequest)(nil), // 86: openshell.v1.UpdateProviderRequest + (*DeleteProviderRequest)(nil), // 87: openshell.v1.DeleteProviderRequest + (*ProviderResponse)(nil), // 88: openshell.v1.ProviderResponse + (*ListProvidersResponse)(nil), // 89: openshell.v1.ListProvidersResponse + (*ListProviderProfilesRequest)(nil), // 90: openshell.v1.ListProviderProfilesRequest + (*GetProviderProfileRequest)(nil), // 91: openshell.v1.GetProviderProfileRequest + (*ProviderProfileImportItem)(nil), // 92: openshell.v1.ProviderProfileImportItem + (*ProviderProfileDiagnostic)(nil), // 93: openshell.v1.ProviderProfileDiagnostic + (*ProviderCredentialTokenGrantAudienceOverride)(nil), // 94: openshell.v1.ProviderCredentialTokenGrantAudienceOverride + (*ProviderCredentialTokenGrantSubjectToken)(nil), // 95: openshell.v1.ProviderCredentialTokenGrantSubjectToken + (*ProviderCredentialTokenGrant)(nil), // 96: openshell.v1.ProviderCredentialTokenGrant + (*ProviderProfileCredential)(nil), // 97: openshell.v1.ProviderProfileCredential + (*ProviderCredentialRefreshMaterial)(nil), // 98: openshell.v1.ProviderCredentialRefreshMaterial + (*ProviderCredentialRefreshOutput)(nil), // 99: openshell.v1.ProviderCredentialRefreshOutput + (*ProviderCredentialRefresh)(nil), // 100: openshell.v1.ProviderCredentialRefresh + (*ProviderCredentialRefreshStatus)(nil), // 101: openshell.v1.ProviderCredentialRefreshStatus + (*ProviderProfileDiscovery)(nil), // 102: openshell.v1.ProviderProfileDiscovery + (*StoredProviderCredentialRefreshState)(nil), // 103: openshell.v1.StoredProviderCredentialRefreshState + (*StoredRefreshMaterialDeletion)(nil), // 104: openshell.v1.StoredRefreshMaterialDeletion + (*GetProviderRefreshStatusRequest)(nil), // 105: openshell.v1.GetProviderRefreshStatusRequest + (*GetProviderRefreshStatusResponse)(nil), // 106: openshell.v1.GetProviderRefreshStatusResponse + (*ConfigureProviderRefreshRequest)(nil), // 107: openshell.v1.ConfigureProviderRefreshRequest + (*ConfigureProviderRefreshResponse)(nil), // 108: openshell.v1.ConfigureProviderRefreshResponse + (*RotateProviderCredentialRequest)(nil), // 109: openshell.v1.RotateProviderCredentialRequest + (*RotateProviderCredentialResponse)(nil), // 110: openshell.v1.RotateProviderCredentialResponse + (*DeleteProviderRefreshRequest)(nil), // 111: openshell.v1.DeleteProviderRefreshRequest + (*DeleteProviderRefreshResponse)(nil), // 112: openshell.v1.DeleteProviderRefreshResponse + (*ProviderProfile)(nil), // 113: openshell.v1.ProviderProfile + (*StoredProviderProfile)(nil), // 114: openshell.v1.StoredProviderProfile + (*ProviderProfileResponse)(nil), // 115: openshell.v1.ProviderProfileResponse + (*ListProviderProfilesResponse)(nil), // 116: openshell.v1.ListProviderProfilesResponse + (*ImportProviderProfilesRequest)(nil), // 117: openshell.v1.ImportProviderProfilesRequest + (*ImportProviderProfilesResponse)(nil), // 118: openshell.v1.ImportProviderProfilesResponse + (*UpdateProviderProfilesRequest)(nil), // 119: openshell.v1.UpdateProviderProfilesRequest + (*UpdateProviderProfilesResponse)(nil), // 120: openshell.v1.UpdateProviderProfilesResponse + (*LintProviderProfilesRequest)(nil), // 121: openshell.v1.LintProviderProfilesRequest + (*LintProviderProfilesResponse)(nil), // 122: openshell.v1.LintProviderProfilesResponse + (*DeleteProviderResponse)(nil), // 123: openshell.v1.DeleteProviderResponse + (*DeleteProviderProfileRequest)(nil), // 124: openshell.v1.DeleteProviderProfileRequest + (*DeleteProviderProfileResponse)(nil), // 125: openshell.v1.DeleteProviderProfileResponse + (*GetSandboxProviderEnvironmentRequest)(nil), // 126: openshell.v1.GetSandboxProviderEnvironmentRequest + (*StaticCredentialEndpointBinding)(nil), // 127: openshell.v1.StaticCredentialEndpointBinding + (*StaticCredentialBinding)(nil), // 128: openshell.v1.StaticCredentialBinding + (*GetSandboxProviderEnvironmentResponse)(nil), // 129: openshell.v1.GetSandboxProviderEnvironmentResponse + (*ExchangeProviderSubjectTokenRequest)(nil), // 130: openshell.v1.ExchangeProviderSubjectTokenRequest + (*ExchangeProviderSubjectTokenResponse)(nil), // 131: openshell.v1.ExchangeProviderSubjectTokenResponse + (*UpdateConfigRequest)(nil), // 132: openshell.v1.UpdateConfigRequest + (*PolicyMergeOperation)(nil), // 133: openshell.v1.PolicyMergeOperation + (*AddNetworkRule)(nil), // 134: openshell.v1.AddNetworkRule + (*RemoveNetworkEndpoint)(nil), // 135: openshell.v1.RemoveNetworkEndpoint + (*RemoveNetworkRule)(nil), // 136: openshell.v1.RemoveNetworkRule + (*AddDenyRules)(nil), // 137: openshell.v1.AddDenyRules + (*AddAllowRules)(nil), // 138: openshell.v1.AddAllowRules + (*RemoveNetworkBinary)(nil), // 139: openshell.v1.RemoveNetworkBinary + (*UpdateConfigResponse)(nil), // 140: openshell.v1.UpdateConfigResponse + (*GetSandboxPolicyStatusRequest)(nil), // 141: openshell.v1.GetSandboxPolicyStatusRequest + (*GetSandboxPolicyStatusResponse)(nil), // 142: openshell.v1.GetSandboxPolicyStatusResponse + (*ListSandboxPoliciesRequest)(nil), // 143: openshell.v1.ListSandboxPoliciesRequest + (*ListSandboxPoliciesResponse)(nil), // 144: openshell.v1.ListSandboxPoliciesResponse + (*ReportPolicyStatusRequest)(nil), // 145: openshell.v1.ReportPolicyStatusRequest + (*ReportPolicyStatusResponse)(nil), // 146: openshell.v1.ReportPolicyStatusResponse + (*SandboxPolicyRevision)(nil), // 147: openshell.v1.SandboxPolicyRevision + (*GetSandboxLogsRequest)(nil), // 148: openshell.v1.GetSandboxLogsRequest + (*PushSandboxLogsRequest)(nil), // 149: openshell.v1.PushSandboxLogsRequest + (*PushSandboxLogsResponse)(nil), // 150: openshell.v1.PushSandboxLogsResponse + (*GetSandboxLogsResponse)(nil), // 151: openshell.v1.GetSandboxLogsResponse + (*SupervisorMessage)(nil), // 152: openshell.v1.SupervisorMessage + (*GatewayMessage)(nil), // 153: openshell.v1.GatewayMessage + (*SupervisorHello)(nil), // 154: openshell.v1.SupervisorHello + (*SessionAccepted)(nil), // 155: openshell.v1.SessionAccepted + (*SessionRejected)(nil), // 156: openshell.v1.SessionRejected + (*SupervisorHeartbeat)(nil), // 157: openshell.v1.SupervisorHeartbeat + (*GatewayHeartbeat)(nil), // 158: openshell.v1.GatewayHeartbeat + (*ReportMainProcessExitRequest)(nil), // 159: openshell.v1.ReportMainProcessExitRequest + (*ReportMainProcessExitResponse)(nil), // 160: openshell.v1.ReportMainProcessExitResponse + (*FinalizeMainProcessExitRequest)(nil), // 161: openshell.v1.FinalizeMainProcessExitRequest + (*FinalizeMainProcessExitResponse)(nil), // 162: openshell.v1.FinalizeMainProcessExitResponse + (*RelayOpen)(nil), // 163: openshell.v1.RelayOpen + (*SshRelayTarget)(nil), // 164: openshell.v1.SshRelayTarget + (*TcpRelayTarget)(nil), // 165: openshell.v1.TcpRelayTarget + (*RelayInit)(nil), // 166: openshell.v1.RelayInit + (*RelayFrame)(nil), // 167: openshell.v1.RelayFrame + (*RelayOpenResult)(nil), // 168: openshell.v1.RelayOpenResult + (*RelayClose)(nil), // 169: openshell.v1.RelayClose + (*L7RequestSample)(nil), // 170: openshell.v1.L7RequestSample + (*DenialSummary)(nil), // 171: openshell.v1.DenialSummary + (*DenialGroupCount)(nil), // 172: openshell.v1.DenialGroupCount + (*NetworkActivitySummary)(nil), // 173: openshell.v1.NetworkActivitySummary + (*PolicyChunk)(nil), // 174: openshell.v1.PolicyChunk + (*DraftPolicyUpdate)(nil), // 175: openshell.v1.DraftPolicyUpdate + (*SubmitPolicyAnalysisRequest)(nil), // 176: openshell.v1.SubmitPolicyAnalysisRequest + (*SubmitPolicyAnalysisResponse)(nil), // 177: openshell.v1.SubmitPolicyAnalysisResponse + (*GetDraftPolicyRequest)(nil), // 178: openshell.v1.GetDraftPolicyRequest + (*GetDraftPolicyResponse)(nil), // 179: openshell.v1.GetDraftPolicyResponse + (*ApproveDraftChunkRequest)(nil), // 180: openshell.v1.ApproveDraftChunkRequest + (*ApproveDraftChunkResponse)(nil), // 181: openshell.v1.ApproveDraftChunkResponse + (*RejectDraftChunkRequest)(nil), // 182: openshell.v1.RejectDraftChunkRequest + (*RejectDraftChunkResponse)(nil), // 183: openshell.v1.RejectDraftChunkResponse + (*DraftChunkApproval)(nil), // 184: openshell.v1.DraftChunkApproval + (*ApproveAllDraftChunksRequest)(nil), // 185: openshell.v1.ApproveAllDraftChunksRequest + (*ApproveAllDraftChunksResponse)(nil), // 186: openshell.v1.ApproveAllDraftChunksResponse + (*EditDraftChunkRequest)(nil), // 187: openshell.v1.EditDraftChunkRequest + (*EditDraftChunkResponse)(nil), // 188: openshell.v1.EditDraftChunkResponse + (*UndoDraftChunkRequest)(nil), // 189: openshell.v1.UndoDraftChunkRequest + (*UndoDraftChunkResponse)(nil), // 190: openshell.v1.UndoDraftChunkResponse + (*ClearDraftChunksRequest)(nil), // 191: openshell.v1.ClearDraftChunksRequest + (*ClearDraftChunksResponse)(nil), // 192: openshell.v1.ClearDraftChunksResponse + (*GetDraftHistoryRequest)(nil), // 193: openshell.v1.GetDraftHistoryRequest + (*DraftHistoryEntry)(nil), // 194: openshell.v1.DraftHistoryEntry + (*GetDraftHistoryResponse)(nil), // 195: openshell.v1.GetDraftHistoryResponse + (*PolicyRevisionPayload)(nil), // 196: openshell.v1.PolicyRevisionPayload + (*DraftChunkPayload)(nil), // 197: openshell.v1.DraftChunkPayload + (*StoredPolicyRevision)(nil), // 198: openshell.v1.StoredPolicyRevision + (*StoredDraftChunk)(nil), // 199: openshell.v1.StoredDraftChunk + (*CreateWorkspaceRequest)(nil), // 200: openshell.v1.CreateWorkspaceRequest + (*CreateWorkspaceResponse)(nil), // 201: openshell.v1.CreateWorkspaceResponse + (*GetWorkspaceRequest)(nil), // 202: openshell.v1.GetWorkspaceRequest + (*GetWorkspaceResponse)(nil), // 203: openshell.v1.GetWorkspaceResponse + (*ListWorkspacesRequest)(nil), // 204: openshell.v1.ListWorkspacesRequest + (*ListWorkspacesResponse)(nil), // 205: openshell.v1.ListWorkspacesResponse + (*DeleteWorkspaceRequest)(nil), // 206: openshell.v1.DeleteWorkspaceRequest + (*DeleteWorkspaceResponse)(nil), // 207: openshell.v1.DeleteWorkspaceResponse + (*WorkspaceMember)(nil), // 208: openshell.v1.WorkspaceMember + (*AddWorkspaceMemberRequest)(nil), // 209: openshell.v1.AddWorkspaceMemberRequest + (*AddWorkspaceMemberResponse)(nil), // 210: openshell.v1.AddWorkspaceMemberResponse + (*RemoveWorkspaceMemberRequest)(nil), // 211: openshell.v1.RemoveWorkspaceMemberRequest + (*RemoveWorkspaceMemberResponse)(nil), // 212: openshell.v1.RemoveWorkspaceMemberResponse + (*ListWorkspaceMembersRequest)(nil), // 213: openshell.v1.ListWorkspaceMembersRequest + (*ListWorkspaceMembersResponse)(nil), // 214: openshell.v1.ListWorkspaceMembersResponse + (*ExtensionServiceCredential)(nil), // 215: openshell.v1.ExtensionServiceCredential + nil, // 216: openshell.v1.SandboxSpec.EnvironmentEntry + nil, // 217: openshell.v1.SandboxTemplate.LabelsEntry + nil, // 218: openshell.v1.SandboxTemplate.AnnotationsEntry + nil, // 219: openshell.v1.SandboxTemplate.EnvironmentEntry + nil, // 220: openshell.v1.SandboxWorkloadConfig.EnvironmentEntry + nil, // 221: openshell.v1.PlatformEvent.MetadataEntry + nil, // 222: openshell.v1.CreateSandboxRequest.LabelsEntry + nil, // 223: openshell.v1.CreateSandboxRequest.AnnotationsEntry + nil, // 224: openshell.v1.ExecSandboxRequest.EnvironmentEntry + nil, // 225: openshell.v1.SandboxLogLine.FieldsEntry + nil, // 226: openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry + nil, // 227: openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry + nil, // 228: openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry + nil, // 229: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry + nil, // 230: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + nil, // 231: openshell.v1.ProviderProfile.AnnotationsEntry + nil, // 232: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + nil, // 233: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry + nil, // 234: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + nil, // 235: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry + nil, // 236: openshell.v1.UpdateConfigRequest.AnnotationsEntry + nil, // 237: openshell.v1.UpdateConfigResponse.AnnotationsEntry + nil, // 238: openshell.v1.SandboxPolicyRevision.ProvenanceEntry + nil, // 239: openshell.v1.PolicyRevisionPayload.ProvenanceEntry + nil, // 240: openshell.v1.StoredPolicyRevision.ProvenanceEntry + nil, // 241: openshell.v1.CreateWorkspaceRequest.LabelsEntry + (*datamodelv1.ObjectMeta)(nil), // 242: openshell.datamodel.v1.ObjectMeta + (*sandboxv1.SandboxPolicy)(nil), // 243: openshell.sandbox.v1.SandboxPolicy + (*structpb.Struct)(nil), // 244: google.protobuf.Struct + (*durationpb.Duration)(nil), // 245: google.protobuf.Duration + (*datamodelv1.Provider)(nil), // 246: openshell.datamodel.v1.Provider + (*datamodelv1.CredentialHandle)(nil), // 247: openshell.datamodel.v1.CredentialHandle + (*sandboxv1.NetworkEndpoint)(nil), // 248: openshell.sandbox.v1.NetworkEndpoint + (*sandboxv1.NetworkBinary)(nil), // 249: openshell.sandbox.v1.NetworkBinary + (*sandboxv1.SettingValue)(nil), // 250: openshell.sandbox.v1.SettingValue + (*sandboxv1.NetworkPolicyRule)(nil), // 251: openshell.sandbox.v1.NetworkPolicyRule + (*sandboxv1.L7DenyRule)(nil), // 252: openshell.sandbox.v1.L7DenyRule + (*sandboxv1.L7Rule)(nil), // 253: openshell.sandbox.v1.L7Rule + (*datamodelv1.Workspace)(nil), // 254: openshell.datamodel.v1.Workspace + (*sandboxv1.GetSandboxConfigRequest)(nil), // 255: openshell.sandbox.v1.GetSandboxConfigRequest + (*sandboxv1.GetGatewayConfigRequest)(nil), // 256: openshell.sandbox.v1.GetGatewayConfigRequest + (*sandboxv1.GetSandboxConfigResponse)(nil), // 257: openshell.sandbox.v1.GetSandboxConfigResponse + (*sandboxv1.GetGatewayConfigResponse)(nil), // 258: openshell.sandbox.v1.GetGatewayConfigResponse } var file_openshell_proto_depIdxs = []int32{ - 201, // 0: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential + 215, // 0: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential 5, // 1: openshell.v1.HealthResponse.status:type_name -> openshell.v1.ServiceStatus 5, // 2: openshell.v1.GetGatewayInfoResponse.status:type_name -> openshell.v1.ServiceStatus 18, // 3: openshell.v1.GetGatewayInfoResponse.compute_drivers:type_name -> openshell.v1.ComputeDriverInfo 19, // 4: openshell.v1.ComputeDriverInfo.capabilities:type_name -> openshell.v1.ComputeDriverCapabilities - 227, // 5: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 242, // 5: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta 21, // 6: openshell.v1.Sandbox.spec:type_name -> openshell.v1.SandboxSpec - 25, // 7: openshell.v1.Sandbox.status:type_name -> openshell.v1.SandboxStatus - 202, // 8: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry - 24, // 9: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate - 228, // 10: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 22, // 11: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements - 23, // 12: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements - 203, // 13: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry - 204, // 14: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry - 205, // 15: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry - 229, // 16: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct - 229, // 17: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct - 26, // 18: openshell.v1.SandboxStatus.conditions:type_name -> openshell.v1.SandboxCondition - 0, // 19: openshell.v1.SandboxStatus.phase:type_name -> openshell.v1.SandboxPhase - 206, // 20: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry - 21, // 21: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec - 207, // 22: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry - 208, // 23: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry - 20, // 24: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox - 20, // 25: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox - 230, // 26: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider - 20, // 27: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox - 20, // 28: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox - 52, // 29: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse - 227, // 30: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 51, // 31: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint - 209, // 32: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry - 56, // 33: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout - 57, // 34: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr - 58, // 35: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit - 150, // 36: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget - 151, // 37: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget - 60, // 38: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit - 55, // 39: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest - 63, // 40: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize - 227, // 41: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 20, // 42: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox - 67, // 43: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine - 27, // 44: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent - 68, // 45: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning - 161, // 46: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate - 210, // 47: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry - 230, // 48: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 230, // 49: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 211, // 50: openshell.v1.UpdateProviderRequest.credential_expires_at_ms:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry - 230, // 51: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider - 230, // 52: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider - 99, // 53: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile - 80, // 54: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride - 1, // 55: openshell.v1.ProviderCredentialTokenGrant.grant_type:type_name -> openshell.v1.ProviderCredentialTokenGrantType - 81, // 56: openshell.v1.ProviderCredentialTokenGrant.subject_token:type_name -> openshell.v1.ProviderCredentialTokenGrantSubjectToken - 86, // 57: openshell.v1.ProviderProfileCredential.refresh:type_name -> openshell.v1.ProviderCredentialRefresh - 82, // 58: openshell.v1.ProviderProfileCredential.token_grant:type_name -> openshell.v1.ProviderCredentialTokenGrant - 2, // 59: openshell.v1.ProviderCredentialRefresh.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 84, // 60: openshell.v1.ProviderCredentialRefresh.material:type_name -> openshell.v1.ProviderCredentialRefreshMaterial - 85, // 61: openshell.v1.ProviderCredentialRefresh.additional_outputs:type_name -> openshell.v1.ProviderCredentialRefreshOutput - 2, // 62: openshell.v1.ProviderCredentialRefreshStatus.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 7, // 63: openshell.v1.ProviderCredentialRefreshStatus.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction - 227, // 64: openshell.v1.StoredProviderCredentialRefreshState.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 2, // 65: openshell.v1.StoredProviderCredentialRefreshState.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 212, // 66: openshell.v1.StoredProviderCredentialRefreshState.material:type_name -> openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry - 213, // 67: openshell.v1.StoredProviderCredentialRefreshState.additional_output_keys:type_name -> openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry - 214, // 68: openshell.v1.StoredProviderCredentialRefreshState.secret_material_handles:type_name -> openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry - 90, // 69: openshell.v1.StoredProviderCredentialRefreshState.pending_secret_deletions:type_name -> openshell.v1.StoredRefreshMaterialDeletion - 7, // 70: openshell.v1.StoredProviderCredentialRefreshState.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction - 231, // 71: openshell.v1.StoredRefreshMaterialDeletion.handle:type_name -> openshell.datamodel.v1.CredentialHandle - 87, // 72: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 2, // 73: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 215, // 74: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry - 87, // 75: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 87, // 76: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 3, // 77: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory - 83, // 78: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential - 232, // 79: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint - 233, // 80: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary - 88, // 81: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery - 216, // 82: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry - 227, // 83: openshell.v1.StoredProviderProfile.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 99, // 84: openshell.v1.StoredProviderProfile.profile:type_name -> openshell.v1.ProviderProfile - 99, // 85: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile - 99, // 86: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile - 78, // 87: openshell.v1.ImportProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem - 79, // 88: openshell.v1.ImportProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 99, // 89: openshell.v1.ImportProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile - 78, // 90: openshell.v1.UpdateProviderProfilesRequest.profile:type_name -> openshell.v1.ProviderProfileImportItem - 79, // 91: openshell.v1.UpdateProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 99, // 92: openshell.v1.UpdateProviderProfilesResponse.profile:type_name -> openshell.v1.ProviderProfile - 78, // 93: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem - 79, // 94: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 113, // 95: openshell.v1.StaticCredentialBinding.endpoints:type_name -> openshell.v1.StaticCredentialEndpointBinding - 217, // 96: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - 218, // 97: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expires_at_ms:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry - 219, // 98: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - 220, // 99: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry - 228, // 100: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 234, // 101: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue - 119, // 102: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation - 221, // 103: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry - 120, // 104: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule - 121, // 105: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint - 122, // 106: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule - 123, // 107: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules - 124, // 108: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules - 125, // 109: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary - 235, // 110: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 236, // 111: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule - 237, // 112: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule - 222, // 113: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry - 133, // 114: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision - 133, // 115: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision - 4, // 116: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus - 4, // 117: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus - 228, // 118: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 223, // 119: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry - 67, // 120: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine - 67, // 121: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine - 140, // 122: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello - 143, // 123: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat - 154, // 124: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult - 155, // 125: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose - 141, // 126: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted - 142, // 127: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected - 144, // 128: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat - 149, // 129: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen - 155, // 130: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose - 150, // 131: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget - 151, // 132: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget - 152, // 133: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit - 156, // 134: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample - 158, // 135: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount - 235, // 136: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 228, // 137: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 228, // 138: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 157, // 139: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary - 160, // 140: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk - 159, // 141: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary - 160, // 142: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk - 170, // 143: openshell.v1.ApproveAllDraftChunksRequest.approvals:type_name -> openshell.v1.DraftChunkApproval - 235, // 144: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 180, // 145: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry - 228, // 146: openshell.v1.PolicyRevisionPayload.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 224, // 147: openshell.v1.PolicyRevisionPayload.provenance:type_name -> openshell.v1.PolicyRevisionPayload.ProvenanceEntry - 235, // 148: openshell.v1.DraftChunkPayload.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 228, // 149: openshell.v1.DraftChunkPayload.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 228, // 150: openshell.v1.DraftChunkPayload.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 225, // 151: openshell.v1.StoredPolicyRevision.provenance:type_name -> openshell.v1.StoredPolicyRevision.ProvenanceEntry - 228, // 152: openshell.v1.StoredDraftChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 228, // 153: openshell.v1.StoredDraftChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 226, // 154: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry - 238, // 155: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 238, // 156: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 238, // 157: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace - 227, // 158: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 6, // 159: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole - 6, // 160: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole - 194, // 161: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember - 194, // 162: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember - 231, // 163: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry.value:type_name -> openshell.datamodel.v1.CredentialHandle - 83, // 164: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential - 114, // 165: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding - 12, // 166: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest - 14, // 167: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest - 16, // 168: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest - 28, // 169: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest - 29, // 170: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest - 30, // 171: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest - 31, // 172: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest - 32, // 173: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest - 33, // 174: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest - 34, // 175: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest - 35, // 176: openshell.v1.OpenShell.StopSandbox:input_type -> openshell.v1.StopSandboxRequest - 36, // 177: openshell.v1.OpenShell.StartSandbox:input_type -> openshell.v1.StartSandboxRequest - 43, // 178: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest - 45, // 179: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest - 46, // 180: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest - 47, // 181: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest - 49, // 182: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest - 53, // 183: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest - 55, // 184: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest - 61, // 185: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame - 62, // 186: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput - 69, // 187: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest - 70, // 188: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest - 71, // 189: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest - 76, // 190: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest - 77, // 191: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest - 103, // 192: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest - 105, // 193: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest - 107, // 194: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest - 72, // 195: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest - 91, // 196: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest - 93, // 197: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest - 95, // 198: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest - 97, // 199: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest - 73, // 200: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest - 110, // 201: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest - 239, // 202: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest - 240, // 203: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest - 118, // 204: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest - 127, // 205: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest - 129, // 206: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest - 131, // 207: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest - 112, // 208: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest - 116, // 209: openshell.v1.OpenShell.ExchangeProviderSubjectToken:input_type -> openshell.v1.ExchangeProviderSubjectTokenRequest - 134, // 210: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest - 135, // 211: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest - 138, // 212: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage - 145, // 213: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest - 147, // 214: openshell.v1.OpenShell.FinalizeMainProcessExit:input_type -> openshell.v1.FinalizeMainProcessExitRequest - 153, // 215: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame - 65, // 216: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest - 162, // 217: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest - 164, // 218: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest - 166, // 219: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest - 168, // 220: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest - 171, // 221: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest - 173, // 222: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest - 175, // 223: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest - 177, // 224: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest - 179, // 225: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest - 8, // 226: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest - 10, // 227: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest - 186, // 228: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest - 188, // 229: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest - 190, // 230: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest - 192, // 231: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest - 195, // 232: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest - 197, // 233: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest - 199, // 234: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest - 13, // 235: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse - 15, // 236: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse - 17, // 237: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse - 37, // 238: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse - 37, // 239: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse - 38, // 240: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse - 39, // 241: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse - 40, // 242: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse - 41, // 243: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse - 42, // 244: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse - 37, // 245: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse - 37, // 246: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse - 44, // 247: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse - 52, // 248: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse - 52, // 249: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse - 48, // 250: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse - 50, // 251: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse - 54, // 252: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse - 59, // 253: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent - 61, // 254: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame - 59, // 255: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent - 74, // 256: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse - 74, // 257: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse - 75, // 258: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse - 102, // 259: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse - 101, // 260: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse - 104, // 261: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse - 106, // 262: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse - 108, // 263: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse - 74, // 264: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse - 92, // 265: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse - 94, // 266: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse - 96, // 267: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse - 98, // 268: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse - 109, // 269: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse - 111, // 270: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse - 241, // 271: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse - 242, // 272: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse - 126, // 273: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse - 128, // 274: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse - 130, // 275: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse - 132, // 276: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse - 115, // 277: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse - 117, // 278: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse - 137, // 279: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse - 136, // 280: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse - 139, // 281: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage - 146, // 282: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse - 148, // 283: openshell.v1.OpenShell.FinalizeMainProcessExit:output_type -> openshell.v1.FinalizeMainProcessExitResponse - 153, // 284: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame - 66, // 285: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent - 163, // 286: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse - 165, // 287: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse - 167, // 288: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse - 169, // 289: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse - 172, // 290: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse - 174, // 291: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse - 176, // 292: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse - 178, // 293: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse - 181, // 294: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse - 9, // 295: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse - 11, // 296: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse - 187, // 297: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse - 189, // 298: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse - 191, // 299: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse - 193, // 300: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse - 196, // 301: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse - 198, // 302: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse - 200, // 303: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse - 235, // [235:304] is the sub-list for method output_type - 166, // [166:235] is the sub-list for method input_type - 166, // [166:166] is the sub-list for extension type_name - 166, // [166:166] is the sub-list for extension extendee - 0, // [0:166] is the sub-list for field type_name + 32, // 7: openshell.v1.Sandbox.status:type_name -> openshell.v1.SandboxStatus + 31, // 8: openshell.v1.Sandbox.created_from_workload_template:type_name -> openshell.v1.SandboxWorkloadTemplateProvenance + 216, // 9: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry + 24, // 10: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate + 243, // 11: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 22, // 12: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements + 23, // 13: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements + 217, // 14: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry + 218, // 15: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry + 219, // 16: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry + 244, // 17: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct + 244, // 18: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct + 242, // 19: openshell.v1.SandboxWorkloadTemplate.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 26, // 20: openshell.v1.SandboxWorkloadTemplate.spec:type_name -> openshell.v1.SandboxWorkloadTemplateSpec + 27, // 21: openshell.v1.SandboxWorkloadTemplateSpec.workload:type_name -> openshell.v1.SandboxWorkloadConfig + 244, // 22: openshell.v1.SandboxWorkloadTemplateSpec.driver_config:type_name -> google.protobuf.Struct + 29, // 23: openshell.v1.SandboxWorkloadTemplateSpec.desired_service_level:type_name -> openshell.v1.SandboxServiceLevel + 220, // 24: openshell.v1.SandboxWorkloadConfig.environment:type_name -> openshell.v1.SandboxWorkloadConfig.EnvironmentEntry + 28, // 25: openshell.v1.SandboxWorkloadConfig.resources:type_name -> openshell.v1.SandboxResources + 23, // 26: openshell.v1.SandboxResources.gpu:type_name -> openshell.v1.GpuResourceRequirements + 30, // 27: openshell.v1.SandboxServiceLevel.startup:type_name -> openshell.v1.SandboxStartup + 245, // 28: openshell.v1.SandboxStartup.ready_within:type_name -> google.protobuf.Duration + 33, // 29: openshell.v1.SandboxStatus.conditions:type_name -> openshell.v1.SandboxCondition + 0, // 30: openshell.v1.SandboxStatus.phase:type_name -> openshell.v1.SandboxPhase + 221, // 31: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry + 21, // 32: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec + 222, // 33: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry + 223, // 34: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry + 25, // 35: openshell.v1.CreateSandboxTemplateRequest.template:type_name -> openshell.v1.SandboxWorkloadTemplate + 25, // 36: openshell.v1.SandboxTemplateResponse.template:type_name -> openshell.v1.SandboxWorkloadTemplate + 25, // 37: openshell.v1.ListSandboxTemplatesResponse.templates:type_name -> openshell.v1.SandboxWorkloadTemplate + 20, // 38: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox + 20, // 39: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox + 246, // 40: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 20, // 41: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox + 20, // 42: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox + 66, // 43: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse + 242, // 44: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 65, // 45: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint + 224, // 46: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry + 70, // 47: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout + 71, // 48: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr + 72, // 49: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit + 164, // 50: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget + 165, // 51: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget + 74, // 52: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit + 69, // 53: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest + 77, // 54: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize + 242, // 55: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 20, // 56: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox + 81, // 57: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine + 34, // 58: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent + 82, // 59: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning + 175, // 60: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate + 225, // 61: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry + 246, // 62: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 246, // 63: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 226, // 64: openshell.v1.UpdateProviderRequest.credential_expires_at_ms:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry + 246, // 65: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider + 246, // 66: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 113, // 67: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile + 94, // 68: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride + 1, // 69: openshell.v1.ProviderCredentialTokenGrant.grant_type:type_name -> openshell.v1.ProviderCredentialTokenGrantType + 95, // 70: openshell.v1.ProviderCredentialTokenGrant.subject_token:type_name -> openshell.v1.ProviderCredentialTokenGrantSubjectToken + 100, // 71: openshell.v1.ProviderProfileCredential.refresh:type_name -> openshell.v1.ProviderCredentialRefresh + 96, // 72: openshell.v1.ProviderProfileCredential.token_grant:type_name -> openshell.v1.ProviderCredentialTokenGrant + 2, // 73: openshell.v1.ProviderCredentialRefresh.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 98, // 74: openshell.v1.ProviderCredentialRefresh.material:type_name -> openshell.v1.ProviderCredentialRefreshMaterial + 99, // 75: openshell.v1.ProviderCredentialRefresh.additional_outputs:type_name -> openshell.v1.ProviderCredentialRefreshOutput + 2, // 76: openshell.v1.ProviderCredentialRefreshStatus.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 7, // 77: openshell.v1.ProviderCredentialRefreshStatus.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction + 242, // 78: openshell.v1.StoredProviderCredentialRefreshState.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 2, // 79: openshell.v1.StoredProviderCredentialRefreshState.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 227, // 80: openshell.v1.StoredProviderCredentialRefreshState.material:type_name -> openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry + 228, // 81: openshell.v1.StoredProviderCredentialRefreshState.additional_output_keys:type_name -> openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry + 229, // 82: openshell.v1.StoredProviderCredentialRefreshState.secret_material_handles:type_name -> openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry + 104, // 83: openshell.v1.StoredProviderCredentialRefreshState.pending_secret_deletions:type_name -> openshell.v1.StoredRefreshMaterialDeletion + 7, // 84: openshell.v1.StoredProviderCredentialRefreshState.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction + 247, // 85: openshell.v1.StoredRefreshMaterialDeletion.handle:type_name -> openshell.datamodel.v1.CredentialHandle + 101, // 86: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 2, // 87: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 230, // 88: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + 101, // 89: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 101, // 90: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 3, // 91: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory + 97, // 92: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential + 248, // 93: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 249, // 94: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 102, // 95: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery + 231, // 96: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry + 242, // 97: openshell.v1.StoredProviderProfile.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 113, // 98: openshell.v1.StoredProviderProfile.profile:type_name -> openshell.v1.ProviderProfile + 113, // 99: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile + 113, // 100: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile + 92, // 101: openshell.v1.ImportProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem + 93, // 102: openshell.v1.ImportProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 113, // 103: openshell.v1.ImportProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile + 92, // 104: openshell.v1.UpdateProviderProfilesRequest.profile:type_name -> openshell.v1.ProviderProfileImportItem + 93, // 105: openshell.v1.UpdateProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 113, // 106: openshell.v1.UpdateProviderProfilesResponse.profile:type_name -> openshell.v1.ProviderProfile + 92, // 107: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem + 93, // 108: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 127, // 109: openshell.v1.StaticCredentialBinding.endpoints:type_name -> openshell.v1.StaticCredentialEndpointBinding + 232, // 110: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + 233, // 111: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expires_at_ms:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry + 234, // 112: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + 235, // 113: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry + 243, // 114: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 250, // 115: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue + 133, // 116: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation + 236, // 117: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry + 134, // 118: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule + 135, // 119: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint + 136, // 120: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule + 137, // 121: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules + 138, // 122: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules + 139, // 123: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary + 251, // 124: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 252, // 125: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 253, // 126: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule + 237, // 127: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry + 147, // 128: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision + 147, // 129: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision + 4, // 130: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus + 4, // 131: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus + 243, // 132: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 238, // 133: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry + 81, // 134: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine + 81, // 135: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine + 154, // 136: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello + 157, // 137: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat + 168, // 138: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult + 169, // 139: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose + 155, // 140: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted + 156, // 141: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected + 158, // 142: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat + 163, // 143: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen + 169, // 144: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose + 164, // 145: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget + 165, // 146: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget + 166, // 147: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit + 170, // 148: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample + 172, // 149: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount + 251, // 150: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 243, // 151: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 243, // 152: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 171, // 153: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary + 174, // 154: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk + 173, // 155: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary + 174, // 156: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk + 184, // 157: openshell.v1.ApproveAllDraftChunksRequest.approvals:type_name -> openshell.v1.DraftChunkApproval + 251, // 158: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 194, // 159: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry + 243, // 160: openshell.v1.PolicyRevisionPayload.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 239, // 161: openshell.v1.PolicyRevisionPayload.provenance:type_name -> openshell.v1.PolicyRevisionPayload.ProvenanceEntry + 251, // 162: openshell.v1.DraftChunkPayload.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 243, // 163: openshell.v1.DraftChunkPayload.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 243, // 164: openshell.v1.DraftChunkPayload.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 240, // 165: openshell.v1.StoredPolicyRevision.provenance:type_name -> openshell.v1.StoredPolicyRevision.ProvenanceEntry + 243, // 166: openshell.v1.StoredDraftChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 243, // 167: openshell.v1.StoredDraftChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 241, // 168: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry + 254, // 169: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 254, // 170: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 254, // 171: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace + 242, // 172: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 6, // 173: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole + 6, // 174: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole + 208, // 175: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember + 208, // 176: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember + 247, // 177: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry.value:type_name -> openshell.datamodel.v1.CredentialHandle + 97, // 178: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential + 128, // 179: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding + 12, // 180: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest + 14, // 181: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest + 16, // 182: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest + 35, // 183: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest + 43, // 184: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest + 44, // 185: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest + 36, // 186: openshell.v1.OpenShell.CreateSandboxTemplate:input_type -> openshell.v1.CreateSandboxTemplateRequest + 37, // 187: openshell.v1.OpenShell.GetSandboxTemplate:input_type -> openshell.v1.GetSandboxTemplateRequest + 38, // 188: openshell.v1.OpenShell.ListSandboxTemplates:input_type -> openshell.v1.ListSandboxTemplatesRequest + 39, // 189: openshell.v1.OpenShell.DeleteSandboxTemplate:input_type -> openshell.v1.DeleteSandboxTemplateRequest + 45, // 190: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest + 46, // 191: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest + 47, // 192: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest + 48, // 193: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest + 49, // 194: openshell.v1.OpenShell.StopSandbox:input_type -> openshell.v1.StopSandboxRequest + 50, // 195: openshell.v1.OpenShell.StartSandbox:input_type -> openshell.v1.StartSandboxRequest + 57, // 196: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest + 59, // 197: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest + 60, // 198: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest + 61, // 199: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest + 63, // 200: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest + 67, // 201: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest + 69, // 202: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest + 75, // 203: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame + 76, // 204: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput + 83, // 205: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest + 84, // 206: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest + 85, // 207: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest + 90, // 208: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest + 91, // 209: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest + 117, // 210: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest + 119, // 211: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest + 121, // 212: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest + 86, // 213: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest + 105, // 214: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest + 107, // 215: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest + 109, // 216: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest + 111, // 217: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest + 87, // 218: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest + 124, // 219: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest + 255, // 220: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest + 256, // 221: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest + 132, // 222: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest + 141, // 223: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest + 143, // 224: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest + 145, // 225: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest + 126, // 226: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest + 130, // 227: openshell.v1.OpenShell.ExchangeProviderSubjectToken:input_type -> openshell.v1.ExchangeProviderSubjectTokenRequest + 148, // 228: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest + 149, // 229: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest + 152, // 230: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage + 159, // 231: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest + 161, // 232: openshell.v1.OpenShell.FinalizeMainProcessExit:input_type -> openshell.v1.FinalizeMainProcessExitRequest + 167, // 233: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame + 79, // 234: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest + 176, // 235: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest + 178, // 236: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest + 180, // 237: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest + 182, // 238: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest + 185, // 239: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest + 187, // 240: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest + 189, // 241: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest + 191, // 242: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest + 193, // 243: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest + 8, // 244: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest + 10, // 245: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest + 200, // 246: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest + 202, // 247: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest + 204, // 248: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest + 206, // 249: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest + 209, // 250: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest + 211, // 251: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest + 213, // 252: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest + 13, // 253: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse + 15, // 254: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse + 17, // 255: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse + 51, // 256: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse + 51, // 257: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse + 52, // 258: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse + 40, // 259: openshell.v1.OpenShell.CreateSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse + 40, // 260: openshell.v1.OpenShell.GetSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse + 41, // 261: openshell.v1.OpenShell.ListSandboxTemplates:output_type -> openshell.v1.ListSandboxTemplatesResponse + 42, // 262: openshell.v1.OpenShell.DeleteSandboxTemplate:output_type -> openshell.v1.DeleteSandboxTemplateResponse + 53, // 263: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse + 54, // 264: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse + 55, // 265: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse + 56, // 266: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse + 51, // 267: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse + 51, // 268: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse + 58, // 269: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse + 66, // 270: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse + 66, // 271: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse + 62, // 272: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse + 64, // 273: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse + 68, // 274: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse + 73, // 275: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent + 75, // 276: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame + 73, // 277: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent + 88, // 278: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse + 88, // 279: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse + 89, // 280: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse + 116, // 281: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse + 115, // 282: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse + 118, // 283: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse + 120, // 284: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse + 122, // 285: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse + 88, // 286: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse + 106, // 287: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse + 108, // 288: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse + 110, // 289: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse + 112, // 290: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse + 123, // 291: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse + 125, // 292: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse + 257, // 293: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse + 258, // 294: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse + 140, // 295: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse + 142, // 296: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse + 144, // 297: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse + 146, // 298: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse + 129, // 299: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse + 131, // 300: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse + 151, // 301: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse + 150, // 302: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse + 153, // 303: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage + 160, // 304: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse + 162, // 305: openshell.v1.OpenShell.FinalizeMainProcessExit:output_type -> openshell.v1.FinalizeMainProcessExitResponse + 167, // 306: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame + 80, // 307: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent + 177, // 308: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse + 179, // 309: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse + 181, // 310: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse + 183, // 311: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse + 186, // 312: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse + 188, // 313: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse + 190, // 314: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse + 192, // 315: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse + 195, // 316: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse + 9, // 317: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse + 11, // 318: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse + 201, // 319: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse + 203, // 320: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse + 205, // 321: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse + 207, // 322: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse + 210, // 323: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse + 212, // 324: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse + 214, // 325: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse + 253, // [253:326] is the sub-list for method output_type + 180, // [180:253] is the sub-list for method input_type + 180, // [180:180] is the sub-list for extension type_name + 180, // [180:180] is the sub-list for extension extendee + 0, // [0:180] is the sub-list for field type_name } func init() { file_openshell_proto_init() } @@ -15933,34 +16821,34 @@ func file_openshell_proto_init() { } file_openshell_proto_msgTypes[15].OneofWrappers = []any{} file_openshell_proto_msgTypes[16].OneofWrappers = []any{} - file_openshell_proto_msgTypes[17].OneofWrappers = []any{} - file_openshell_proto_msgTypes[51].OneofWrappers = []any{ + file_openshell_proto_msgTypes[24].OneofWrappers = []any{} + file_openshell_proto_msgTypes[65].OneofWrappers = []any{ (*ExecSandboxEvent_Stdout)(nil), (*ExecSandboxEvent_Stderr)(nil), (*ExecSandboxEvent_Exit)(nil), } - file_openshell_proto_msgTypes[52].OneofWrappers = []any{ + file_openshell_proto_msgTypes[66].OneofWrappers = []any{ (*TcpForwardInit_Ssh)(nil), (*TcpForwardInit_Tcp)(nil), } - file_openshell_proto_msgTypes[53].OneofWrappers = []any{ + file_openshell_proto_msgTypes[67].OneofWrappers = []any{ (*TcpForwardFrame_Init)(nil), (*TcpForwardFrame_Data)(nil), } - file_openshell_proto_msgTypes[54].OneofWrappers = []any{ + file_openshell_proto_msgTypes[68].OneofWrappers = []any{ (*ExecSandboxInput_Start)(nil), (*ExecSandboxInput_Stdin)(nil), (*ExecSandboxInput_Resize)(nil), } - file_openshell_proto_msgTypes[58].OneofWrappers = []any{ + file_openshell_proto_msgTypes[72].OneofWrappers = []any{ (*SandboxStreamEvent_Sandbox)(nil), (*SandboxStreamEvent_Log)(nil), (*SandboxStreamEvent_Event)(nil), (*SandboxStreamEvent_Warning)(nil), (*SandboxStreamEvent_DraftPolicyUpdate)(nil), } - file_openshell_proto_msgTypes[85].OneofWrappers = []any{} - file_openshell_proto_msgTypes[111].OneofWrappers = []any{ + file_openshell_proto_msgTypes[99].OneofWrappers = []any{} + file_openshell_proto_msgTypes[125].OneofWrappers = []any{ (*PolicyMergeOperation_AddRule)(nil), (*PolicyMergeOperation_RemoveEndpoint)(nil), (*PolicyMergeOperation_RemoveRule)(nil), @@ -15968,36 +16856,36 @@ func file_openshell_proto_init() { (*PolicyMergeOperation_AddAllowRules)(nil), (*PolicyMergeOperation_RemoveBinary)(nil), } - file_openshell_proto_msgTypes[130].OneofWrappers = []any{ + file_openshell_proto_msgTypes[144].OneofWrappers = []any{ (*SupervisorMessage_Hello)(nil), (*SupervisorMessage_Heartbeat)(nil), (*SupervisorMessage_RelayOpenResult)(nil), (*SupervisorMessage_RelayClose)(nil), } - file_openshell_proto_msgTypes[131].OneofWrappers = []any{ + file_openshell_proto_msgTypes[145].OneofWrappers = []any{ (*GatewayMessage_SessionAccepted)(nil), (*GatewayMessage_SessionRejected)(nil), (*GatewayMessage_Heartbeat)(nil), (*GatewayMessage_RelayOpen)(nil), (*GatewayMessage_RelayClose)(nil), } - file_openshell_proto_msgTypes[141].OneofWrappers = []any{ + file_openshell_proto_msgTypes[155].OneofWrappers = []any{ (*RelayOpen_Ssh)(nil), (*RelayOpen_Tcp)(nil), } - file_openshell_proto_msgTypes[145].OneofWrappers = []any{ + file_openshell_proto_msgTypes[159].OneofWrappers = []any{ (*RelayFrame_Init)(nil), (*RelayFrame_Data)(nil), } - file_openshell_proto_msgTypes[176].OneofWrappers = []any{} - file_openshell_proto_msgTypes[177].OneofWrappers = []any{} + file_openshell_proto_msgTypes[190].OneofWrappers = []any{} + file_openshell_proto_msgTypes[191].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_openshell_proto_rawDesc), len(file_openshell_proto_rawDesc)), NumEnums: 8, - NumMessages: 219, + NumMessages: 234, NumExtensions: 0, NumServices: 1, }, diff --git a/sdk/go/proto/openshellv1/openshell_grpc.pb.go b/sdk/go/proto/openshellv1/openshell_grpc.pb.go index 9220f22999..61bc081437 100644 --- a/sdk/go/proto/openshellv1/openshell_grpc.pb.go +++ b/sdk/go/proto/openshellv1/openshell_grpc.pb.go @@ -29,6 +29,10 @@ const ( OpenShell_CreateSandbox_FullMethodName = "/openshell.v1.OpenShell/CreateSandbox" OpenShell_GetSandbox_FullMethodName = "/openshell.v1.OpenShell/GetSandbox" OpenShell_ListSandboxes_FullMethodName = "/openshell.v1.OpenShell/ListSandboxes" + OpenShell_CreateSandboxTemplate_FullMethodName = "/openshell.v1.OpenShell/CreateSandboxTemplate" + OpenShell_GetSandboxTemplate_FullMethodName = "/openshell.v1.OpenShell/GetSandboxTemplate" + OpenShell_ListSandboxTemplates_FullMethodName = "/openshell.v1.OpenShell/ListSandboxTemplates" + OpenShell_DeleteSandboxTemplate_FullMethodName = "/openshell.v1.OpenShell/DeleteSandboxTemplate" OpenShell_ListSandboxProviders_FullMethodName = "/openshell.v1.OpenShell/ListSandboxProviders" OpenShell_AttachSandboxProvider_FullMethodName = "/openshell.v1.OpenShell/AttachSandboxProvider" OpenShell_DetachSandboxProvider_FullMethodName = "/openshell.v1.OpenShell/DetachSandboxProvider" @@ -119,6 +123,14 @@ type OpenShellClient interface { GetSandbox(ctx context.Context, in *GetSandboxRequest, opts ...grpc.CallOption) (*SandboxResponse, error) // List sandboxes. ListSandboxes(ctx context.Context, in *ListSandboxesRequest, opts ...grpc.CallOption) (*ListSandboxesResponse, error) + // Create a reusable sandbox workload template. + CreateSandboxTemplate(ctx context.Context, in *CreateSandboxTemplateRequest, opts ...grpc.CallOption) (*SandboxTemplateResponse, error) + // Fetch a reusable sandbox workload template by name. + GetSandboxTemplate(ctx context.Context, in *GetSandboxTemplateRequest, opts ...grpc.CallOption) (*SandboxTemplateResponse, error) + // List reusable sandbox workload templates. + ListSandboxTemplates(ctx context.Context, in *ListSandboxTemplatesRequest, opts ...grpc.CallOption) (*ListSandboxTemplatesResponse, error) + // Delete a reusable sandbox workload template by name. + DeleteSandboxTemplate(ctx context.Context, in *DeleteSandboxTemplateRequest, opts ...grpc.CallOption) (*DeleteSandboxTemplateResponse, error) // List provider records attached to a sandbox. ListSandboxProviders(ctx context.Context, in *ListSandboxProvidersRequest, opts ...grpc.CallOption) (*ListSandboxProvidersResponse, error) // Attach a provider record to an existing sandbox. @@ -354,6 +366,46 @@ func (c *openShellClient) ListSandboxes(ctx context.Context, in *ListSandboxesRe return out, nil } +func (c *openShellClient) CreateSandboxTemplate(ctx context.Context, in *CreateSandboxTemplateRequest, opts ...grpc.CallOption) (*SandboxTemplateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SandboxTemplateResponse) + err := c.cc.Invoke(ctx, OpenShell_CreateSandboxTemplate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) GetSandboxTemplate(ctx context.Context, in *GetSandboxTemplateRequest, opts ...grpc.CallOption) (*SandboxTemplateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SandboxTemplateResponse) + err := c.cc.Invoke(ctx, OpenShell_GetSandboxTemplate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ListSandboxTemplates(ctx context.Context, in *ListSandboxTemplatesRequest, opts ...grpc.CallOption) (*ListSandboxTemplatesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListSandboxTemplatesResponse) + err := c.cc.Invoke(ctx, OpenShell_ListSandboxTemplates_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) DeleteSandboxTemplate(ctx context.Context, in *DeleteSandboxTemplateRequest, opts ...grpc.CallOption) (*DeleteSandboxTemplateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteSandboxTemplateResponse) + err := c.cc.Invoke(ctx, OpenShell_DeleteSandboxTemplate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *openShellClient) ListSandboxProviders(ctx context.Context, in *ListSandboxProvidersRequest, opts ...grpc.CallOption) (*ListSandboxProvidersResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ListSandboxProvidersResponse) @@ -1042,6 +1094,14 @@ type OpenShellServer interface { GetSandbox(context.Context, *GetSandboxRequest) (*SandboxResponse, error) // List sandboxes. ListSandboxes(context.Context, *ListSandboxesRequest) (*ListSandboxesResponse, error) + // Create a reusable sandbox workload template. + CreateSandboxTemplate(context.Context, *CreateSandboxTemplateRequest) (*SandboxTemplateResponse, error) + // Fetch a reusable sandbox workload template by name. + GetSandboxTemplate(context.Context, *GetSandboxTemplateRequest) (*SandboxTemplateResponse, error) + // List reusable sandbox workload templates. + ListSandboxTemplates(context.Context, *ListSandboxTemplatesRequest) (*ListSandboxTemplatesResponse, error) + // Delete a reusable sandbox workload template by name. + DeleteSandboxTemplate(context.Context, *DeleteSandboxTemplateRequest) (*DeleteSandboxTemplateResponse, error) // List provider records attached to a sandbox. ListSandboxProviders(context.Context, *ListSandboxProvidersRequest) (*ListSandboxProvidersResponse, error) // Attach a provider record to an existing sandbox. @@ -1235,6 +1295,18 @@ func (UnimplementedOpenShellServer) GetSandbox(context.Context, *GetSandboxReque func (UnimplementedOpenShellServer) ListSandboxes(context.Context, *ListSandboxesRequest) (*ListSandboxesResponse, error) { return nil, status.Error(codes.Unimplemented, "method ListSandboxes not implemented") } +func (UnimplementedOpenShellServer) CreateSandboxTemplate(context.Context, *CreateSandboxTemplateRequest) (*SandboxTemplateResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateSandboxTemplate not implemented") +} +func (UnimplementedOpenShellServer) GetSandboxTemplate(context.Context, *GetSandboxTemplateRequest) (*SandboxTemplateResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetSandboxTemplate not implemented") +} +func (UnimplementedOpenShellServer) ListSandboxTemplates(context.Context, *ListSandboxTemplatesRequest) (*ListSandboxTemplatesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListSandboxTemplates not implemented") +} +func (UnimplementedOpenShellServer) DeleteSandboxTemplate(context.Context, *DeleteSandboxTemplateRequest) (*DeleteSandboxTemplateResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteSandboxTemplate not implemented") +} func (UnimplementedOpenShellServer) ListSandboxProviders(context.Context, *ListSandboxProvidersRequest) (*ListSandboxProvidersResponse, error) { return nil, status.Error(codes.Unimplemented, "method ListSandboxProviders not implemented") } @@ -1553,6 +1625,78 @@ func _OpenShell_ListSandboxes_Handler(srv interface{}, ctx context.Context, dec return interceptor(ctx, in, info, handler) } +func _OpenShell_CreateSandboxTemplate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateSandboxTemplateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).CreateSandboxTemplate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_CreateSandboxTemplate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).CreateSandboxTemplate(ctx, req.(*CreateSandboxTemplateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_GetSandboxTemplate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetSandboxTemplateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetSandboxTemplate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetSandboxTemplate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetSandboxTemplate(ctx, req.(*GetSandboxTemplateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ListSandboxTemplates_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListSandboxTemplatesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ListSandboxTemplates(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ListSandboxTemplates_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ListSandboxTemplates(ctx, req.(*ListSandboxTemplatesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_DeleteSandboxTemplate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteSandboxTemplateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).DeleteSandboxTemplate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_DeleteSandboxTemplate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).DeleteSandboxTemplate(ctx, req.(*DeleteSandboxTemplateRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _OpenShell_ListSandboxProviders_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ListSandboxProvidersRequest) if err := dec(in); err != nil { @@ -2649,6 +2793,22 @@ var OpenShell_ServiceDesc = grpc.ServiceDesc{ MethodName: "ListSandboxes", Handler: _OpenShell_ListSandboxes_Handler, }, + { + MethodName: "CreateSandboxTemplate", + Handler: _OpenShell_CreateSandboxTemplate_Handler, + }, + { + MethodName: "GetSandboxTemplate", + Handler: _OpenShell_GetSandboxTemplate_Handler, + }, + { + MethodName: "ListSandboxTemplates", + Handler: _OpenShell_ListSandboxTemplates_Handler, + }, + { + MethodName: "DeleteSandboxTemplate", + Handler: _OpenShell_DeleteSandboxTemplate_Handler, + }, { MethodName: "ListSandboxProviders", Handler: _OpenShell_ListSandboxProviders_Handler, diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index b0e7ed696c..322b1a092c 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -152,11 +152,54 @@ await client.sandbox.setSetting(name, 'feature.enabled', { value: { case: 'boolV Sandbox-scoped `setPolicy` may only change `networkPolicies`; static fields (`filesystem`, `landlock`, `process`) must match the create-time policy. Sandbox-scoped setting deletes are rejected by the gateway, so only upsert (`setSetting`) is exposed here. +## Sandbox templates + +Sandbox workload templates are reusable, workspace-scoped runtime shapes. They +own image, environment, resource, and driver-specific settings; sandbox creation +from a template can still attach labels, providers, and create-time policy. + +```ts +import { OpenShellClient, type SandboxWorkloadTemplate } from '@nvidia/openshell-sdk' + +const client = await OpenShellClient.connect({ gateway, oidcToken }) + +const template: SandboxWorkloadTemplate = await client.sandboxTemplates.create( + { + metadata: { name: 'python', labels: { team: 'runtime' } }, + spec: { + workload: { + image: 'ghcr.io/nvidia/openshell-community/sandboxes/python:latest', + environment: { FEATURE_FLAG: 'on' }, + resources: { cpu: '1', memory: '512Mi' }, + }, + driverConfig: { kubernetes: { pod: { runtime_class_name: 'kata-containers' } } }, + }, + }, + { workspace: 'default' }, +) + +const sandbox = await client.sandbox.createFromTemplate({ + templateName: template.metadata!.name, + workspace: 'default', + providers: ['github'], + policy: { version: 1, networkPolicies: {} }, +}) + +await client.sandboxTemplates.get('python', { workspace: 'default' }) +await client.sandboxTemplates.list({ workspace: 'default', limit: 100 }) +await client.sandboxTemplates.delete('python', { workspace: 'default' }) +``` + +Use `allWorkspaces: true` on `list()` for a platform-admin view. The SDK clears +the workspace field in that request because the gateway treats `workspace` and +`allWorkspaces` as mutually exclusive. + ## Surface and roadmap The SDK's goal is agent parity: anything the OpenShell gateway can do should be reachable from typed code, not only the CLI. The API is organized as scoped sub-clients over one shared connection, mirroring the CLI's verbs. - `client.sandbox` (`SandboxClient`) is available today: sandbox lifecycle, exec, forward, SSH, sandbox-scoped providers, config, and policy. +- `client.sandboxTemplates` (`SandboxTemplateClient`) is available today: reusable sandbox workload template CRUD. - `client.gateway` (`GatewayClient`) is planned: gateway-scoped config and settings, health, and cluster status. - `client.providers` (`ProviderClient`) is planned: gateway-scoped provider CRUD and profiles. diff --git a/sdk/typescript/src/client.test.ts b/sdk/typescript/src/client.test.ts index 29a7438c69..5b12a48e23 100644 --- a/sdk/typescript/src/client.test.ts +++ b/sdk/typescript/src/client.test.ts @@ -17,6 +17,7 @@ import { POLICY_SOURCE_NAMES, Pushable, SandboxClient, + SandboxTemplateClient, SCOPE_NAMES, STATUS_NAMES, } from './client.js'; @@ -30,15 +31,25 @@ function client(impl: Partial>): SandboxClient { return new SandboxClient(transport); } +function templateClient(impl: Partial>): SandboxTemplateClient { + const transport: Transport = createRouterTransport((router) => { + router.service(OpenShell, impl); + }); + return new SandboxTemplateClient(transport); +} + function readySandbox( name: string, id: string, resourceVersion = 7n, + createdFromWorkloadTemplate?: { name: string; resourceVersion: string }, + workspace = 'default', ): MessageInitShape { return { sandbox: { - metadata: { id, name, labels: { team: 'aire' }, resourceVersion }, + metadata: { id, name, workspace, labels: { team: 'aire' }, resourceVersion }, status: { phase: SandboxPhase.READY }, + createdFromWorkloadTemplate, }, }; } @@ -261,6 +272,207 @@ describe('create', () => { expect(created.spec?.providers).toEqual(['claude']); }); + it('createFromTemplate sends the workload template name with governance fields only', async () => { + let created: { + workloadTemplateName?: string; + name?: string; + workspace?: string; + labels?: Record; + spec?: { + policy?: { version?: number }; + providers?: string[]; + command?: string[]; + tty?: boolean; + template?: { image?: string }; + }; + } = {}; + const sandbox = client({ + createSandbox: (req) => { + created = req; + return readySandbox('job-1', 'sb-id', 7n, undefined, req.workspace || 'default'); + }, + }); + const ref = await sandbox.createFromTemplate({ + name: 'job-1', + workspace: 'staging', + templateName: 'gpu-kata', + labels: { team: 'runtime' }, + providers: ['github'], + command: ['/opt/worker', '--serve'], + tty: true, + policy: { version: 1, networkPolicies: {} }, + }); + + expect(created.workloadTemplateName).toBe('gpu-kata'); + expect(created.name).toBe('job-1'); + expect(created.workspace).toBe('staging'); + expect(created.labels).toEqual({ team: 'runtime' }); + expect(created.spec?.providers).toEqual(['github']); + expect(created.spec?.command).toEqual(['/opt/worker', '--serve']); + expect(created.spec?.tty).toBe(true); + expect(created.spec?.policy?.version).toBe(1); + expect(created.spec?.template).toBeUndefined(); + expect(ref.workspace).toBe('staging'); + }); + + it('propagates workspace through sandbox lifecycle calls', async () => { + const observed: { + create?: { workspace?: string }; + get?: { workspace?: string }; + list?: { workspace?: string; allWorkspaces?: boolean }; + delete?: { workspace?: string }; + attach?: { workspace?: string }; + detach?: { workspace?: string }; + listProviders?: { workspace?: string }; + updatePolicy?: { workspace?: string }; + updateSetting?: { workspace?: string }; + configGets: string[]; + execGet?: string; + interactiveGet?: string; + sshGet?: string; + forwardGet?: string; + } = { configGets: [] }; + const sandbox = client({ + createSandbox: (req) => { + observed.create = req; + return readySandbox(req.name || 'sb', 'sb-created', 7n, undefined, req.workspace || 'default'); + }, + getSandbox: (req) => { + if (req.name === 'exec') observed.execGet = req.workspace; + else if (req.name === 'interactive') observed.interactiveGet = req.workspace; + else if (req.name === 'ssh') observed.sshGet = req.workspace; + else if (req.name === 'forward') observed.forwardGet = req.workspace; + else if (req.name === 'config') observed.configGets.push(req.workspace); + else observed.get = req; + return readySandbox(req.name, `${req.name}-id`, 7n, undefined, req.workspace || 'default'); + }, + listSandboxes: (req) => { + observed.list = req; + return { + sandboxes: [ + { + metadata: { + id: 'listed-id', + name: 'listed', + workspace: req.workspace || 'default', + labels: { team: 'aire' }, + resourceVersion: 7n, + }, + status: { phase: SandboxPhase.READY }, + }, + ], + }; + }, + deleteSandbox: (req) => { + observed.delete = req; + return { deleted: true }; + }, + attachSandboxProvider: (req) => { + observed.attach = req; + return { + sandbox: readySandbox(req.sandboxName, 'attach-id', 7n, undefined, req.workspace || 'default').sandbox, + attached: true, + }; + }, + detachSandboxProvider: (req) => { + observed.detach = req; + return { + sandbox: readySandbox(req.sandboxName, 'detach-id', 7n, undefined, req.workspace || 'default').sandbox, + detached: true, + }; + }, + listSandboxProviders: (req) => { + observed.listProviders = req; + return { providers: [] }; + }, + updateConfig: (req) => { + if (req.settingKey) observed.updateSetting = req; + else observed.updatePolicy = req; + return { version: 5, policyHash: 'hash', settingsRevision: 10n, deleted: false }; + }, + getSandboxConfig: () => ({ + policy: { version: 1, networkPolicies: {} }, + version: 5, + policyHash: 'hash', + settings: {}, + configRevision: 1n, + policySource: PolicySource.SANDBOX, + globalPolicyVersion: 0, + providerEnvRevision: 0n, + }), + // eslint-disable-next-line require-yield + execSandbox: async function* () { + yield { payload: { case: 'exit', value: { exitCode: 0 } } }; + }, + // eslint-disable-next-line require-yield + execSandboxInteractive: async function* () { + yield { payload: { case: 'exit', value: { exitCode: 0 } } }; + }, + createSshSession: (req) => ({ + sandboxId: req.sandboxId, + token: 'tok', + gatewayHost: 'gw', + gatewayPort: 443, + gatewayScheme: 'https', + hostKeyFingerprint: '', + expiresAtMs: 0n, + }), + revokeSshSession: () => ({ revoked: true }), + }); + + const created = await sandbox.create({ name: 'direct', workspace: 'staging', image: 'img' }); + const got = await sandbox.get('lookup', { workspace: 'staging' }); + const listed = await sandbox.list({ workspace: 'staging', limit: 10 }); + const deleted = await sandbox.delete('lookup', { workspace: 'staging' }); + await expect(sandbox.waitReady('lookup', 1, { workspace: 'staging' })).resolves.toMatchObject({ + workspace: 'staging', + }); + await expect(sandbox.exec('exec', ['true'], { workspace: 'staging' })).resolves.toMatchObject({ exitCode: 0 }); + const interactive = await sandbox.execInteractive('interactive', ['true'], { workspace: 'staging' }); + for await (const _event of interactive.output) { + // drain + } + await sandbox.createSshSession('ssh', { workspace: 'staging' }); + const attached = await sandbox.attachProvider('lookup', 'github', { workspace: 'staging' }); + const detached = await sandbox.detachProvider('lookup', 'github', { workspace: 'staging' }); + await sandbox.listProviders('lookup', { workspace: 'staging' }); + await sandbox.getConfig('config', { workspace: 'staging' }); + await sandbox.setPolicy('lookup', { version: 1, networkPolicies: {} }, { workspace: 'staging' }); + await sandbox.setSetting( + 'lookup', + 'feature.enabled', + { value: { case: 'boolValue', value: true } }, + { workspace: 'staging' }, + ); + + expect(created.workspace).toBe('staging'); + expect(got.workspace).toBe('staging'); + expect(listed[0]?.workspace).toBe('staging'); + expect(deleted).toBe(true); + expect(attached.sandbox.workspace).toBe('staging'); + expect(detached.sandbox.workspace).toBe('staging'); + expect(observed.create?.workspace).toBe('staging'); + expect(observed.get?.workspace).toBe('staging'); + expect(observed.list).toMatchObject({ workspace: 'staging', allWorkspaces: false }); + expect(observed.delete?.workspace).toBe('staging'); + expect(observed.execGet).toBe('staging'); + expect(observed.interactiveGet).toBe('staging'); + expect(observed.sshGet).toBe('staging'); + expect(observed.attach?.workspace).toBe('staging'); + expect(observed.detach?.workspace).toBe('staging'); + expect(observed.listProviders?.workspace).toBe('staging'); + expect(observed.configGets).toContain('staging'); + expect(observed.updatePolicy?.workspace).toBe('staging'); + expect(observed.updateSetting?.workspace).toBe('staging'); + }); + + it('createFromTemplate rejects an empty template name locally', async () => { + const sandbox = client({}); + await expect(sandbox.createFromTemplate({ templateName: ' ' })).rejects.toMatchObject({ + code: 'invalid_config', + }); + }); + it('rejects gateway sandboxes missing required metadata', async () => { const sandbox = client({ getSandbox: () => ({ sandbox: { status: { phase: SandboxPhase.READY } } }), @@ -288,6 +500,158 @@ describe('create', () => { exitCode: 9, }); }); + + it('maps workload template provenance onto SandboxRef', async () => { + const sandbox = client({ + getSandbox: () => + readySandbox('from-template', 'sb-id', 7n, { + name: 'gpu-kata', + resourceVersion: '42', + }), + }); + + const ref = await sandbox.get('from-template'); + + expect(ref.createdFromWorkloadTemplate).toEqual({ + name: 'gpu-kata', + resourceVersion: '42', + }); + }); +}); + +describe('sandbox templates', () => { + it('create sends the template resource and workspace', async () => { + let observed: { + workspace?: string; + template?: { + metadata?: { name?: string; labels?: Record }; + spec?: { + workload?: { + image?: string; + environment?: Record; + resources?: { cpu?: string; memory?: string; gpu?: { count?: number } }; + }; + driverConfig?: Record; + }; + }; + } = {}; + const templates = templateClient({ + createSandboxTemplate: (req) => { + observed = req; + return { + template: { + metadata: { + id: 'template-python', + name: req.template?.metadata?.name ?? '', + labels: req.template?.metadata?.labels ?? {}, + workspace: req.workspace, + resourceVersion: 1n, + }, + spec: req.template?.spec, + }, + }; + }, + }); + + const created = await templates.create( + { + metadata: { name: 'python', labels: { team: 'runtime' } }, + spec: { + workload: { + image: 'ghcr.io/nvidia/openshell-community/sandboxes/python:latest', + environment: { FEATURE_FLAG: 'on' }, + resources: { cpu: '1', memory: '512Mi', gpu: { count: 1 } }, + }, + driverConfig: { kubernetes: { runtime_class_name: 'kata-containers' } }, + }, + }, + { workspace: 'default' }, + ); + + expect(observed.workspace).toBe('default'); + expect(observed.template?.metadata?.name).toBe('python'); + expect(observed.template?.metadata?.labels).toEqual({ team: 'runtime' }); + expect(observed.template?.spec?.workload?.environment).toEqual({ FEATURE_FLAG: 'on' }); + expect(observed.template?.spec?.workload?.resources?.gpu?.count).toBe(1); + expect(created.metadata?.workspace).toBe('default'); + expect(created.metadata?.resourceVersion).toBe(1n); + }); + + it('get list and delete forward workspace and pagination', async () => { + const observed: { + get?: { name?: string; workspace?: string }; + list?: { limit?: number; offset?: number; workspace?: string; allWorkspaces?: boolean }; + delete?: { name?: string; workspace?: string }; + } = {}; + const templates = templateClient({ + getSandboxTemplate: (req) => { + observed.get = req; + return { + template: { + metadata: { id: 'template-gpu-kata', name: req.name, workspace: req.workspace }, + spec: { workload: { image: 'img:v1' } }, + }, + }; + }, + listSandboxTemplates: (req) => { + observed.list = req; + return { + templates: [ + { + metadata: { id: 'template-python', name: 'python', workspace: req.workspace || 'default' }, + spec: { workload: { image: 'img:v1' } }, + }, + ], + }; + }, + deleteSandboxTemplate: (req) => { + observed.delete = req; + return { deleted: true }; + }, + }); + + const got = await templates.get('gpu-kata', { workspace: 'staging' }); + const listed = await templates.list({ workspace: 'staging', limit: 10, offset: 2, labelSelector: 'team=runtime' }); + const deleted = await templates.delete('gpu-kata', { workspace: 'staging' }); + + expect(got.metadata?.name).toBe('gpu-kata'); + expect(listed).toHaveLength(1); + expect(deleted).toBe(true); + expect(observed.get).toMatchObject({ name: 'gpu-kata', workspace: 'staging' }); + expect(observed.list).toMatchObject({ + limit: 10, + offset: 2, + workspace: 'staging', + allWorkspaces: false, + labelSelector: 'team=runtime', + }); + expect(observed.delete).toMatchObject({ name: 'gpu-kata', workspace: 'staging' }); + }); + + it('list clears workspace when allWorkspaces is set', async () => { + let observed: { workspace?: string; allWorkspaces?: boolean } = {}; + const templates = templateClient({ + listSandboxTemplates: (req) => { + observed = req; + return { templates: [] }; + }, + }); + + await templates.list({ workspace: 'staging', allWorkspaces: true }); + + expect(observed.workspace).toBe(''); + expect(observed.allWorkspaces).toBe(true); + }); + + it('rejects empty names and missing template responses locally', async () => { + const templates = templateClient({ + getSandboxTemplate: () => ({}), + }); + + await expect(templates.get(' ')).rejects.toMatchObject({ code: 'invalid_config' }); + await expect(templates.delete(' ')).rejects.toMatchObject({ code: 'invalid_config' }); + await expect(templates.get('missing-response')).rejects.toMatchObject({ code: 'invalid_config' }); + }); }); describe('waits', () => { diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index d2bf520800..25b6f952f8 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -17,12 +17,13 @@ import type { MessageInitShape } from '@bufbuild/protobuf'; import { type CallOptions, type Client, createClient, type Transport } from '@connectrpc/connect'; import { errorCode, fromConnect, SdkError } from './errors.js'; import type { Provider } from './gen/datamodel_pb.js'; -import type { Sandbox, UpdateConfigResponse } from './gen/openshell_pb.js'; +import type { Sandbox, SandboxWorkloadTemplate, UpdateConfigResponse } from './gen/openshell_pb.js'; import { type ExecSandboxInputSchema, OpenShell, SandboxPhase, type SandboxSpecSchema, + type SandboxWorkloadTemplateSchema, ServiceStatus, type TcpForwardFrameSchema, } from './gen/openshell_pb.js'; @@ -31,9 +32,16 @@ import { PolicySource, type SandboxPolicySchema, SettingScope, type SettingValue import { validateSshResponse } from './ssh-validate.js'; import { buildTransport, type ConnectOptions } from './transport.js'; -// The policy and setting value shapes are the generated protobuf messages; -// re-export them rather than re-curating a parallel surface. Callers round-trip -// `getConfig().policy` back into `setPolicy`, and build `SettingValue`s inline. +// Generated protobuf message shapes that callers need to populate or round-trip +// directly. Re-export these rather than re-curating parallel surfaces. +export type { + SandboxResources, + SandboxServiceLevel, + SandboxStartup, + SandboxWorkloadConfig, + SandboxWorkloadTemplate, + SandboxWorkloadTemplateSpec, +} from './gen/openshell_pb.js'; export type { SandboxPolicy, SettingValue } from './gen/sandbox_pb.js'; export type { ConnectOptions }; export { errorCode }; @@ -78,6 +86,8 @@ export interface Health { export interface SandboxSpec { name?: string; + /** Workspace scope. Omit or use an empty string for the gateway default workspace. */ + workspace?: string; image?: string; labels?: Record; environment?: Record; @@ -103,24 +113,74 @@ export interface SandboxSpec { rawSpec?: MessageInitShape; } +export interface SandboxFromTemplateSpec { + name?: string; + /** Workspace scope. Omit or use an empty string for the gateway default workspace. */ + workspace?: string; + templateName: string; + labels?: Record; + providers?: string[]; + /** Exact canonical command. Empty selects the gateway scratch shell. */ + command?: string[]; + /** Allocate a retained pseudo-terminal for the canonical command. */ + tty?: boolean; + /** + * Create-time sandbox policy (the safety boundary). The named workload + * template supplies runtime workload fields. + */ + policy?: MessageInitShape; +} + export interface SandboxRef { id: string; name: string; + workspace: string; phase: SandboxPhaseName; labels: Record; /** u64 rendered as a string — JS numbers can't hold it safely. */ resourceVersion: string; mainProcessInstanceId?: string; exitCode?: number; + createdFromWorkloadTemplate?: SandboxWorkloadTemplateProvenance; +} + +export interface SandboxWorkloadTemplateProvenance { + name: string; + resourceVersion: string; } export interface ListOptions { limit?: number; offset?: number; labelSelector?: string; + /** Workspace scope. Omit or use an empty string for the gateway default workspace. */ + workspace?: string; + /** List across all workspaces. Requires platform admin permission. */ + allWorkspaces?: boolean; +} + +export interface SandboxWorkspaceOptions { + /** Workspace scope. Omit or use an empty string for the gateway default workspace. */ + workspace?: string; +} + +export type SandboxCallOptions = CallOptions & SandboxWorkspaceOptions; + +export interface SandboxTemplateWorkspaceOptions { + /** Workspace scope. Omit or use an empty string for the gateway default workspace. */ + workspace?: string; +} + +export interface SandboxTemplateListOptions extends SandboxTemplateWorkspaceOptions { + limit?: number; + offset?: number; + /** Optional label selector in key=value comma-separated form. */ + labelSelector?: string; + /** List templates across all workspaces. Requires platform admin permission. */ + allWorkspaces?: boolean; } -export interface ExecOptions { +export interface ExecOptions extends SandboxWorkspaceOptions { workdir?: string; environment?: Record; timeoutSecs?: number; @@ -158,7 +218,7 @@ export interface ExecExitEvent { /** An exec stream item: a stdout/stderr chunk or the terminal exit event. */ export type ExecStreamEvent = ExecStreamChunk | ExecExitEvent; -export interface ExecInteractiveOptions { +export interface ExecInteractiveOptions extends SandboxWorkspaceOptions { workdir?: string; environment?: Record; timeoutSecs?: number; @@ -190,12 +250,12 @@ export interface ExecInteractiveSession { } /** Cancellation for the poll-based wait helpers. */ -export interface WaitOptions { +export interface WaitOptions extends SandboxWorkspaceOptions { /** Abort the wait (and the in-flight poll RPC) early. */ signal?: AbortSignal; } -export interface ForwardOptions { +export interface ForwardOptions extends SandboxWorkspaceOptions { /** Loopback TCP port inside the sandbox to dial. */ targetPort: number; /** Target host inside the sandbox (loopback only). Default 127.0.0.1. */ @@ -248,7 +308,7 @@ export interface ProviderChange { changed: boolean; } -export interface ProviderChangeOptions { +export interface ProviderChangeOptions extends SandboxWorkspaceOptions { /** Pin the sandbox resource version for optimistic concurrency (u64 as string). */ expectedResourceVersion?: string; } @@ -274,7 +334,7 @@ export interface SandboxConfig { providerEnvRevision: string; } -export interface SetPolicyOptions { +export interface SetPolicyOptions extends SandboxWorkspaceOptions { /** Pin the sandbox resource version for optimistic concurrency (u64 as string). */ expectedResourceVersion?: string; /** Poll getConfig until the applied policy hash is observed. */ @@ -346,14 +406,26 @@ function sandboxRef(sandbox: Sandbox | undefined): SandboxRef { return { id: meta.id, name: meta.name, + workspace: meta.workspace, phase: phaseName(sandbox.status?.phase ?? SandboxPhase.UNSPECIFIED), labels: meta?.labels ?? {}, resourceVersion: (meta?.resourceVersion ?? 0n).toString(), mainProcessInstanceId: sandbox.status?.mainProcessInstanceId || undefined, exitCode: sandbox.status?.exitCode, + createdFromWorkloadTemplate: sandbox.createdFromWorkloadTemplate + ? { + name: sandbox.createdFromWorkloadTemplate.name, + resourceVersion: sandbox.createdFromWorkloadTemplate.resourceVersion, + } + : undefined, }; } +function sandboxTemplate(template: SandboxWorkloadTemplate | undefined): SandboxWorkloadTemplate { + if (!template) throw new SdkError('invalid_config', 'sandbox template missing from gateway response'); + return template; +} + function providerRef(provider: Provider): ProviderRef { const meta = provider.metadata; return { @@ -419,6 +491,16 @@ function versionPin(value: string | undefined): bigint { const FORWARD_CHUNK = 64 * 1024; +function workspaceOption(options?: SandboxWorkspaceOptions | null): string { + return options?.workspace ?? ''; +} + +function requestCallOptions(options?: SandboxCallOptions | null): CallOptions | undefined { + if (!options) return undefined; + const { workspace: _workspace, ...callOptions } = options; + return callOptions; +} + // Build CallOptions that bound one poll RPC by the remaining wall-clock budget // and honor caller cancellation, so a stalled RPC cannot outlive the deadline. function deadlineOptions(remainingMs: number, signal?: AbortSignal): CallOptions { @@ -536,6 +618,85 @@ export class Pushable implements AsyncIterable { } } +// ---- sandbox template client ---------------------------------------------- + +// Reusable sandbox workload template lifecycle. Templates intentionally return +// generated proto messages because the resource owns portable workload fields +// plus driver-specific config that should not be lossy in the curated layer. +export class SandboxTemplateClient { + private readonly grpc: Client; + + readonly raw: Client; + readonly transport: Transport; + + constructor(transport: Transport, grpc = createClient(OpenShell, transport)) { + this.transport = transport; + this.grpc = grpc; + this.raw = this.grpc; + } + + static async connect(options: ConnectOptions): Promise { + return new SandboxTemplateClient(buildTransport(options)); + } + + async create( + template: MessageInitShape, + options?: SandboxTemplateWorkspaceOptions | null, + ): Promise { + try { + const resp = await this.grpc.createSandboxTemplate({ + template, + workspace: options?.workspace ?? '', + }); + return sandboxTemplate(resp.template); + } catch (e) { + throw e instanceof SdkError ? e : fromConnect(e); + } + } + + async get(name: string, options?: SandboxTemplateWorkspaceOptions | null): Promise { + if (name.trim() === '') throw new SdkError('invalid_config', 'template name is required'); + try { + const resp = await this.grpc.getSandboxTemplate({ + name, + workspace: options?.workspace ?? '', + }); + return sandboxTemplate(resp.template); + } catch (e) { + throw e instanceof SdkError ? e : fromConnect(e); + } + } + + async list(options?: SandboxTemplateListOptions | null): Promise { + try { + const allWorkspaces = options?.allWorkspaces ?? false; + const resp = await this.grpc.listSandboxTemplates({ + limit: options?.limit ?? 0, + offset: options?.offset ?? 0, + workspace: allWorkspaces ? '' : (options?.workspace ?? ''), + allWorkspaces, + labelSelector: options?.labelSelector ?? '', + }); + return resp.templates; + } catch (e) { + throw fromConnect(e); + } + } + + async delete(name: string, options?: SandboxTemplateWorkspaceOptions | null): Promise { + if (name.trim() === '') throw new SdkError('invalid_config', 'template name is required'); + try { + const resp = await this.grpc.deleteSandboxTemplate({ + name, + workspace: options?.workspace ?? '', + }); + return resp.deleted; + } catch (e) { + throw fromConnect(e); + } + } +} + // ---- sandbox client -------------------------------------------------------- // Sandbox lifecycle + exec. Usable standalone via `SandboxClient.connect()`, @@ -590,6 +751,7 @@ export class SandboxClient { const resp = await this.grpc.createSandbox({ name: spec.name ?? '', labels: spec.labels ?? {}, + workspace: spec.workspace ?? '', spec: specInit, }); return sandboxRef(resp.sandbox); @@ -598,9 +760,33 @@ export class SandboxClient { } } - async get(name: string, callOptions?: CallOptions): Promise { + async createFromTemplate(spec: SandboxFromTemplateSpec): Promise { + if (spec.templateName.trim() === '') throw new SdkError('invalid_config', 'templateName is required'); try { - const resp = await this.grpc.getSandbox({ name }, callOptions); + const resp = await this.grpc.createSandbox({ + name: spec.name ?? '', + labels: spec.labels ?? {}, + workspace: spec.workspace ?? '', + spec: { + providers: spec.providers ?? [], + command: spec.command ?? [], + tty: spec.tty ?? false, + policy: spec.policy, + }, + workloadTemplateName: spec.templateName, + }); + return sandboxRef(resp.sandbox); + } catch (e) { + throw fromConnect(e); + } + } + + async get(name: string, options?: SandboxCallOptions | null): Promise { + try { + const resp = await this.grpc.getSandbox( + { name, workspace: workspaceOption(options) }, + requestCallOptions(options), + ); return sandboxRef(resp.sandbox); } catch (e) { throw fromConnect(e); @@ -609,10 +795,13 @@ export class SandboxClient { async list(options?: ListOptions | null): Promise { try { + const allWorkspaces = options?.allWorkspaces ?? false; const resp = await this.grpc.listSandboxes({ limit: options?.limit ?? 0, offset: options?.offset ?? 0, labelSelector: options?.labelSelector ?? '', + workspace: allWorkspaces ? '' : (options?.workspace ?? ''), + allWorkspaces, }); return resp.sandboxes.map((s) => sandboxRef(s)); } catch (e) { @@ -620,9 +809,9 @@ export class SandboxClient { } } - async delete(name: string): Promise { + async delete(name: string, options?: SandboxWorkspaceOptions | null): Promise { try { - const resp = await this.grpc.deleteSandbox({ name }); + const resp = await this.grpc.deleteSandbox({ name, workspace: workspaceOption(options) }); return resp.deleted; } catch (e) { throw fromConnect(e); @@ -641,7 +830,10 @@ export class SandboxClient { if (Date.now() >= deadline) throw new SdkError('connect', `timed out waiting for sandbox '${name}'`); let ref: SandboxRef; try { - ref = await this.get(name, deadlineOptions(deadline - Date.now(), signal)); + ref = await this.get(name, { + ...deadlineOptions(deadline - Date.now(), signal), + workspace: options?.workspace, + }); } catch (e) { throw mapWaitError(e, name, deadline, signal); } @@ -664,7 +856,7 @@ export class SandboxClient { if (signal?.aborted) throw new SdkError('connect', `wait for sandbox '${name}' aborted`); if (Date.now() >= deadline) throw new SdkError('connect', `timed out waiting for sandbox '${name}' to delete`); try { - await this.get(name, deadlineOptions(deadline - Date.now(), signal)); + await this.get(name, { ...deadlineOptions(deadline - Date.now(), signal), workspace: options?.workspace }); } catch (e) { if (e instanceof SdkError && e.code === 'not_found') return; throw mapWaitError(e, name, deadline, signal); @@ -687,7 +879,10 @@ export class SandboxClient { ): AsyncGenerator { try { // Resolve the sandbox id first, exactly like the gateway client. - const sandbox = await this.get(name, options?.signal ? { signal: options.signal } : undefined); + const sandbox = await this.get(name, { + workspace: options?.workspace, + ...(options?.signal ? { signal: options.signal } : {}), + }); const stream = this.grpc.execSandbox( { sandboxId: sandbox.id, @@ -761,7 +956,9 @@ export class SandboxClient { ): Promise { let sandboxId: string; try { - sandboxId = (await this.get(name, options?.signal ? { signal: options.signal } : undefined)).id; + sandboxId = ( + await this.get(name, { workspace: options?.workspace, ...(options?.signal ? { signal: options.signal } : {}) }) + ).id; } catch (e) { throw e instanceof SdkError ? e : fromConnect(e); } @@ -880,7 +1077,7 @@ export class SandboxClient { let sandboxId: string; try { - const ref = await this.get(name, opts.signal ? { signal: opts.signal } : undefined); + const ref = await this.get(name, { workspace: opts.workspace, ...(opts.signal ? { signal: opts.signal } : {}) }); if (ref.phase !== 'ready') { throw new SdkError('connect', `sandbox '${name}' is not ready (phase: ${ref.phase})`); } @@ -1061,9 +1258,9 @@ export class SandboxClient { // Mint a short-lived SSH session token for the sandbox — the input side of // ssh-config / ProxyCommand and forwardTcp authorization. - async createSshSession(name: string): Promise { + async createSshSession(name: string, options?: SandboxWorkspaceOptions | null): Promise { try { - const sandbox = await this.get(name); + const sandbox = await this.get(name, options); const resp = await this.grpc.createSshSession({ sandboxId: sandbox.id }); // Reject any response outside the proto trust-boundary contract before // handing these values to the caller (they feed OpenSSH ProxyCommand). @@ -1101,6 +1298,7 @@ export class SandboxClient { sandboxName: name, providerName: provider, expectedResourceVersion: versionPin(options?.expectedResourceVersion), + workspace: workspaceOption(options), }); return { sandbox: sandboxRef(resp.sandbox), changed: resp.attached }; } catch (e) { @@ -1118,6 +1316,7 @@ export class SandboxClient { sandboxName: name, providerName: provider, expectedResourceVersion: versionPin(options?.expectedResourceVersion), + workspace: workspaceOption(options), }); return { sandbox: sandboxRef(resp.sandbox), changed: resp.detached }; } catch (e) { @@ -1125,19 +1324,19 @@ export class SandboxClient { } } - async listProviders(name: string): Promise { + async listProviders(name: string, options?: SandboxWorkspaceOptions | null): Promise { try { - const resp = await this.grpc.listSandboxProviders({ sandboxName: name }); + const resp = await this.grpc.listSandboxProviders({ sandboxName: name, workspace: workspaceOption(options) }); return resp.providers.map((p) => providerRef(p)); } catch (e) { throw fromConnect(e); } } - async getConfig(name: string, callOptions?: CallOptions): Promise { + async getConfig(name: string, options?: SandboxCallOptions | null): Promise { try { - const sandbox = await this.get(name, callOptions); - const resp = await this.grpc.getSandboxConfig({ sandboxId: sandbox.id }, callOptions); + const sandbox = await this.get(name, options); + const resp = await this.grpc.getSandboxConfig({ sandboxId: sandbox.id }, requestCallOptions(options)); return sandboxConfig(resp); } catch (e) { throw e instanceof SdkError ? e : fromConnect(e); @@ -1159,9 +1358,11 @@ export class SandboxClient { policy, global: false, expectedResourceVersion: versionPin(options?.expectedResourceVersion), + workspace: workspaceOption(options), }); const result = updateConfigResult(resp); - if (options?.wait) await this.waitForPolicyHash(name, result.policyHash, options.waitTimeoutSecs); + if (options?.wait) + await this.waitForPolicyHash(name, result.policyHash, options.waitTimeoutSecs, options.workspace); return result; } catch (e) { throw e instanceof SdkError ? e : fromConnect(e); @@ -1174,6 +1375,7 @@ export class SandboxClient { name: string, key: string, value: MessageInitShape, + options?: SandboxWorkspaceOptions | null, ): Promise { try { const resp = await this.grpc.updateConfig({ @@ -1181,6 +1383,7 @@ export class SandboxClient { settingKey: key, settingValue: value, global: false, + workspace: workspaceOption(options), }); return updateConfigResult(resp); } catch (e) { @@ -1191,13 +1394,18 @@ export class SandboxClient { // Poll getConfig until the applied policy hash is observed. Each poll RPC is // bounded by the remaining deadline (deadlineOptions), so a stalled getConfig // cannot make the returned promise outlive timeoutSecs. - private async waitForPolicyHash(name: string, policyHash: string, timeoutSecs = 60): Promise { + private async waitForPolicyHash( + name: string, + policyHash: string, + timeoutSecs = 60, + workspace?: string, + ): Promise { const deadline = Date.now() + timeoutSecs * 1000; let delay = 100; for (;;) { let config: SandboxConfig; try { - config = await this.getConfig(name, deadlineOptions(deadline - Date.now())); + config = await this.getConfig(name, { ...deadlineOptions(deadline - Date.now()), workspace }); } catch (e) { if (Date.now() >= deadline) { throw new SdkError('connect', `timed out waiting for policy '${policyHash}' on sandbox '${name}'`); @@ -1219,6 +1427,8 @@ export class SandboxClient { export class OpenShellClient { /** Sandbox lifecycle + exec: create/get/list/delete, waitReady/waitDeleted, exec. */ readonly sandbox: SandboxClient; + /** Reusable sandbox workload template lifecycle. */ + readonly sandboxTemplates: SandboxTemplateClient; /** * Advanced escape hatch: a generated client for every gateway RPC, including @@ -1238,6 +1448,7 @@ export class OpenShellClient { this.grpc = createClient(OpenShell, transport); this.raw = this.grpc; this.sandbox = new SandboxClient(transport, this.grpc); + this.sandboxTemplates = new SandboxTemplateClient(transport, this.grpc); } /** diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 61cdce3871..31571865be 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -23,10 +23,20 @@ export type { ProviderChangeOptions, ProviderRef, SandboxConfig, + SandboxFromTemplateSpec, SandboxPhaseName, SandboxPolicy, SandboxRef, + SandboxResources, + SandboxServiceLevel, SandboxSpec, + SandboxStartup, + SandboxTemplateListOptions, + SandboxTemplateWorkspaceOptions, + SandboxWorkloadConfig, + SandboxWorkloadTemplate, + SandboxWorkloadTemplateProvenance, + SandboxWorkloadTemplateSpec, SetPolicyOptions, SettingScopeName, SettingValue, @@ -34,7 +44,7 @@ export type { UpdateConfigResult, WaitOptions, } from './client.js'; -export { errorCode, OpenShellClient, SandboxClient } from './client.js'; +export { errorCode, OpenShellClient, SandboxClient, SandboxTemplateClient } from './client.js'; export type { SdkErrorCode } from './errors.js'; export { SdkError } from './errors.js'; export type { ClientCredentialsOptions, OidcTokenProvider } from './oidc.js'; From fd9fc0a135a8001d591b8d210a172047155c8fbc Mon Sep 17 00:00:00 2001 From: Simon Scatton <44714756+SDAChess@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:45:59 +0000 Subject: [PATCH 084/505] fix(release): publish prerelease helm charts (#3126) Signed-off-by: Simon Scatton --- .github/workflows/release-tag.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index d47a6c7cb5..b3c45c97fb 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -732,7 +732,6 @@ jobs: release-helm: name: Release Helm Chart (OCI) needs: [compute-versions, release, tag-ghcr-release] - if: needs.compute-versions.outputs.is_prerelease != 'true' runs-on: ubuntu-latest timeout-minutes: 10 permissions: From 06f0aa6fe30177f2aecba9af396649adae4d7cdd Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Wed, 2 Sep 2026 09:51:53 +0000 Subject: [PATCH 085/505] test(guest): consolidate rootless Podman provisioning (#3125) * fix(test-guest): support Nix stat on macOS Signed-off-by: Evan Lezar * test(guest): version Ubuntu test guest profiles Signed-off-by: Evan Lezar * test(guest): consolidate rootless Podman provisioning Signed-off-by: Evan Lezar --------- Signed-off-by: Evan Lezar --- nix/test-guest/README.md | 63 +++++++++++-------- nix/test-guest/cache.sh | 6 +- .../configuration/podman-rootless.yml | 36 +++++++++++ nix/test-guest/configuration/podman.yml | 42 ------------- .../configuration/tasks/podman-common.yml | 13 ++++ .../tasks/podman-rootless/fedora.yml | 12 ++++ .../tasks/podman-rootless/shared.yml | 56 +++++++++++++++++ .../tasks/podman-rootless/ubuntu.yml | 29 +++++++++ nix/test-guest/default.nix | 18 +++++- .../distros/{ubuntu.nix => ubuntu-24-04.nix} | 0 nix/test-guest/distros/ubuntu-26-04.nix | 25 ++++++++ nix/test-guest/run.sh | 24 +++---- 12 files changed, 239 insertions(+), 85 deletions(-) create mode 100644 nix/test-guest/configuration/podman-rootless.yml delete mode 100644 nix/test-guest/configuration/podman.yml create mode 100644 nix/test-guest/configuration/tasks/podman-common.yml create mode 100644 nix/test-guest/configuration/tasks/podman-rootless/fedora.yml create mode 100644 nix/test-guest/configuration/tasks/podman-rootless/shared.yml create mode 100644 nix/test-guest/configuration/tasks/podman-rootless/ubuntu.yml rename nix/test-guest/distros/{ubuntu.nix => ubuntu-24-04.nix} (100%) create mode 100644 nix/test-guest/distros/ubuntu-26-04.nix diff --git a/nix/test-guest/README.md b/nix/test-guest/README.md index b608cb19fe..3bf1687205 100644 --- a/nix/test-guest/README.md +++ b/nix/test-guest/README.md @@ -33,13 +33,20 @@ nix/test-guest/ ├── cache-lib.sh ├── cache-seal.sh ├── distros/ -│ ├── ubuntu.nix +│ ├── ubuntu-24-04.nix +│ ├── ubuntu-26-04.nix │ ├── centos.nix │ ├── fedora.nix │ └── rocky.nix └── configuration/ ├── docker.yml - ├── podman.yml + ├── podman-rootless.yml + ├── tasks/ + │ ├── podman-common.yml + │ └── podman-rootless/ + │ ├── fedora.yml + │ ├── shared.yml + │ └── ubuntu.yml └── selinux.yml ``` @@ -56,22 +63,26 @@ The root [`flake.nix`](../../flake.nix) exposes this directory as the `test-gues ## Supported configurations -| Distro | Docker | Podman | SELinux | Package format | +| Distro | Docker | Rootless Podman | SELinux | Package format | | --- | --- | --- | --- | --- | -| Ubuntu 24.04 | Yes | Yes | No | `.deb` | -| CentOS Stream 10 | No | Yes | Yes | `.rpm` | +| Ubuntu 24.04 | Yes | No | No | `.deb` | +| Ubuntu 26.04 | Yes | Yes | No | `.deb` | +| CentOS Stream 10 | No | No | Yes | `.rpm` | | Fedora 44 | No | Yes | Yes | `.rpm` | -| Rocky Linux 9 | Yes | Yes | Yes | `.rpm` | +| Rocky Linux 9 | Yes | No | Yes | `.rpm` | The `snapd` configuration is available for Ubuntu and prepares snapd for local Snap lifecycle experiments. It does not install Docker, because the Snap gateway reproduction uses the Docker **Snap** and its `docker:docker-daemon` interface rather than the host-package Docker configuration. -The Ubuntu 24.04 Podman configuration is available for runtime and packaging -checks, but its Podman 4 release does not provide the `pasta` rootless network -helper required by OpenShell sandbox callbacks. OpenShell Podman E2E runs use -the Fedora guest, which provides Podman 5 and `pasta`. +`podman-rootless` configures the explicit rootless Podman guest setup used by +OpenShell tests. It supports Fedora and Ubuntu 26.04 or later. Ubuntu adds the +AppArmor rule that permits `pasta` to receive Podman stop signals. Fedora +installs the subordinate-ID utilities and rootless storage and network helpers. +Both configurations verify rootless mode and the `pasta` network helper +required by OpenShell sandbox callbacks. Ubuntu 24.04 ships Podman 4, which +does not provide that helper. List the available distros and configurations: @@ -84,30 +95,30 @@ nix run .#test-guest -- --list Boot a base Ubuntu VM: ```shell -nix run .#test-guest -- --distro ubuntu +nix run .#test-guest -- --distro ubuntu-24-04 ``` Apply the Docker configuration before opening the SSH session: ```shell -nix run .#test-guest -- --distro ubuntu --with docker +nix run .#test-guest -- --distro ubuntu-24-04 --with docker ``` Other combinations use the same interface: ```shell nix run .#test-guest -- --distro rocky --with docker -nix run .#test-guest -- --distro centos --with podman -nix run .#test-guest -- --distro fedora --with podman +nix run .#test-guest -- --distro ubuntu-26-04 --with podman-rootless +nix run .#test-guest -- --distro fedora --with podman-rootless ``` Configurations are repeatable: ```shell nix run .#test-guest -- \ - --distro ubuntu \ + --distro ubuntu-24-04 \ --with docker \ - --with podman + --with podman-rootless ``` Ensure SELinux is enforcing on CentOS, Fedora, or Rocky: @@ -138,7 +149,7 @@ The `test-guest-cache` app ensures a prepared disk exists for one exact distro, ```shell nix run .#test-guest-cache -- \ - --distro ubuntu \ + --distro ubuntu-24-04 \ --with docker ``` @@ -147,7 +158,7 @@ backing cache: ```shell nix run .#test-guest-cache -- \ - --distro ubuntu \ + --distro ubuntu-24-04 \ --with docker \ --repository ghcr.io/nvidia/openshell/test-guest-cache \ --digest sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef @@ -157,7 +168,7 @@ The command never publishes implicitly. Add `--push` after authenticating ORAS t ```shell nix run .#test-guest-cache -- \ - --distro ubuntu \ + --distro ubuntu-24-04 \ --with docker \ --repository ghcr.io/nvidia/openshell/test-guest-cache \ --push @@ -184,8 +195,8 @@ The default cache directory is `${XDG_CACHE_HOME:-$HOME/.cache}/openshell/test-g Cache command options: ```text ---distro NAME Base distro: ubuntu, centos, fedora, or rocky ---with NAME Apply docker, podman, or selinux; repeatable +--distro NAME Base distro: ubuntu-24-04, ubuntu-26-04, centos, fedora, or rocky +--with NAME Apply docker, podman-rootless, selinux, or snapd; repeatable --repository REF OCI repository without a tag --digest DIGEST Trusted OCI manifest digest required for pulls --cache-dir PATH Override the local prepared-disk cache directory @@ -209,7 +220,7 @@ Install the package in an Ubuntu VM and run a command: ```shell nix run .#test-guest -- \ - --distro ubuntu \ + --distro ubuntu-24-04 \ --with docker \ --install artifacts/openshell_0.0.0-local_arm64.deb \ -- openshell --version @@ -226,7 +237,7 @@ guest file preserves the source's ordinary permission bits: ```shell nix run .#test-guest -- \ - --distro ubuntu \ + --distro ubuntu-24-04 \ --copy ./openshell:/usr/local/bin/openshell \ -- openshell --version ``` @@ -241,7 +252,7 @@ gateway. On each failure it prints snapd and gateway journals. ```shell nix run .#test-guest -- \ - --distro ubuntu \ + --distro ubuntu-24-04 \ --with snapd \ --keep \ --copy ./openshell_*.snap:/tmp/openshell.snap \ @@ -259,8 +270,8 @@ The destination must be an absolute guest path. Copied files are installed with ## Runner options ```text ---distro NAME Base distro: ubuntu, centos, fedora, or rocky ---with NAME Apply docker, podman, or selinux; repeatable +--distro NAME Base distro: ubuntu-24-04, ubuntu-26-04, centos, fedora, or rocky +--with NAME Apply docker, podman-rootless, selinux, or snapd; repeatable --install PATH Install a .deb or .rpm package; repeatable --copy SRC:DEST Copy a regular file into the guest, preserving its host mode; repeatable diff --git a/nix/test-guest/cache.sh b/nix/test-guest/cache.sh index 1103b3c289..f29a9b3492 100644 --- a/nix/test-guest/cache.sh +++ b/nix/test-guest/cache.sh @@ -12,8 +12,8 @@ Usage: nix run .#test-guest-cache -- --distro DISTRO [OPTIONS] Options: - --distro NAME Base distro: ubuntu, centos, fedora, or rocky - --with NAME Apply a configuration; repeatable (docker, podman, selinux) + --distro NAME Base distro: ubuntu-24-04, ubuntu-26-04, centos, fedora, or rocky + --with NAME Apply a configuration; repeatable (docker, podman-rootless, selinux, snapd) --repository REF OCI repository without a tag --digest DIGEST Trusted OCI manifest digest required for pulls --cache-dir PATH Override the local prepared-disk cache directory @@ -380,7 +380,7 @@ build_local() { for configuration in "${configurations[@]}"; do case "${configuration}" in docker) validation+='; docker info >/dev/null' ;; - podman) validation+='; podman info >/dev/null' ;; + podman-rootless) validation+='; podman info >/dev/null' ;; selinux) validation+='; test "$(getenforce)" = Enforcing' ;; esac done diff --git a/nix/test-guest/configuration/podman-rootless.yml b/nix/test-guest/configuration/podman-rootless.yml new file mode 100644 index 0000000000..23fef05e5e --- /dev/null +++ b/nix/test-guest/configuration/podman-rootless.yml @@ -0,0 +1,36 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# PROTOTYPE: Configure rootless Podman in a disposable test guest. + +- name: Configure rootless Podman + hosts: test_vm + become: true + gather_facts: true + + tasks: + - name: Validate rootless Podman support + ansible.builtin.assert: + that: + - >- + ansible_facts.distribution == "Fedora" or + (ansible_facts.distribution == "Ubuntu" and + ansible_facts.distribution_version is version("26.04", ">=")) + fail_msg: >- + Rootless Podman requires Fedora or Ubuntu 26.04 or newer, not + {{ ansible_facts.distribution }} {{ ansible_facts.distribution_version }}. + + - name: Install common Podman prerequisites + ansible.builtin.import_tasks: tasks/podman-common.yml + + - name: Configure rootless Podman on Ubuntu + ansible.builtin.import_tasks: tasks/podman-rootless/ubuntu.yml + when: ansible_facts.distribution == "Ubuntu" + + - name: Configure rootless Podman on Fedora + ansible.builtin.import_tasks: tasks/podman-rootless/fedora.yml + when: ansible_facts.distribution == "Fedora" + + - name: Configure shared rootless Podman settings + ansible.builtin.import_tasks: tasks/podman-rootless/shared.yml diff --git a/nix/test-guest/configuration/podman.yml b/nix/test-guest/configuration/podman.yml deleted file mode 100644 index 1c79199074..0000000000 --- a/nix/test-guest/configuration/podman.yml +++ /dev/null @@ -1,42 +0,0 @@ ---- -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -# PROTOTYPE: Configure Podman in a disposable test guest. - -- name: Configure Podman - hosts: test_vm - become: true - gather_facts: true - - tasks: - - name: Validate Podman support - ansible.builtin.assert: - that: - - ansible_facts.distribution in ["Ubuntu", "CentOS", "Fedora", "Rocky"] - fail_msg: >- - Podman is unsupported on {{ ansible_facts.distribution }}. - - - name: Refresh Ubuntu package metadata - ansible.builtin.apt: - update_cache: true - when: ansible_facts.distribution == "Ubuntu" - - - name: Install Podman dependencies - ansible.builtin.package: - name: podman - state: present - - - name: Enable the rootless Podman API socket - ansible.builtin.systemd_service: - name: podman.socket - scope: user - enabled: true - state: started - become: false - - - name: Verify rootless Podman - ansible.builtin.command: - cmd: podman info - become: false - changed_when: false diff --git a/nix/test-guest/configuration/tasks/podman-common.yml b/nix/test-guest/configuration/tasks/podman-common.yml new file mode 100644 index 0000000000..18a0f1304a --- /dev/null +++ b/nix/test-guest/configuration/tasks/podman-common.yml @@ -0,0 +1,13 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +- name: Refresh Ubuntu package metadata + ansible.builtin.apt: + update_cache: true + when: ansible_facts.distribution == "Ubuntu" + +- name: Install Podman + ansible.builtin.package: + name: podman + state: present diff --git a/nix/test-guest/configuration/tasks/podman-rootless/fedora.yml b/nix/test-guest/configuration/tasks/podman-rootless/fedora.yml new file mode 100644 index 0000000000..7d7d08a261 --- /dev/null +++ b/nix/test-guest/configuration/tasks/podman-rootless/fedora.yml @@ -0,0 +1,12 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +- name: Install Fedora rootless Podman prerequisites + ansible.builtin.dnf: + name: + - fuse-overlayfs + - passt + - shadow-utils + - shadow-utils-subid + state: present diff --git a/nix/test-guest/configuration/tasks/podman-rootless/shared.yml b/nix/test-guest/configuration/tasks/podman-rootless/shared.yml new file mode 100644 index 0000000000..9f4eba6ae9 --- /dev/null +++ b/nix/test-guest/configuration/tasks/podman-rootless/shared.yml @@ -0,0 +1,56 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +- name: Verify test user subordinate ID mappings + ansible.builtin.command: + argv: + - awk + - "-F:" + - >- + $1 == "openshell" && $2 ~ /^[0-9]+$/ && + $3 ~ /^[1-9][0-9]*$/ && $3 >= 65536 { found = 1 } + END { exit !found } + - "{{ item }}" + loop: + - /etc/subuid + - /etc/subgid + changed_when: false + +- name: Enable the rootless Podman API socket + ansible.builtin.systemd_service: + name: podman.socket + scope: user + enabled: true + state: started + become: false + +- name: Verify rootless Podman + ansible.builtin.command: + cmd: podman info + become: false + changed_when: false + +- name: Verify rootless Podman mode + ansible.builtin.command: + argv: + - podman + - info + - --format + - "{% raw %}{{.Host.Security.Rootless}}{% endraw %}" + become: false + changed_when: false + register: podman_rootless + failed_when: podman_rootless.stdout != "true" + +- name: Verify rootless Podman uses pasta + ansible.builtin.command: + argv: + - podman + - info + - --format + - "{% raw %}{{.Host.RootlessNetworkCmd}}{% endraw %}" + become: false + changed_when: false + register: podman_rootless_network + failed_when: podman_rootless_network.stdout != "pasta" diff --git a/nix/test-guest/configuration/tasks/podman-rootless/ubuntu.yml b/nix/test-guest/configuration/tasks/podman-rootless/ubuntu.yml new file mode 100644 index 0000000000..25d474e415 --- /dev/null +++ b/nix/test-guest/configuration/tasks/podman-rootless/ubuntu.yml @@ -0,0 +1,29 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +- name: Install Ubuntu rootless Podman prerequisites + ansible.builtin.apt: + name: + - apparmor + - fuse-overlayfs + - passt + - uidmap + state: present + +- name: Allow pasta to receive Podman stop signals + ansible.builtin.lineinfile: + path: /etc/apparmor.d/usr.bin.pasta + insertafter: "^ include $" + line: " signal (receive) peer=podman," + state: present + register: pasta_apparmor_profile + +- name: Reload pasta AppArmor profile + ansible.builtin.command: + argv: + - apparmor_parser + - --replace + - /etc/apparmor.d/usr.bin.pasta + when: pasta_apparmor_profile.changed + changed_when: pasta_apparmor_profile.changed diff --git a/nix/test-guest/default.nix b/nix/test-guest/default.nix index 753f266ab4..3b03c1fa95 100644 --- a/nix/test-guest/default.nix +++ b/nix/test-guest/default.nix @@ -18,7 +18,8 @@ let if isAarch64 then "${qemu}/bin/qemu-system-aarch64" else "${qemu}/bin/qemu-system-x86_64"; distros = { - ubuntu = import ./distros/ubuntu.nix { inherit pkgs architecture; }; + ubuntu-24-04 = import ./distros/ubuntu-24-04.nix { inherit pkgs architecture; }; + ubuntu-26-04 = import ./distros/ubuntu-26-04.nix { inherit pkgs architecture; }; centos = import ./distros/centos.nix { inherit pkgs architecture; }; fedora = import ./distros/fedora.nix { inherit pkgs architecture; }; rocky = import ./distros/rocky.nix { inherit pkgs architecture; }; @@ -26,11 +27,18 @@ let configurations = { docker = ./configuration/docker.yml; - podman = ./configuration/podman.yml; + podman-rootless = ./configuration/podman-rootless.yml; selinux = ./configuration/selinux.yml; snapd = ./configuration/snapd.yml; }; + configurationTasks = [ + "podman-common.yml" + "podman-rootless/fedora.yml" + "podman-rootless/shared.yml" + "podman-rootless/ubuntu.yml" + ]; + mkDistroProfile = name: distro: pkgs.writeText "openshell-test-guest-${name}" '' @@ -52,7 +60,11 @@ let ); configurationCatalog = pkgs.linkFarm "openshell-test-guest-configurations" ( - pkgs.lib.mapAttrsToList (name: path: { inherit name path; }) configurations + (pkgs.lib.mapAttrsToList (name: path: { inherit name path; }) configurations) + ++ (map (name: { + name = "tasks/${name}"; + path = ./configuration/tasks/${name}; + }) configurationTasks) ); runtimeInputs = [ diff --git a/nix/test-guest/distros/ubuntu.nix b/nix/test-guest/distros/ubuntu-24-04.nix similarity index 100% rename from nix/test-guest/distros/ubuntu.nix rename to nix/test-guest/distros/ubuntu-24-04.nix diff --git a/nix/test-guest/distros/ubuntu-26-04.nix b/nix/test-guest/distros/ubuntu-26-04.nix new file mode 100644 index 0000000000..7f37fec138 --- /dev/null +++ b/nix/test-guest/distros/ubuntu-26-04.nix @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +{ pkgs, architecture }: + +let + imageArchitecture = if architecture == "aarch64" then "arm64" else "amd64"; + imageUrl = "https://cloud-images.ubuntu.com/releases/releases/26.04/release/ubuntu-26.04-server-cloudimg-${imageArchitecture}.img"; + imageHash = + if architecture == "aarch64" then + "sha256-PhE/3UHznhNyk3UXO7KueT+H3G20KU5SUf8kdpcXiLo=" + else + "sha256-gZa+nXlYBZy1bGx1yA/fbO6KiIW8FJ6nkdfbHH75MDU="; +in +{ + osId = "ubuntu"; + osVersion = "26.04"; + packageFamily = "deb"; + inherit imageUrl imageHash; + image = pkgs.fetchurl { + name = "ubuntu-26.04-server-cloudimg-${imageArchitecture}.img"; + url = imageUrl; + hash = imageHash; + }; +} diff --git a/nix/test-guest/run.sh b/nix/test-guest/run.sh index 6be58eb235..8704633828 100644 --- a/nix/test-guest/run.sh +++ b/nix/test-guest/run.sh @@ -12,8 +12,8 @@ Usage: nix run .#test-guest -- --distro DISTRO [OPTIONS] [-- COMMAND...] Options: - --distro NAME Base distro: ubuntu, centos, fedora, or rocky - --with NAME Apply a configuration; repeatable (docker, podman, selinux, snapd) + --distro NAME Base distro: ubuntu-24-04, ubuntu-26-04, centos, fedora, or rocky + --with NAME Apply a configuration; repeatable (docker, podman-rootless, selinux, snapd) --install PATH Install a .deb or .rpm package; repeatable --copy SRC:DEST Copy a regular file to an absolute guest path, preserving its host mode; repeatable @@ -48,12 +48,13 @@ preserved_file_mode() { local source_path=$1 local source_mode - if [ "$(uname -s)" = Darwin ]; then - if ! source_mode=$(stat -f '%Lp' "${source_path}"); then - echo "could not determine mode for --copy source: ${source_path}" >&2 - return 1 - fi - elif ! source_mode=$(stat -c '%a' "${source_path}"); then + # Nix supplies GNU coreutils on macOS, so choose the supported stat format + # by probing rather than relying on the host kernel name. + if source_mode=$(stat -c '%a' "${source_path}" 2>/dev/null); then + : + elif source_mode=$(stat -f '%Lp' "${source_path}" 2>/dev/null); then + : + else echo "could not determine mode for --copy source: ${source_path}" >&2 return 1 fi @@ -141,6 +142,7 @@ if [ "${list}" -eq 1 ]; then done echo "Configurations:" for entry in "${OPENSHELL_TEST_GUEST_CONFIGURATIONS}"/*; do + [ -f "${entry}" ] || continue printf ' %s\n' "${entry##*/}" done exit 0 @@ -160,9 +162,9 @@ fi # shellcheck disable=SC1090 . "${OPENSHELL_TEST_GUEST_DISTROS}/${distro}" -for item in "${configurations[@]}"; do - if [[ ! ${item} =~ ^[a-z0-9][a-z0-9-]*$ ]] || - [ ! -r "${OPENSHELL_TEST_GUEST_CONFIGURATIONS}/${item}" ]; then + for item in "${configurations[@]}"; do + if [[ ! ${item} =~ ^[a-z0-9][a-z0-9-]*$ ]] || + [ ! -f "${OPENSHELL_TEST_GUEST_CONFIGURATIONS}/${item}" ]; then echo "unknown configuration: ${item:-}" >&2 exit 2 fi From b92e9bda4f2e9ed010516b9ce4cf9bbdadf967f3 Mon Sep 17 00:00:00 2001 From: Simon Scatton <44714756+SDAChess@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:06:32 +0000 Subject: [PATCH 086/505] ci: add Fedora conformance workflow (#3086) * ci: add Fedora conformance workflow Signed-off-by: Simon Scatton * ci: enable KVM for Fedora conformance Signed-off-by: Simon Scatton * ci: run Fedora conformance with KVM Signed-off-by: Simon Scatton * ci: copy Fedora conformance script into guest Signed-off-by: Simon Scatton * ci: limit conformance VM setup to KVM Signed-off-by: Simon Scatton * ci: streamline Fedora conformance builds Signed-off-by: Simon Scatton * ci: use dev supervisor for Fedora conformance Signed-off-by: Simon Scatton * ci: pin Fedora RPM build image Signed-off-by: Simon Scatton * ci: cache RPM vendoring dependencies Signed-off-by: Simon Scatton * ci: clarify Fedora conformance job name Signed-off-by: Simon Scatton * ci: make conformance workflow manual only Signed-off-by: Simon Scatton * ci(conformance): use new podman rootless install * fix(ci): build gateway from renamed package Signed-off-by: Simon Scatton --------- Signed-off-by: Simon Scatton --- .github/workflows/build-rpm.yml | 123 +++++++++++++++++++ .github/workflows/conformance.yml | 195 ++++++++++++++++++++++++++++++ .github/workflows/rpm-package.yml | 87 ++----------- .packit.yaml | 5 - nix/test-guest/cache-seal.sh | 4 +- openshell.spec | 46 +------ 6 files changed, 338 insertions(+), 122 deletions(-) create mode 100644 .github/workflows/build-rpm.yml create mode 100644 .github/workflows/conformance.yml diff --git a/.github/workflows/build-rpm.yml b/.github/workflows/build-rpm.yml new file mode 100644 index 0000000000..f35e1e0b4d --- /dev/null +++ b/.github/workflows/build-rpm.yml @@ -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:be9d65e2344d805cc11114319c685ecaa96b6d9b4350a0a6460cdb931babbd19 + 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 diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 0000000000..bae9e3d326 --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,195 @@ +# 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 + dev-shell: .#devShells.x86_64-linux.musl + 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 + dev-shell: .#devShells.x86_64-linux.musl + 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 + dev-shell: .#devShells.x86_64-linux.glibc-2-28 + cargo-version: ${{ needs.version.outputs.cargo }} + image-tag: dev + interpreter: /lib64/ld-linux-x86-64.so.2 + 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: Install RPMs and check status + shell: bash + run: | + set -euo pipefail + chmod +x conformance-input/openshell-conformance + guest_script="$RUNNER_TEMP/conformance.sh" + cat > "$guest_script" <<'EOF' + set -euo pipefail + + on_exit() { + rc=$? + trap - EXIT + if [ "$rc" -ne 0 ]; then + systemctl --user status openshell-gateway --no-pager || true + journalctl --user -u openshell-gateway --no-pager -n 200 || true + podman info || true + podman ps --all || true + getenforce || true + fi + exit "$rc" + } + trap on_exit EXIT + + mkdir -p "$HOME/.config/openshell" + echo 'OPENSHELL_TELEMETRY_ENABLED=false' > "$HOME/.config/openshell/gateway.env" + systemctl --user enable --now openshell-gateway + openshell gateway add --local https://127.0.0.1:17670 --name openshell + /tmp/openshell-conformance run smoke + EOF + + OPENSHELL_TEST_GUEST_CACHE_DISABLE=1 nix run .#test-guest -- \ + --distro fedora \ + --with podman-rootless \ + --with selinux \ + --install rpm-input/openshell-[0-9]*.rpm \ + --install rpm-input/openshell-gateway-[0-9]*.rpm \ + --copy "$guest_script:/tmp/conformance.sh" \ + --copy conformance-input/openshell-conformance:/tmp/openshell-conformance \ + -- bash /tmp/conformance.sh diff --git a/.github/workflows/rpm-package.yml b/.github/workflows/rpm-package.yml index 8701644585..5cda9a88c3 100644 --- a/.github/workflows/rpm-package.yml +++ b/.github/workflows/rpm-package.yml @@ -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 }} diff --git a/.packit.yaml b/.packit.yaml index 6379d8db83..d3b92eafae 100644 --- a/.packit.yaml +++ b/.packit.yaml @@ -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 diff --git a/nix/test-guest/cache-seal.sh b/nix/test-guest/cache-seal.sh index 9f0da8564d..04cf8dfc0c 100755 --- a/nix/test-guest/cache-seal.sh +++ b/nix/test-guest/cache-seal.sh @@ -49,7 +49,9 @@ sync # Deleted credentials can remain in allocated blocks. Fill free space with # zeroes so qemu-img convert can safely omit those blocks from the cache disk. zero_file=/var/tmp/openshell-cache-zero -dd if=/dev/zero of="${zero_file}" bs=64M status=none 2>/dev/null || true +echo "==> Cache sealing: zeroing free disk space" +dd if=/dev/zero of="${zero_file}" bs=64M status=progress || true +echo "==> Cache sealing: free disk space zeroed" rm -f "${zero_file}" sync diff --git a/openshell.spec b/openshell.spec index 3200659d73..ac57d29ee9 100644 --- a/openshell.spec +++ b/openshell.spec @@ -12,12 +12,6 @@ # in the format redhat-rpm-config expects (especially on EPEL). %global debug_package %{nil} -# Default container image tag for supervisor and sandbox images. -# Overridden to 'latest' by Packit's fix-spec-file action for tagged stable -# releases (via git describe --exact-match). PR and commit-to-main builds -# keep the default 'dev' so they track the development image stream. -%global image_tag dev - Name: openshell Version: %{openshell_version} Release: 1.20260518180028805757.podman.toml.gateway.listener.11.g8c0cb7c8%{?dist} @@ -30,21 +24,9 @@ Source1: openshell-%{openshell_version}-vendor.tar.xz ExclusiveArch: x86_64 aarch64 -# Rust build dependencies -# NOTE: MSRV is 1.88 (Rust edition 2024). As of mid-2025, this requires -# Fedora Rawhide or newer. Stable Fedora and EPEL-10 may ship older Rust; -# adjust targets in .packit.yaml accordingly or provide a supplementary -# Rust toolchain via additional_repos in the COPR build config. -BuildRequires: rust >= 1.88 +# Cargo metadata generation BuildRequires: cargo BuildRequires: cargo-rpm-macros >= 25 -BuildRequires: gcc -BuildRequires: gcc-c++ -BuildRequires: make -BuildRequires: cmake -BuildRequires: pkg-config -BuildRequires: clang-devel -BuildRequires: z3-devel BuildRequires: systemd-rpm-macros # Man page generation @@ -103,18 +85,8 @@ sed -i 's/^version = "0.0.0"/version = "%{openshell_cargo_version}"/' Cargo.toml grep -q 'version = "%{openshell_cargo_version}"' Cargo.toml || (echo "ERROR: Cargo.toml version patch failed" && exit 1) %build -# Build the CLI and gateway binaries unless the release workflow supplied the -# same prebuilt artifacts used for tarballs and Debian packages. -export CARGO_BUILD_JOBS=%{_smp_build_ncpus} -# Set the default container image tag so compiled-in image refs point at -# real tags in the ghcr.io/nvidia/openshell registry. -export OPENSHELL_IMAGE_TAG=%{image_tag} -if [ -n "${OPENSHELL_PREBUILT_BINARIES_DIR:-}" ]; then - test -x "${OPENSHELL_PREBUILT_BINARIES_DIR}/openshell" - test -x "${OPENSHELL_PREBUILT_BINARIES_DIR}/openshell-gateway" -else - cargo build --release --bin openshell --bin openshell-gateway -fi +test -x "${OPENSHELL_PREBUILT_BINARIES_DIR}/openshell" +test -x "${OPENSHELL_PREBUILT_BINARIES_DIR}/openshell-gateway" # Generate vendored crate manifest and license metadata. # cargo-vendor.txt is consumed by an RPM generator (from cargo-rpm-macros) @@ -129,18 +101,10 @@ pandoc -s -t man deploy/man/openshell-gateway.8.md -o openshell-gateway.8 %install # --- CLI binary --- -if [ -n "${OPENSHELL_PREBUILT_BINARIES_DIR:-}" ]; then - install -Dpm 0755 "${OPENSHELL_PREBUILT_BINARIES_DIR}/%{name}" %{buildroot}%{_bindir}/%{name} -else - install -Dpm 0755 target/release/%{name} %{buildroot}%{_bindir}/%{name} -fi +install -Dpm 0755 "${OPENSHELL_PREBUILT_BINARIES_DIR}/%{name}" %{buildroot}%{_bindir}/%{name} # --- Gateway binary --- -if [ -n "${OPENSHELL_PREBUILT_BINARIES_DIR:-}" ]; then - install -Dpm 0755 "${OPENSHELL_PREBUILT_BINARIES_DIR}/%{name}-gateway" %{buildroot}%{_bindir}/%{name}-gateway -else - install -Dpm 0755 target/release/%{name}-gateway %{buildroot}%{_bindir}/%{name}-gateway -fi +install -Dpm 0755 "${OPENSHELL_PREBUILT_BINARIES_DIR}/%{name}-gateway" %{buildroot}%{_bindir}/%{name}-gateway # --- Default gateway TOML config template --- # Shipped as a read-only reference in %{_datadir}. The systemd unit seeds a From 387aea069a8b5ff6cf43491f16cf228f4be37cc0 Mon Sep 17 00:00:00 2001 From: Simon Scatton <44714756+SDAChess@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:51:42 +0200 Subject: [PATCH 087/505] fix(ci): use multi-arch Fedora image for RPM builds (#3130) Signed-off-by: Simon Scatton --- .github/workflows/build-rpm.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-rpm.yml b/.github/workflows/build-rpm.yml index f35e1e0b4d..71aa04dddf 100644 --- a/.github/workflows/build-rpm.yml +++ b/.github/workflows/build-rpm.yml @@ -47,7 +47,7 @@ jobs: runs-on: ${{ inputs.runner }} timeout-minutes: 60 container: - image: docker.io/library/fedora:44@sha256:be9d65e2344d805cc11114319c685ecaa96b6d9b4350a0a6460cdb931babbd19 + image: docker.io/library/fedora:44@sha256:43b29f65a41eb9c35e1cd5323e3bdf3b655c2357a9f4f1ff2f9c2798e5045d80 steps: - name: Install packaging dependencies run: | From 857af42a16bd47aba45a5968083c95e940cd714e Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Wed, 2 Sep 2026 14:56:08 +0000 Subject: [PATCH 088/505] feat(vm): support corporate HTTP forward proxy egress for microVM sandboxes (#3090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(vm): support corporate HTTP forward proxy egress for microVM sandboxes The corporate forward proxy machinery from #1792 is driver-agnostic and already merged: openshell-supervisor-network implements CONNECT chaining, NO_PROXY matching, credentials, https:// proxies and corporate CA trust, and openshell-sandbox exposes it as six argv-only flags. Podman gained the driver half in #2245/#2512 and Kubernetes in #2633; the VM driver had none of it, so VM sandboxes on proxy-only networks could not reach any destination requiring the proxy even when policy allowed it. The blocking piece was not proxy logic but delivery: the VM guest init script runs as PID 1 and execs a fixed supervisor command line, and libkrun's krun_set_exec receives an empty argv, so there was no channel for driver-owned supervisor arguments. The supervisor's proxy flags deliberately have no environment fallback, and build_guest_environment merges user-supplied environment, so the guest env is not a safe transport either. Add a driver-authored argument file, mirroring the existing init.d manifest: the driver writes /opt/openshell/supervisor-args into the overlay upperdir on every launch and the guest reads it verbatim, one argument per line, appending it to every supervisor exec. It is written even when empty, which is what makes the channel unforgeable -- the upperdir always shadows the read-only image layer, so an image can neither supply its own arguments nor disable the operator's by omitting the file. Because both launch backends exec the same init script, this covers libkrun and QEMU without touching either. A microVM has no bind mounts or container secrets, so the credential and CA bundle are staged into the per-sandbox overlay the way the gateway JWT already is: credential root-only at 0600, CA at 0644, both rewritten every launch so a removed setting clears prior material, and both deleted with the sandbox state directory. This places the credential at rest in the overlay image on the gateway host, which differs from the Podman secret model and is documented as an explicit security consideration. Validation is fail-closed and shared: a new openshell_core::driver_utils::validate_upstream_proxy_settings holds the pairing rules the Podman driver established, and both the gateway and the driver call it so an invalid table names the offending key instead of surfacing as an opaque driver-readiness timeout. Guest egress leaves through gvproxy, so a proxy on the gateway host's loopback is reachable only through host.openshell.internal; the guest to gateway callback is unaffected. Closes #3088 Signed-off-by: Philippe Martin * fix(vm): bound the proxy CA read and scope the host-loopback recipe Two review findings on the corporate forward proxy support for microVM sandboxes. The driver read the operator's proxy_ca_bundle with an unbounded fs::read and accepted it on a substring match for the PEM BEGIN CERTIFICATE marker. A special file such as /dev/zero therefore grew driver memory without bound on every authorized sandbox create, and a PEM block holding invalid DER passed the host check but contributes no trust anchor in the guest, so every supervisor would fail after boot with an error attributed to the sandbox rather than to the setting. Move the read into openshell-core as read_upstream_proxy_ca_bundle_file: it reuses the credential reader's bounded-read path (non-regular files rejected on fstat, size capped, read bounded even if the file grows), then requires at least one anchor that RootCertStore::add_parsable_certificates accepts. The supervisor's own reader now delegates to it, so host acceptance and guest acceptance are the same function and cannot drift. The published host-loopback recipe was written for libkrun only. gvproxy NATs host.openshell.internal to the gateway host's 127.0.0.1, but GPU sandboxes run on the QEMU/TAP backend where that name resolves to the TAP host address and the driver's own nftables input chain accepts only the gateway port from the guest — no proxy on the gateway host is reachable there at any bind address, so an operator following the generic recipe lost all proxy-required egress while configuration validation succeeded. Scope the recipe to libkrun in every reference and reject a gateway-host proxy URL when a launch plan resolves to QEMU, naming the reason, instead of booting a sandbox whose policy-approved CONNECTs all time out. Signed-off-by: Philippe Martin * fix(vm): match the QEMU proxy preflight to the selected TAP host The gateway-host proxy guard added for the QEMU/TAP backend classified the wrong set of addresses in both directions. It ran at the top of configure_qemu_launch_plan, before the subnet allocation that settles plan.host_ip, so it could not compare against the address the guest actually reaches the host on. An operator pointing https_proxy at the sandbox's own TAP host address, such as 10.0.128.1, passed the check, and the driver's nftables input chain — which accepts only the gateway port from the guest — then dropped every policy-approved CONNECT, which is exactly the silent timeout the guard exists to prevent. In the other direction it rejected 192.168.127.254 unconditionally. That address is special only to libkrun/gvproxy; on QEMU/TAP it is an ordinary address that may be routable through the guest's masqueraded egress, so the guard refused a working configuration. Run the check after the launch plan's network allocation, on both the freshly-allocated and already-complete paths, and compare IP literals with that sandbox's selected TAP host. Loopback literals, localhost, and the documented host aliases that write_host_gateway_aliases seeds to the TAP host still classify as the gateway host, and the failure names the address. The gvproxy host-loopback constant returns to being a documentation anchor. Signed-off-by: Philippe Martin --------- Signed-off-by: Philippe Martin --- .../skills/debug-openshell-cluster/SKILL.md | 52 + Cargo.lock | 3 + architecture/sandbox.md | 29 + crates/openshell-core/Cargo.toml | 3 + crates/openshell-core/src/container_paths.rs | 29 + crates/openshell-core/src/driver_utils.rs | 535 ++++++++++- crates/openshell-driver-vm/README.md | 8 + .../scripts/openshell-vm-sandbox-init.sh | 73 +- crates/openshell-driver-vm/src/driver.rs | 904 +++++++++++++++++- crates/openshell-driver-vm/src/main.rs | 87 ++ crates/openshell-gateway/src/vm.rs | 176 +++- .../src/upstream_proxy.rs | 31 +- docs/reference/gateway-config.mdx | 44 + docs/reference/sandbox-compute-drivers.mdx | 12 + e2e/rust/Cargo.toml | 5 + e2e/rust/e2e-vm.sh | 1 + e2e/rust/src/harness/host_process.rs | 103 ++ e2e/rust/src/harness/mod.rs | 1 + e2e/rust/tests/vm_corporate_proxy.rs | 888 +++++++++++++++++ examples/governance-interceptor/Cargo.lock | 11 + 20 files changed, 2923 insertions(+), 72 deletions(-) create mode 100644 e2e/rust/src/harness/host_process.rs create mode 100644 e2e/rust/tests/vm_corporate_proxy.rs diff --git a/.agents/skills/debug-openshell-cluster/SKILL.md b/.agents/skills/debug-openshell-cluster/SKILL.md index 47588b1aeb..14e88286cb 100644 --- a/.agents/skills/debug-openshell-cluster/SKILL.md +++ b/.agents/skills/debug-openshell-cluster/SKILL.md @@ -630,6 +630,58 @@ openshell status openshell logs ``` +#### Corporate upstream proxy + +When VM sandbox egress routes through a corporate HTTP forward proxy, the +operator-owned settings live under `[openshell.drivers.vm]` and the gateway +forwards them to the `openshell-driver-vm` subprocess as `--https-proxy`, +`--no-proxy`, `--proxy-auth-file`, `--proxy-auth-allow-insecure`, +`--proxy-connect-by-hostname`, and `--proxy-ca-bundle`. Both the gateway and +the driver validate them at startup, so any present-but-invalid value fails +closed with an error naming the key rather than reverting to a direct dial. +Confirm the configuration and the resulting driver argv first: + +```bash +grep -A20 '^\[openshell.drivers.vm\]' | grep -E 'https_proxy|no_proxy|proxy_auth_file|proxy_auth_allow_insecure|proxy_connect_by_hostname|proxy_ca_bundle' +ps -o args= -p "$(pgrep -f openshell-driver-vm | head -n1)" | tr ' ' '\n' | grep -A1 -- '--proxy\|--https-proxy\|--no-proxy' +``` + +Reachability is the most common failure, and it depends on the VM backend. +On libkrun (non-GPU sandboxes) guest egress leaves through gvproxy, so a proxy +bound to the gateway host's loopback is **not** reachable at `127.0.0.1` from +inside the guest: it must be addressed as +`http://host.openshell.internal:`, which gvproxy NATs from +`192.168.127.254` to the host's `127.0.0.1`. A `https_proxy` pointing at a +loopback URL produces policy-approved CONNECT attempts that time out while +public destinations still work. + +GPU sandboxes run on QEMU/TAP, where no gateway-host proxy is reachable at +all: `host.openshell.internal` resolves to the TAP host address, and the +driver's nftables `input` chain accepts only the gateway port from the guest. +The driver rejects such a configuration at launch — a create failing with +`https_proxy ... addresses the gateway host, which a QEMU/TAP sandbox ... +cannot reach` means the proxy must move to an address routable from the +guest's masqueraded egress (or the sandbox must run without a GPU). + +The settings reach the supervisor through a driver-written argument file in +the per-sandbox overlay, not through the guest environment. The credential and +CA bundle are staged into the same overlay at fixed guest paths. Inspect the +guest side from the VM console log, which records how many driver-supplied +arguments the init script read: + +```bash +grep -E 'supervisor arguments from driver|supervisor argument list' /sandboxes//rootfs-console.log +grep -Ei 'upstream|connect|proxy' /sandboxes//rootfs-console.log | tail -n 40 +``` + +`FATAL: supervisor argument list ... is not readable` or `FATAL: empty entry in +supervisor argument list` means the overlay is broken or was tampered with, and +the guest deliberately aborts rather than starting a supervisor with a +truncated egress configuration. If the guest logs no driver arguments at all +while `gateway.toml` sets `https_proxy`, the running driver predates the +configuration — check that the gateway spawned the driver binary you expect +(`[openshell.drivers.vm].driver_dir`). + ## Common Failure Patterns | Symptom | Likely cause | Check | diff --git a/Cargo.lock b/Cargo.lock index 90240d6b3a..dc24adc6db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3861,8 +3861,11 @@ dependencies = [ "prost", "prost-types", "protoc-bin-vendored", + "rcgen", "reqwest 0.12.28", "rustix 1.1.4", + "rustls", + "rustls-pemfile", "serde", "serde_json", "tar", diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 6e4e020536..dd9621a09d 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -300,6 +300,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 diff --git a/crates/openshell-core/Cargo.toml b/crates/openshell-core/Cargo.toml index d8483bd2cb..c96d536f07 100644 --- a/crates/openshell-core/Cargo.toml +++ b/crates/openshell-core/Cargo.toml @@ -27,6 +27,8 @@ serde_json = { workspace = true } tracing = { workspace = true } url = { workspace = true } ipnet = "2" +rustls = { workspace = true } +rustls-pemfile = { workspace = true } base64 = { workspace = true } chrono = { version = "0.4", default-features = false, features = ["clock", "std"], optional = true } reqwest = { workspace = true, features = ["blocking", "rustls-tls-native-roots"], optional = true } @@ -53,6 +55,7 @@ protoc-bin-vendored = { workspace = true } [dev-dependencies] tempfile = "3" +rcgen = { workspace = true } [lints] workspace = true diff --git a/crates/openshell-core/src/container_paths.rs b/crates/openshell-core/src/container_paths.rs index c63e4bcdd8..e26ea53f7a 100644 --- a/crates/openshell-core/src/container_paths.rs +++ b/crates/openshell-core/src/container_paths.rs @@ -65,6 +65,32 @@ pub const VM_GUEST_TLS_KEY_PATH: &str = "/opt/openshell/tls/tls.key"; pub const VM_GUEST_SANDBOX_TOKEN_PATH: &str = "/opt/openshell/auth/sandbox.jwt"; pub const VM_GUEST_INIT_DROPIN_DIR: &str = "/opt/openshell/init.d"; pub const VM_GUEST_INIT_DROPIN_MANIFEST: &str = "/opt/openshell/init.d.manifest"; + +/// Guest path for the corporate upstream-proxy credential in VM sandboxes. +/// +/// The VM driver stages the `user:pass` credential here (mode `0600`, +/// root-only) inside the per-sandbox overlay upperdir, and passes only this +/// path on the supervisor's argv. A microVM has no bind mounts or container +/// secrets, so this is the same delivery the per-sandbox JWT already uses. +pub const VM_GUEST_UPSTREAM_PROXY_AUTH_PATH: &str = "/opt/openshell/auth/upstream-proxy"; + +/// Guest path for the corporate proxy CA bundle in VM sandboxes. +/// +/// A CA certificate is not secret, so unlike the credential this is staged +/// world-readable. The supervisor trusts it for the handshake with an +/// `https://` proxy and for server certificates re-signed by a +/// TLS-intercepting proxy. +pub const VM_GUEST_PROXY_CA_PATH: &str = "/opt/openshell/tls/proxy-ca.pem"; + +/// Guest path for the driver-authored supervisor argument list in VM sandboxes. +/// +/// Podman and Kubernetes build the supervisor's command line directly; the VM +/// guest init script execs a fixed argv, so driver-owned arguments travel +/// through this file instead. The driver writes it into the overlay upperdir +/// on every launch — empty when it has no arguments to pass — so a sandbox +/// image can neither forge entries nor shadow the driver's copy, and the +/// guest appends exactly what it finds there and nothing else. +pub const VM_GUEST_SUPERVISOR_ARGS_PATH: &str = "/opt/openshell/supervisor-args"; pub const VM_UMOCI_PATH: &str = "/opt/openshell/bin/umoci"; pub const VM_SANDBOX_OWNER_NORMALIZED_MARKER: &str = "/opt/openshell/.sandbox-owner-normalized"; @@ -103,6 +129,9 @@ mod tests { VM_GUEST_SANDBOX_TOKEN_PATH, VM_GUEST_INIT_DROPIN_DIR, VM_GUEST_INIT_DROPIN_MANIFEST, + VM_GUEST_UPSTREAM_PROXY_AUTH_PATH, + VM_GUEST_PROXY_CA_PATH, + VM_GUEST_SUPERVISOR_ARGS_PATH, VM_UMOCI_PATH, VM_SANDBOX_OWNER_NORMALIZED_MARKER, ]; diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index c8be114ebd..74871751da 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -375,6 +375,123 @@ pub const MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES: u64 = 4096; /// cannot be opened or stat'd, is not a regular file, or exceeds the size /// bound. pub fn read_upstream_proxy_credential_file(path: &str) -> Result { + read_regular_file_bounded(path, MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES).map_err(|err| match err { + BoundedReadError::Open(e) => format!("failed to open proxy auth file '{path}': {e}"), + BoundedReadError::Stat(e) => format!("failed to stat proxy auth file '{path}': {e}"), + BoundedReadError::NotRegular => format!("proxy auth file '{path}' is not a regular file"), + BoundedReadError::TooLarge => format!( + "proxy auth file '{path}' exceeds the {MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES}-byte limit" + ), + BoundedReadError::Read(e) => format!("failed to read proxy auth file '{path}': {e}"), + }) +} + +/// Hard upper bound on the size of a corporate proxy CA bundle file. +/// +/// A CA bundle holding every corporate trust anchor is a few tens of +/// kilobytes; this cap only exists so a hostile or misconfigured path (a huge +/// file, or a special file such as `/dev/zero`) cannot exhaust gateway, +/// driver, or supervisor memory during a bounded read. +pub const MAX_UPSTREAM_PROXY_CA_BUNDLE_BYTES: u64 = 1024 * 1024; + +/// Read and validate an operator corporate proxy CA bundle PEM file. +/// +/// Rejects non-regular files (e.g. `/dev/zero`, directories, FIFOs) and files +/// larger than [`MAX_UPSTREAM_PROXY_CA_BUNDLE_BYTES`], then requires the +/// bundle to contribute at least one trust anchor rustls actually accepts — +/// see [`validate_upstream_proxy_ca_bundle_pem`]. Returns the PEM contents. +/// +/// Shared by the compute driver (at sandbox-create time, so the operator gets +/// an error naming the setting) and the in-container supervisor (at startup), +/// so a bundle accepted on the host is never rejected inside the sandbox and +/// vice versa. This is a blocking read; async callers should wrap it (e.g. +/// `tokio::task::spawn_blocking`). +/// +/// `label` names the operator-facing setting (`proxy_ca_bundle`, or the +/// supervisor's argument name) and prefixes every error. +/// +/// # Errors +/// +/// Returns a descriptive error (never containing file contents) when the path +/// cannot be read, is not a regular file, exceeds the size bound, or holds no +/// usable certificate. +pub fn read_upstream_proxy_ca_bundle_file(path: &str, label: &str) -> Result { + let pem = read_regular_file_bounded(path, MAX_UPSTREAM_PROXY_CA_BUNDLE_BYTES).map_err( + |err| match err { + BoundedReadError::Open(e) | BoundedReadError::Stat(e) | BoundedReadError::Read(e) => { + format!("{label} '{path}' could not be read: {e}") + } + BoundedReadError::NotRegular => { + format!("{label} '{path}' is not a regular file") + } + BoundedReadError::TooLarge => format!( + "{label} '{path}' exceeds the {MAX_UPSTREAM_PROXY_CA_BUNDLE_BYTES}-byte limit" + ), + }, + )?; + validate_upstream_proxy_ca_bundle_pem(&pem, path, label)?; + Ok(pem) +} + +/// Require a CA bundle PEM to contribute at least one usable trust anchor. +/// +/// Fail-closed to match the rest of the operator-owned proxy configuration: +/// the operator explicitly pointed at this file, so a bundle with no usable +/// certificate is an error rather than a silent fall-back to the built-in +/// roots that would quietly weaken the trust boundary. +/// +/// Validating that rustls accepts an anchor — rather than only that PEM +/// framing base64-decodes — is what makes the host-side check equivalent to +/// the guest-side one: a PEM block holding invalid DER passes +/// `rustls_pemfile::certs` but is silently dropped by +/// `RootCertStore::add_parsable_certificates`, so counting PEM blocks alone +/// would accept on the host a bundle that contributes zero anchors at runtime. +/// +/// # Errors +/// +/// Returns a descriptive error, prefixed with `label` and naming `path`, when +/// the PEM holds no certificate block or no block contains valid X.509 DER. +pub fn validate_upstream_proxy_ca_bundle_pem( + pem: &str, + path: &str, + label: &str, +) -> Result<(), String> { + let certs: Vec<_> = rustls_pemfile::certs(&mut pem.as_bytes()) + .flatten() + .collect(); + if certs.is_empty() { + return Err(format!( + "{label} '{path}' contains no PEM certificate blocks" + )); + } + let mut store = rustls::RootCertStore::empty(); + let (added, _ignored) = store.add_parsable_certificates(certs); + if added == 0 { + return Err(format!( + "{label} '{path}' contains no usable trust anchors \ + (PEM blocks were found but none contain valid X.509 DER)" + )); + } + Ok(()) +} + +/// Failure modes of [`read_regular_file_bounded`], so each caller can phrase +/// them in terms of the operator setting it is reading. +enum BoundedReadError { + Open(std::io::Error), + Stat(std::io::Error), + NotRegular, + TooLarge, + Read(std::io::Error), +} + +/// Read a regular file into a `String`, rejecting anything larger than +/// `max_bytes` and anything that is not a regular file. +/// +/// Backs the operator-supplied proxy file readers, which must never let a +/// hostile or misconfigured path (`/dev/zero`, a FIFO, a directory, a huge +/// file) exhaust memory or block the caller. +fn read_regular_file_bounded(path: &str, max_bytes: u64) -> Result { use std::io::Read as _; // Windows rejects opening a directory before a file handle is available, @@ -384,10 +501,9 @@ pub fn read_upstream_proxy_credential_file(path: &str) -> Result // window if the path is replaced between these operations. #[cfg(target_os = "windows")] { - let path_metadata = std::fs::metadata(path) - .map_err(|e| format!("failed to open proxy auth file '{path}': {e}"))?; + let path_metadata = std::fs::metadata(path).map_err(BoundedReadError::Open)?; if !path_metadata.is_file() { - return Err(format!("proxy auth file '{path}' is not a regular file")); + return Err(BoundedReadError::NotRegular); } } @@ -405,31 +521,145 @@ pub fn read_upstream_proxy_credential_file(path: &str) -> Result #[cfg(not(unix))] let open_result = std::fs::File::open(path); - let file = open_result.map_err(|e| format!("failed to open proxy auth file '{path}': {e}"))?; - let metadata = file - .metadata() - .map_err(|e| format!("failed to stat proxy auth file '{path}': {e}"))?; + let file = open_result.map_err(BoundedReadError::Open)?; + let metadata = file.metadata().map_err(BoundedReadError::Stat)?; if !metadata.is_file() { - return Err(format!("proxy auth file '{path}' is not a regular file")); + return Err(BoundedReadError::NotRegular); } - if metadata.len() > MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES { - return Err(format!( - "proxy auth file '{path}' exceeds the {MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES}-byte limit" - )); + if metadata.len() > max_bytes { + return Err(BoundedReadError::TooLarge); } // Bound the read even if the file grows between stat and read. let mut buf = String::new(); - file.take(MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES + 1) + file.take(max_bytes + 1) .read_to_string(&mut buf) - .map_err(|e| format!("failed to read proxy auth file '{path}': {e}"))?; - if buf.len() as u64 > MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES { - return Err(format!( - "proxy auth file '{path}' exceeds the {MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES}-byte limit" - )); + .map_err(BoundedReadError::Read)?; + if buf.len() as u64 > max_bytes { + return Err(BoundedReadError::TooLarge); } Ok(buf) } +/// Operator-supplied corporate upstream-proxy settings, as a borrowed view. +/// +/// Compute drivers store these keys under their own +/// `[openshell.drivers.]` table; this type exists so the pairing rules +/// between them live in one place instead of being restated per driver. +/// Field names map 1:1 onto the documented TOML keys `https_proxy`, +/// `no_proxy`, `proxy_auth_file`, `proxy_auth_allow_insecure`, +/// `proxy_connect_by_hostname`, and `proxy_ca_bundle`. +#[derive(Debug, Clone, Copy, Default)] +pub struct UpstreamProxySettings<'a> { + /// `https_proxy`: the corporate forward proxy URL. + pub url: Option<&'a str>, + /// `no_proxy`: comma-separated bypass list. + pub no_proxy: Option<&'a str>, + /// `proxy_auth_file`: host path to a `user:pass` credential file. + pub auth_file: Option<&'a str>, + /// `proxy_auth_allow_insecure`: acknowledgement that Basic auth to an + /// `http://` proxy travels in cleartext. + pub auth_allow_insecure: Option, + /// `proxy_connect_by_hostname`: send hostnames rather than validated IPs + /// in CONNECT requests. + pub connect_by_hostname: Option, + /// `proxy_ca_bundle`: host path to a PEM CA bundle trusted for the proxy. + pub ca_bundle: Option<&'a str>, +} + +/// Validate operator-supplied corporate upstream-proxy settings, fail-closed. +/// +/// Shares URL semantics with the in-container supervisor through +/// [`parse_upstream_proxy_url`], so a value accepted here can never be +/// rejected by the supervisor at sandbox startup (or vice versa). Every +/// auxiliary setting is only meaningful relative to a proxy boundary the +/// operator believed was in effect, so a stray one is rejected rather than +/// silently accepted while all egress dials directly. +/// +/// A present-but-empty string is rejected everywhere: the supervisor treats +/// an empty driver-supplied argument as a fatal misconfiguration, so a driver +/// must never accept (and later pass) one. +/// +/// # Errors +/// +/// Returns a message naming the offending key. +pub fn validate_upstream_proxy_settings( + settings: &UpstreamProxySettings<'_>, +) -> Result<(), String> { + let proxy_secure = if let Some(url) = settings.url { + let addr = parse_upstream_proxy_url(url).map_err(|err| match err { + UpstreamProxyUrlError::Empty => "https_proxy must not be empty when set".to_string(), + UpstreamProxyUrlError::InlineCredentials => { + "https_proxy must not embed credentials in the URL; supply them via \ + proxy_auth_file so they are not stored in config or sandbox metadata" + .to_string() + } + err => format!("https_proxy {err}"), + })?; + addr.secure + } else { + false + }; + + if let Some(list) = settings.no_proxy { + if list.trim().is_empty() { + return Err("no_proxy must not be empty when set; omit it instead".to_string()); + } + if settings.url.is_none() { + return Err("no_proxy is set but no https_proxy is configured".to_string()); + } + } + + if let Some(path) = settings.auth_file { + if path.trim().is_empty() { + return Err("proxy_auth_file must not be empty when set".to_string()); + } + if settings.url.is_none() { + return Err("proxy_auth_file is set but no https_proxy is configured".to_string()); + } + // Basic auth over the plain-TCP proxy connection is readable by + // anyone on the network path; sending it requires an explicit + // operator acknowledgement rather than being an implicit side effect + // of configuring credentials. For an https:// proxy the credential is + // inside the verified TLS session, so the acknowledgement is + // unnecessary (but tolerated). + if settings.auth_allow_insecure != Some(true) && !proxy_secure { + return Err( + "proxy_auth_file sends the credential as cleartext Basic auth over the \ + plain-TCP connection to the http:// proxy; set proxy_auth_allow_insecure \ + = true to accept that exposure, or remove proxy_auth_file" + .to_string(), + ); + } + } else if settings.auth_allow_insecure.is_some() { + // The acknowledgement without credentials means the operator believed + // an auth file was configured; surface the mismatch. + return Err( + "proxy_auth_allow_insecure is set but no proxy_auth_file is configured".to_string(), + ); + } + + if settings.connect_by_hostname.is_some() && settings.url.is_none() { + return Err( + "proxy_connect_by_hostname is set but no https_proxy is configured".to_string(), + ); + } + + // A CA bundle only makes sense relative to a proxy boundary (an https:// + // proxy handshake, or a TLS-intercepting proxy's re-sign CA). The file's + // readability and certificate content are checked at sandbox-create time + // by the driver and fail closed again in the supervisor. + if let Some(path) = settings.ca_bundle { + if path.trim().is_empty() { + return Err("proxy_ca_bundle must not be empty when set".to_string()); + } + if settings.url.is_none() { + return Err("proxy_ca_bundle is set but no https_proxy is configured".to_string()); + } + } + + Ok(()) +} + /// Container-side directory where the provider SPIFFE Workload API socket is mounted. pub const PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR: &str = "/spiffe-workload-api"; @@ -888,4 +1118,273 @@ mod tests { "reading a FIFO must not block" ); } + + /// Build settings with only the fields a case cares about. + #[test] + fn ca_bundle_file_accepts_a_real_certificate() { + // The positive case that pins host acceptance to guest acceptance: + // what the driver stages is exactly what rustls will trust. + let cert = rcgen::generate_simple_self_signed(vec!["proxy.corp.example".to_string()]) + .expect("test CA"); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("proxy-ca.pem"); + std::fs::write(&path, cert.cert.pem()).unwrap(); + + let pem = + read_upstream_proxy_ca_bundle_file(path.to_str().unwrap(), "proxy_ca_bundle").unwrap(); + assert!(pem.contains("BEGIN CERTIFICATE")); + } + + #[test] + fn ca_bundle_file_rejects_non_regular_and_oversized_paths() { + // /dev/zero is the case that matters: an unbounded read of it would + // exhaust gateway or driver memory on any authorized sandbox create. + let dir = tempfile::tempdir().unwrap(); + let err = + read_upstream_proxy_ca_bundle_file(dir.path().to_str().unwrap(), "proxy_ca_bundle") + .unwrap_err(); + assert!(err.contains("regular file"), "{err}"); + assert!(err.contains("proxy_ca_bundle"), "{err}"); + + if Path::new("/dev/zero").exists() { + let err = + read_upstream_proxy_ca_bundle_file("/dev/zero", "proxy_ca_bundle").unwrap_err(); + assert!(err.contains("regular file"), "{err}"); + } + + let oversized = dir.path().join("oversized.pem"); + std::fs::write( + &oversized, + vec![b'x'; usize::try_from(MAX_UPSTREAM_PROXY_CA_BUNDLE_BYTES).unwrap() + 1], + ) + .unwrap(); + let err = + read_upstream_proxy_ca_bundle_file(oversized.to_str().unwrap(), "proxy_ca_bundle") + .unwrap_err(); + assert!(err.contains("exceeds"), "{err}"); + } + + #[test] + fn ca_bundle_file_missing_path_is_an_error() { + let err = + read_upstream_proxy_ca_bundle_file("/nonexistent/proxy-ca.pem", "proxy_ca_bundle") + .unwrap_err(); + assert!(err.contains("could not be read"), "{err}"); + } + + #[test] + fn ca_bundle_rejects_a_file_without_certificate_blocks() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("proxy-ca.pem"); + std::fs::write(&path, "this is not a certificate\n").unwrap(); + let err = read_upstream_proxy_ca_bundle_file(path.to_str().unwrap(), "proxy_ca_bundle") + .unwrap_err(); + assert!(err.contains("no PEM certificate blocks"), "{err}"); + + std::fs::write(&path, "").unwrap(); + let err = read_upstream_proxy_ca_bundle_file(path.to_str().unwrap(), "proxy_ca_bundle") + .unwrap_err(); + assert!(err.contains("no PEM certificate blocks"), "{err}"); + } + + #[test] + fn ca_bundle_rejects_pem_blocks_holding_invalid_der() { + // Passes `rustls_pemfile::certs` but contributes no trust anchor, so + // accepting it on the host would break every guest after boot. + let err = validate_upstream_proxy_ca_bundle_pem( + "-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----\n", + "/etc/openshell/tls/proxy-ca.pem", + "proxy_ca_bundle", + ) + .unwrap_err(); + assert!(err.contains("no usable trust anchors"), "{err}"); + } + + fn proxy_settings(url: Option<&str>) -> UpstreamProxySettings<'_> { + UpstreamProxySettings { + url, + ..UpstreamProxySettings::default() + } + } + + #[test] + fn upstream_proxy_settings_accept_a_bare_proxy_url() { + validate_upstream_proxy_settings(&proxy_settings(Some("http://proxy.corp.com:3128"))) + .expect("a lone proxy URL is a complete configuration"); + } + + #[test] + fn upstream_proxy_settings_accept_an_empty_configuration() { + validate_upstream_proxy_settings(&UpstreamProxySettings::default()) + .expect("no proxy configured at all is valid"); + } + + #[test] + fn upstream_proxy_settings_reject_an_unsupported_scheme() { + let err = validate_upstream_proxy_settings(&proxy_settings(Some("socks5://proxy:1080"))) + .expect_err("only http:// and https:// proxies are supported"); + assert!(err.starts_with("https_proxy "), "{err}"); + assert!(err.contains("unsupported proxy scheme"), "{err}"); + } + + #[test] + fn upstream_proxy_settings_reject_inline_credentials_by_naming_the_auth_file() { + let err = validate_upstream_proxy_settings(&proxy_settings(Some("http://u:p@proxy:3128"))) + .expect_err("inline credentials would be stored in gateway config"); + assert!(err.contains("proxy_auth_file"), "{err}"); + } + + #[test] + fn upstream_proxy_settings_reject_an_empty_proxy_url() { + let err = validate_upstream_proxy_settings(&proxy_settings(Some(" "))) + .expect_err("present-but-empty is a misconfiguration, not 'unset'"); + assert_eq!(err, "https_proxy must not be empty when set"); + } + + #[test] + fn upstream_proxy_settings_reject_auxiliary_keys_without_a_proxy_url() { + // Each auxiliary key implies a proxy boundary the operator believed + // was in effect; accepting one while every dial goes direct would + // hide a fail-open state. + for (settings, key) in [ + ( + UpstreamProxySettings { + no_proxy: Some("10.0.0.0/8"), + ..UpstreamProxySettings::default() + }, + "no_proxy", + ), + ( + UpstreamProxySettings { + auth_file: Some("/etc/openshell/secrets/proxy-auth"), + ..UpstreamProxySettings::default() + }, + "proxy_auth_file", + ), + ( + UpstreamProxySettings { + connect_by_hostname: Some(true), + ..UpstreamProxySettings::default() + }, + "proxy_connect_by_hostname", + ), + ( + UpstreamProxySettings { + ca_bundle: Some("/etc/openshell/tls/proxy-ca.pem"), + ..UpstreamProxySettings::default() + }, + "proxy_ca_bundle", + ), + ] { + let err = validate_upstream_proxy_settings(&settings) + .expect_err("an auxiliary key without a proxy URL must fail closed"); + assert_eq!( + err, + format!("{key} is set but no https_proxy is configured") + ); + } + } + + #[test] + fn upstream_proxy_settings_reject_empty_auxiliary_values() { + for (settings, expected) in [ + ( + UpstreamProxySettings { + url: Some("http://proxy:3128"), + no_proxy: Some(" "), + ..UpstreamProxySettings::default() + }, + "no_proxy must not be empty when set; omit it instead", + ), + ( + UpstreamProxySettings { + url: Some("http://proxy:3128"), + auth_file: Some(""), + ..UpstreamProxySettings::default() + }, + "proxy_auth_file must not be empty when set", + ), + ( + UpstreamProxySettings { + url: Some("http://proxy:3128"), + ca_bundle: Some(""), + ..UpstreamProxySettings::default() + }, + "proxy_ca_bundle must not be empty when set", + ), + ] { + let err = validate_upstream_proxy_settings(&settings) + .expect_err("present-but-empty must never be treated as unset"); + assert_eq!(err, expected); + } + } + + #[test] + fn upstream_proxy_credentials_require_the_cleartext_acknowledgement() { + let err = validate_upstream_proxy_settings(&UpstreamProxySettings { + url: Some("http://proxy:3128"), + auth_file: Some("/etc/openshell/secrets/proxy-auth"), + ..UpstreamProxySettings::default() + }) + .expect_err("Basic auth to an http:// proxy is cleartext on the wire"); + assert!(err.contains("proxy_auth_allow_insecure"), "{err}"); + + validate_upstream_proxy_settings(&UpstreamProxySettings { + url: Some("http://proxy:3128"), + auth_file: Some("/etc/openshell/secrets/proxy-auth"), + auth_allow_insecure: Some(true), + ..UpstreamProxySettings::default() + }) + .expect("the explicit acknowledgement makes the exposure an operator decision"); + } + + #[test] + fn upstream_proxy_credentials_need_no_acknowledgement_for_an_https_proxy() { + // The credential travels inside the verified TLS session to the proxy. + validate_upstream_proxy_settings(&UpstreamProxySettings { + url: Some("https://proxy:3130"), + auth_file: Some("/etc/openshell/secrets/proxy-auth"), + ..UpstreamProxySettings::default() + }) + .expect("an https:// proxy does not expose the credential on the wire"); + + // ... but setting it anyway is tolerated rather than an error. + validate_upstream_proxy_settings(&UpstreamProxySettings { + url: Some("https://proxy:3130"), + auth_file: Some("/etc/openshell/secrets/proxy-auth"), + auth_allow_insecure: Some(true), + ..UpstreamProxySettings::default() + }) + .expect("a redundant acknowledgement is tolerated"); + } + + #[test] + fn upstream_proxy_acknowledgement_without_credentials_is_rejected() { + // Including `= false`: the operator believed an auth file was + // configured, so the mismatch is surfaced rather than ignored. + for ack in [Some(true), Some(false)] { + let err = validate_upstream_proxy_settings(&UpstreamProxySettings { + url: Some("http://proxy:3128"), + auth_allow_insecure: ack, + ..UpstreamProxySettings::default() + }) + .expect_err("the acknowledgement is meaningless without a credential"); + assert_eq!( + err, + "proxy_auth_allow_insecure is set but no proxy_auth_file is configured" + ); + } + } + + #[test] + fn upstream_proxy_ca_bundle_is_valid_with_a_plain_http_proxy() { + // A TLS-intercepting proxy can be reached over plain HTTP while still + // re-signing tunneled server certificates with its own CA. + validate_upstream_proxy_settings(&UpstreamProxySettings { + url: Some("http://proxy:3128"), + ca_bundle: Some("/etc/openshell/tls/proxy-ca.pem"), + ..UpstreamProxySettings::default() + }) + .expect("an intercepting proxy's CA is meaningful without an https:// proxy URL"); + } } diff --git a/crates/openshell-driver-vm/README.md b/crates/openshell-driver-vm/README.md index 4fc9ace415..5c61ae1823 100644 --- a/crates/openshell-driver-vm/README.md +++ b/crates/openshell-driver-vm/README.md @@ -154,6 +154,14 @@ Select the VM driver with `--drivers vm`, `OPENSHELL_DRIVERS=vm`, or `compute_dr | `guest_tls_ca` | unset | CA cert for the guest's mTLS client bundle. Required when `grpc_endpoint` uses `https://`. | | `guest_tls_cert` | unset | Guest client certificate. | | `guest_tls_key` | unset | Guest client private key. | +| `https_proxy` | unset | Corporate forward proxy (`http://host:port` or `https://host:port`) the in-guest supervisor chains policy-approved TLS CONNECT egress through. On the libkrun backend a proxy on the gateway host's loopback must be addressed as `http://host.openshell.internal:` — guest egress leaves through gvproxy, which NATs `192.168.127.254` to the host's `127.0.0.1`. The QEMU/TAP backend (GPU sandboxes) has no such NAT and its nftables rules expose only the gateway port to the guest, so a gateway-host proxy URL is rejected at launch there; use an address routable from the guest's masqueraded egress. | +| `no_proxy` | unset | Comma-separated bypass list for the corporate proxy only. OpenShell policy evaluation still applies. | +| `proxy_auth_file` | unset | Gateway-host path to a `user:pass` credential file. Staged root-only into the per-sandbox overlay and removed with the sandbox. | +| `proxy_auth_allow_insecure` | unset | Required with `proxy_auth_file` against an `http://` proxy: acknowledges that Basic auth is cleartext on the connection to the proxy. | +| `proxy_connect_by_hostname` | unset | Send hostnames rather than validated IPs in CONNECT. Last resort for proxies whose ACLs reject IP targets. | +| `proxy_ca_bundle` | unset | Gateway-host path to a PEM CA bundle trusted for an `https://` proxy and for certificates a TLS-intercepting proxy re-signs. | + +The proxy settings are operator-owned and deployment-level: they are not accepted through `template.driver_config.vm`, and they reach the supervisor on its command line through a per-sandbox argument file the driver writes into the overlay upperdir on every launch, so a sandbox image cannot forge or shadow them. Every present-but-invalid value is fatal at gateway or sandbox startup rather than degrading to a direct dial. See [`openshell-gateway --help`](../openshell-server/src/cli.rs) for the gateway process flag surface. diff --git a/crates/openshell-driver-vm/scripts/openshell-vm-sandbox-init.sh b/crates/openshell-driver-vm/scripts/openshell-vm-sandbox-init.sh index 14dbc0466b..32d6ed1dff 100644 --- a/crates/openshell-driver-vm/scripts/openshell-vm-sandbox-init.sh +++ b/crates/openshell-driver-vm/scripts/openshell-vm-sandbox-init.sh @@ -192,6 +192,67 @@ prepare_guest_image_rootfs() { rm -rf "$payload_dir" } +# Driver-owned arguments appended to the supervisor's command line. +# +# The VM driver cannot build the supervisor's argv the way the container +# drivers do, so it writes the arguments it chose into the overlay upperdir +# and this script appends them verbatim. Populated by +# read_supervisor_extra_args; empty until then. +SUPERVISOR_EXTRA_ARGS=() + +# Upper bound on driver-supplied supervisor arguments. +# +# The corporate proxy settings are the only producer today and top out at ten +# entries. The cap exists so a corrupt or oversized file cannot expand into an +# unbounded command line. +SUPERVISOR_EXTRA_ARGS_MAX=32 + +read_supervisor_extra_args() { + # Read the driver-authored supervisor argument list, one argument per + # line, verbatim -- no word splitting, globbing, or expansion, so values + # containing spaces (e.g. a NO_PROXY list) survive intact. + # + # Security: this is the operator-owned egress boundary. The driver writes + # this file into the overlay upperdir on every launch, including an empty + # file when it has no arguments to pass, so the upperdir copy always + # shadows the read-only image layer. A sandbox image can therefore neither + # supply its own supervisor arguments by baking a file at this path nor + # disable the operator's by omitting one. A missing file means the driver + # passed nothing; a file it cannot read means the overlay is broken, and + # we fail closed rather than start a supervisor with a silently truncated + # egress configuration. + local args_file + args_file="$(root_path /opt/openshell/supervisor-args)" + + SUPERVISOR_EXTRA_ARGS=() + if [ ! -f "$args_file" ]; then + return 0 + fi + if [ ! -r "$args_file" ]; then + ts "FATAL: supervisor argument list ${args_file} is not readable" + exit 1 + fi + + local arg + while IFS= read -r arg; do + # render_guest_supervisor_args never emits a blank line, so one means + # the file was truncated or tampered with after the driver wrote it. + if [ -z "$arg" ]; then + ts "FATAL: empty entry in supervisor argument list" + exit 1 + fi + if [ "${#SUPERVISOR_EXTRA_ARGS[@]}" -ge "$SUPERVISOR_EXTRA_ARGS_MAX" ]; then + ts "FATAL: supervisor argument list exceeds ${SUPERVISOR_EXTRA_ARGS_MAX} entries" + exit 1 + fi + SUPERVISOR_EXTRA_ARGS+=("$arg") + done < "$args_file" + + if [ "${#SUPERVISOR_EXTRA_ARGS[@]}" -gt 0 ]; then + ts "supervisor arguments from driver: ${#SUPERVISOR_EXTRA_ARGS[@]} entries" + fi +} + exec_supervisor_in_newroot() { local chroot_bin local bootstrap="/.openshell-bootstrap" @@ -214,14 +275,16 @@ exec_supervisor_in_newroot() { "${bootstrap}/lib64/ld-linux-aarch64.so.1"; do if [ -x "/newroot${loader}" ]; then lib_path="${bootstrap}/lib:${bootstrap}/lib64:${bootstrap}/usr/lib:${bootstrap}/usr/lib64:${bootstrap}/lib/aarch64-linux-gnu:${bootstrap}/lib/x86_64-linux-gnu:${bootstrap}/usr/lib/aarch64-linux-gnu:${bootstrap}/usr/lib/x86_64-linux-gnu" - exec "$chroot_bin" /newroot "$loader" --library-path "$lib_path" "$supervisor" --workdir /sandbox + exec "$chroot_bin" /newroot "$loader" --library-path "$lib_path" \ + "$supervisor" --workdir /sandbox "${SUPERVISOR_EXTRA_ARGS[@]+"${SUPERVISOR_EXTRA_ARGS[@]}"}" fi done - exec "$chroot_bin" /newroot "$supervisor" --workdir /sandbox + exec "$chroot_bin" /newroot "$supervisor" --workdir /sandbox "${SUPERVISOR_EXTRA_ARGS[@]+"${SUPERVISOR_EXTRA_ARGS[@]}"}" fi if [ -x /newroot/opt/openshell/bin/openshell-sandbox ]; then - exec "$chroot_bin" /newroot /opt/openshell/bin/openshell-sandbox --workdir /sandbox + exec "$chroot_bin" /newroot /opt/openshell/bin/openshell-sandbox \ + --workdir /sandbox "${SUPERVISOR_EXTRA_ARGS[@]+"${SUPERVISOR_EXTRA_ARGS[@]}"}" fi done @@ -833,11 +896,13 @@ if [ -n "${OPENSHELL_SANDBOX_ID:-}" ]; then ts "OPENSHELL_SANDBOX_ID=${OPENSHELL_SANDBOX_ID}" fi +read_supervisor_extra_args + ts "starting openshell-sandbox supervisor" if [ "${ROOT_PREFIX:-}" = "/newroot" ]; then exec_supervisor_in_newroot fi -exec /opt/openshell/bin/openshell-sandbox --workdir /sandbox +exec /opt/openshell/bin/openshell-sandbox --workdir /sandbox "${SUPERVISOR_EXTRA_ARGS[@]+"${SUPERVISOR_EXTRA_ARGS[@]}"}" } if [ "${1:-}" != "--post-overlay" ]; then diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index 8adcc79f92..e70a6a48d3 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -60,7 +60,7 @@ use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use std::fs; use std::io::Read; -use std::net::Ipv4Addr; +use std::net::{IpAddr, Ipv4Addr}; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::path::{Component, Path, PathBuf}; @@ -132,7 +132,7 @@ impl VmSandboxDriverConfig { /// Code paths route via `GVPROXY_HOST_LOOPBACK_ALIAS` (DNS / /etc/hosts) /// instead so logs stay readable; this constant is kept for documentation /// and parity with the guest init script. -#[allow(dead_code)] +#[allow(dead_code)] // Documentation/parity anchor; all routing goes via the alias. const GVPROXY_HOST_LOOPBACK_IP: &str = "192.168.127.254"; const OPENSHELL_HOST_GATEWAY_ALIAS: &str = "host.openshell.internal"; /// Hostname gvproxy resolves (via its embedded DNS) to the host-loopback IP. @@ -162,6 +162,19 @@ const GUEST_INIT_DROPIN_DIR: &str = openshell_core::container_paths::VM_GUEST_IN /// upperdir on every launch, so the image cannot forge or shadow it. const GUEST_INIT_DROPIN_MANIFEST: &str = openshell_core::container_paths::VM_GUEST_INIT_DROPIN_MANIFEST; +/// Guest path of the root-only corporate proxy credential staged by the driver. +const GUEST_UPSTREAM_PROXY_AUTH_PATH: &str = + openshell_core::container_paths::VM_GUEST_UPSTREAM_PROXY_AUTH_PATH; +/// Guest path of the corporate proxy CA bundle staged by the driver. +const GUEST_PROXY_CA_PATH: &str = openshell_core::container_paths::VM_GUEST_PROXY_CA_PATH; +/// Guest path of the driver-authored supervisor argument list. +/// +/// The counterpart of [`GUEST_INIT_DROPIN_MANIFEST`] for the supervisor's own +/// command line: written into the overlay upperdir on every launch (empty +/// when there is nothing to pass) so the guest appends exactly the arguments +/// the driver chose and a sandbox image cannot forge or shadow them. +const GUEST_SUPERVISOR_ARGS_PATH: &str = + openshell_core::container_paths::VM_GUEST_SUPERVISOR_ARGS_PATH; const IMAGE_CACHE_ROOT_DIR: &str = "images"; const IMAGE_CACHE_ROOTFS_IMAGE: &str = "rootfs.ext4"; const OVERLAY_TEMPLATE_CACHE_DIR: &str = "overlay-templates"; @@ -217,7 +230,7 @@ enum GuestImagePayloadSource { LocalDocker { rootfs_archive: PathBuf }, } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Clone, serde::Serialize, serde::Deserialize)] pub struct VmDriverConfig { pub openshell_endpoint: String, pub state_dir: PathBuf, @@ -243,6 +256,104 @@ pub struct VmDriverConfig { /// When empty, defaults to the resolved UID. #[serde(default, skip_serializing_if = "Option::is_none")] pub sandbox_gid: Option, + + /// Corporate forward proxy URL (`http://host:port` or `https://host:port`) + /// passed to the in-guest supervisor. + /// + /// The supervisor chains policy-approved TLS tunnels through this proxy + /// with HTTP CONNECT instead of dialing destinations directly. This is an + /// operator-owned egress boundary: it travels on the supervisor's argv, + /// which sandbox spec/template environment and image `ENV` cannot + /// influence. A proxy on the gateway host's loopback is reachable from the + /// guest only through the gvproxy host alias + /// (`host.openshell.internal`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub https_proxy: Option, + + /// Comma-separated `NO_PROXY` list passed alongside the proxy URL. + /// + /// Matching destinations are dialed directly instead of through the + /// corporate proxy. This bypasses only the corporate proxy, never + /// `OpenShell` policy evaluation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub no_proxy: Option, + + /// Path (on the gateway host) to a file containing the corporate proxy + /// credential in `user:pass` form. + /// + /// The driver validates it at sandbox-create time and stages it into the + /// per-sandbox overlay at [`GUEST_UPSTREAM_PROXY_AUTH_PATH`], root-only. + /// Credentials are never embedded in the proxy URL and never reach the + /// guest environment. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proxy_auth_file: Option, + + /// Explicit acknowledgement that proxy credentials are sent in cleartext. + /// + /// `Proxy-Authorization: Basic` over the plain-TCP connection to an + /// `http://` proxy is recoverable by anyone on the network path, so + /// [`Self::proxy_auth_file`] requires this acknowledgement. An `https://` + /// proxy carries the credential inside the verified TLS session and does + /// not need it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proxy_auth_allow_insecure: Option, + + /// Send the destination hostname in CONNECT requests instead of a + /// validated IP. + /// + /// The default binds the tunnel to an address that passed the sandbox's + /// SSRF and `allowed_ips` validation. Set this only when the proxy's ACLs + /// filter on hostnames and reject IP CONNECT targets: the proxy then + /// resolves the name itself and its own ACLs become the effective egress + /// control for proxied TLS. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proxy_connect_by_hostname: Option, + + /// Path (on the gateway host) to a PEM CA bundle trusted for the + /// corporate proxy. + /// + /// The driver stages it into the per-sandbox overlay at + /// [`GUEST_PROXY_CA_PATH`] and passes that path via + /// `--upstream-proxy-ca-bundle`. It is trusted both for the handshake + /// with an `https://` proxy and for server certificates re-signed by a + /// TLS-intercepting proxy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proxy_ca_bundle: Option, +} + +/// Redacting `Debug` so a proxy URL or credential path never reaches a log. +/// +/// A validated proxy URL cannot embed credentials, but `Debug` can be emitted +/// before validation runs, so presence is logged rather than the value. +impl std::fmt::Debug for VmDriverConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VmDriverConfig") + .field("openshell_endpoint", &self.openshell_endpoint) + .field("state_dir", &self.state_dir) + .field("launcher_bin", &self.launcher_bin) + .field("default_image", &self.default_image) + .field("bootstrap_image", &self.bootstrap_image) + .field("log_level", &self.log_level) + .field("krun_log_level", &self.krun_log_level) + .field("vcpus", &self.vcpus) + .field("mem_mib", &self.mem_mib) + .field("overlay_disk_mib", &self.overlay_disk_mib) + .field("guest_tls_ca", &self.guest_tls_ca) + .field("guest_tls_cert", &self.guest_tls_cert) + .field("guest_tls_key", &self.guest_tls_key) + .field("gpu_enabled", &self.gpu_enabled) + .field("gpu_mem_mib", &self.gpu_mem_mib) + .field("gpu_vcpus", &self.gpu_vcpus) + .field("sandbox_uid", &self.sandbox_uid) + .field("sandbox_gid", &self.sandbox_gid) + .field("https_proxy", &self.https_proxy.is_some()) + .field("no_proxy", &self.no_proxy) + .field("proxy_auth_file", &self.proxy_auth_file.is_some()) + .field("proxy_auth_allow_insecure", &self.proxy_auth_allow_insecure) + .field("proxy_connect_by_hostname", &self.proxy_connect_by_hostname) + .field("proxy_ca_bundle", &self.proxy_ca_bundle) + .finish() + } } /// Default sandbox UID used by the VM driver when no config value is set. @@ -269,6 +380,12 @@ impl Default for VmDriverConfig { gpu_vcpus: 4, sandbox_uid: None, sandbox_gid: None, + https_proxy: None, + no_proxy: None, + proxy_auth_file: None, + proxy_auth_allow_insecure: None, + proxy_connect_by_hostname: None, + proxy_ca_bundle: None, } } } @@ -307,6 +424,29 @@ impl VmDriverConfig { Ok(()) } + /// Validate the operator's corporate upstream-proxy settings, fail-closed. + /// + /// Delegates to the validator shared with the Podman and Kubernetes + /// drivers and with the in-guest supervisor, so a value accepted here is + /// never rejected inside the guest — and no misconfiguration can silently + /// degrade to a direct dial. + /// + /// # Errors + /// + /// Returns a message naming the offending key. + pub fn validate_proxy_config(&self) -> Result<(), String> { + openshell_core::driver_utils::validate_upstream_proxy_settings( + &openshell_core::driver_utils::UpstreamProxySettings { + url: self.https_proxy.as_deref(), + no_proxy: self.no_proxy.as_deref(), + auth_file: self.proxy_auth_file.as_deref(), + auth_allow_insecure: self.proxy_auth_allow_insecure, + connect_by_hostname: self.proxy_connect_by_hostname, + ca_bundle: self.proxy_ca_bundle.as_deref(), + }, + ) + } + fn requires_tls_materials(&self) -> bool { self.openshell_endpoint.starts_with("https://") } @@ -447,6 +587,7 @@ impl VmDriver { .validate() .map_err(|err| err.message().to_string())?; config.validate_sandbox_identity()?; + config.validate_proxy_config()?; if config.openshell_endpoint.trim().is_empty() { return Err("openshell endpoint is required".to_string()); } @@ -908,6 +1049,16 @@ impl VmDriver { return Err(err); } + // Staged on every launch, including a restart onto a preserved + // overlay, so the driver's copy always shadows the image layer. + if let Err(err) = inject_guest_upstream_proxy(&overlay_disk, &self.config).await { + self.lifecycle_extensions + .after_launch_failed(&sandbox, &state_dir, LaunchAbortReason::GuestPrepareFailed) + .await; + self.release_gpu_and_subnet(&sandbox.id); + return Err(err); + } + let endpoint_override = if plan.backend == VmBackend::Qemu { plan.host_ip.as_deref().map(|host_ip| { guest_visible_openshell_endpoint_for_tap(&self.config.openshell_endpoint, host_ip) @@ -1694,26 +1845,44 @@ impl VmDriver { if plan.gpu_bdf.is_none() { plan.gpu_bdf = gpu_bdf; } - if has_complete_qemu_network(plan) { - return Ok(()); + if !has_complete_qemu_network(plan) { + let subnet = self + .subnet_allocator + .lock() + .map_err(|e| Status::internal(format!("subnet allocator lock poisoned: {e}")))? + .allocate(sandbox_id) + .map_err(Status::failed_precondition)?; + let mac = mac_from_sandbox_id(sandbox_id); + plan.tap_device = Some(tap_device_name(sandbox_id)); + plan.guest_ip = Some(subnet.guest_ip.to_string()); + plan.host_ip = Some(subnet.host_ip.to_string()); + plan.vsock_cid = Some(allocate_vsock_cid()); + plan.guest_mac = Some(format!( + "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5] + )); + plan.gateway_port = gateway_port_from_endpoint(&self.config.openshell_endpoint); + } + + // The corporate-proxy host-loopback recipe is a libkrun/gvproxy + // property and has no QEMU/TAP equivalent (see + // `proxy_url_targets_gateway_host`). Run it here, after the subnet + // allocation above has settled `plan.host_ip`, because the address to + // compare against is this sandbox's own TAP host address. Fail the + // create with the reason rather than boot a sandbox whose + // policy-approved CONNECTs all time out against an unreachable proxy. + if let Some(url) = self.config.https_proxy.as_deref() + && proxy_url_targets_gateway_host(url, plan.host_ip.as_deref()) + { + let tap_host = plan.host_ip.as_deref().unwrap_or("the TAP host address"); + return Err(Status::failed_precondition(format!( + "https_proxy '{url}' addresses the gateway host, which a QEMU/TAP sandbox \ + (GPU sandboxes) cannot reach: host.openshell.internal resolves to this \ + sandbox's TAP host address {tap_host} and the driver's nftables rules allow \ + only the gateway port from the guest. Configure a proxy address routable \ + from the guest's masqueraded egress, or run this sandbox without a GPU" + ))); } - - let subnet = self - .subnet_allocator - .lock() - .map_err(|e| Status::internal(format!("subnet allocator lock poisoned: {e}")))? - .allocate(sandbox_id) - .map_err(Status::failed_precondition)?; - let mac = mac_from_sandbox_id(sandbox_id); - plan.tap_device = Some(tap_device_name(sandbox_id)); - plan.guest_ip = Some(subnet.guest_ip.to_string()); - plan.host_ip = Some(subnet.host_ip.to_string()); - plan.vsock_cid = Some(allocate_vsock_cid()); - plan.guest_mac = Some(format!( - "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", - mac[0], mac[1], mac[2], mac[3], mac[4], mac[5] - )); - plan.gateway_port = gateway_port_from_endpoint(&self.config.openshell_endpoint); Ok(()) } @@ -4437,6 +4606,53 @@ fn guest_visible_openshell_endpoint(endpoint: &str) -> String { endpoint.to_string() } +/// Whether a corporate proxy URL points at the gateway host itself, as seen +/// from a QEMU/TAP guest whose TAP host address is `tap_host_ip`. +/// +/// On the libkrun backend gvproxy NATs the host-loopback alias +/// `host.openshell.internal` (and any loopback URL, which the driver rewrites +/// to that alias) to the gateway host's `127.0.0.1`, so a proxy bound to host +/// loopback is reachable from the guest. The QEMU/TAP backend used for GPU +/// sandboxes has no equivalent: `host.openshell.internal` resolves to the TAP +/// host address, and the driver's own nftables `input` chain accepts only the +/// gateway port from the guest and drops the rest, so no proxy on the gateway +/// host is reachable regardless of the address it binds. +/// +/// The gateway host is therefore reached from a QEMU guest under exactly three +/// spellings: the guest's own loopback (never the host's, but a configuration +/// that plainly means the host), the documented host aliases that +/// `write_host_gateway_aliases` seeds to the TAP host address, and that TAP +/// host address written literally. `tap_host_ip` is this sandbox's allocated +/// address, so the comparison must be made after the launch plan's subnet +/// allocation; `None` means the plan carries no TAP host and only the +/// address-independent spellings are classified. +/// +/// gvproxy's `GVPROXY_HOST_LOOPBACK_IP` is deliberately **not** matched here. +/// It is special only to libkrun; on QEMU/TAP it is an ordinary address that +/// may well be routable through the guest's masqueraded egress, and rejecting +/// it would refuse a working configuration. +/// +/// Used to reject an unreachable configuration up front on the QEMU path +/// instead of letting every policy-approved CONNECT time out. +fn proxy_url_targets_gateway_host(url: &str, tap_host_ip: Option<&str>) -> bool { + let Ok(parsed) = Url::parse(url) else { + // Unparseable URLs are rejected by shared validation before launch. + return false; + }; + let tap_host = tap_host_ip.and_then(|ip| ip.parse::().ok()); + match parsed.host() { + Some(Host::Ipv4(ip)) => ip.is_loopback() || tap_host == Some(IpAddr::V4(ip)), + Some(Host::Ipv6(ip)) => ip.is_loopback() || tap_host == Some(IpAddr::V6(ip)), + Some(Host::Domain(host)) => { + host.eq_ignore_ascii_case("localhost") + || host.eq_ignore_ascii_case(OPENSHELL_HOST_GATEWAY_ALIAS) + || host.eq_ignore_ascii_case("host.containers.internal") + || host.eq_ignore_ascii_case("host.docker.internal") + } + None => false, + } +} + fn gateway_port_from_endpoint(endpoint: &str) -> Option { Url::parse(endpoint).ok().and_then(|url| url.port()) } @@ -5130,6 +5346,182 @@ fn inject_guest_init_dropins( span_status.finish(Ok(())) } +/// Build the corporate upstream-proxy arguments passed to the guest supervisor. +/// +/// This operator-owned egress boundary travels on the supervisor's argv, +/// which sandbox spec/template environment and image `ENV` cannot influence. +/// Credentials are never on argv — only the root-only guest path is passed; +/// the supervisor reads the credential from that file. +fn upstream_proxy_cli_args(config: &VmDriverConfig) -> Vec { + let mut args = Vec::new(); + if let Some(url) = &config.https_proxy { + args.push("--upstream-proxy".to_string()); + args.push(url.clone()); + } + if let Some(list) = &config.no_proxy { + args.push("--upstream-no-proxy".to_string()); + args.push(list.clone()); + } + if config.proxy_auth_file.is_some() { + args.push("--upstream-proxy-auth-file".to_string()); + // The guest path, never the gateway-host path the operator configured. + args.push(GUEST_UPSTREAM_PROXY_AUTH_PATH.to_string()); + } + // Config validation guarantees the acknowledgement is `true` whenever an + // auth file is configured against an http:// proxy; the supervisor + // independently refuses credentials without it. + if config.proxy_auth_allow_insecure == Some(true) { + args.push("--upstream-proxy-auth-allow-insecure".to_string()); + } + // Absent means the default validated-IP CONNECT binding; only the + // explicit hostname opt-in is passed through. + if config.proxy_connect_by_hostname == Some(true) { + args.push("--upstream-proxy-connect-by-hostname".to_string()); + } + if config.proxy_ca_bundle.is_some() { + args.push("--upstream-proxy-ca-bundle".to_string()); + args.push(GUEST_PROXY_CA_PATH.to_string()); + } + args +} + +/// Render the supervisor argument list as newline-separated arguments. +/// +/// One argument per line, verbatim: the guest reads the lines into an array +/// without word splitting or globbing, so values containing spaces survive +/// intact. An empty list renders an empty file, which the guest reads as "no +/// extra arguments". +fn render_guest_supervisor_args(args: &[String]) -> Vec { + let mut body = args.join("\n"); + if !body.is_empty() { + body.push('\n'); + } + body.into_bytes() +} + +/// Reject argument values the newline-delimited guest file cannot represent. +/// +/// Every value here is operator-supplied config, so this is a guard against +/// misconfiguration rather than an attack: a stray newline would otherwise +/// split one value into two arguments in the guest. +fn validate_guest_supervisor_args(args: &[String]) -> Result<(), String> { + for arg in args { + if arg.contains('\n') || arg.contains('\r') || arg.contains('\0') { + return Err( + "corporate proxy settings must not contain newline or NUL characters".to_string(), + ); + } + } + Ok(()) +} + +/// Read and validate the corporate proxy credential from the gateway host. +/// +/// Uses the validators shared with the supervisor, so a credential accepted +/// here is never rejected inside the guest. The error never carries the file +/// contents. +async fn read_sandbox_proxy_credential(path: &str) -> Result { + let path_owned = path.to_string(); + let raw = tokio::task::spawn_blocking(move || { + openshell_core::driver_utils::read_upstream_proxy_credential_file(&path_owned) + }) + .await + .map_err(|err| Status::internal(format!("proxy_auth_file read task failed: {err}")))? + .map_err(Status::invalid_argument)?; + let credential = openshell_core::driver_utils::parse_upstream_proxy_credential(&raw) + .map_err(|err| Status::invalid_argument(format!("proxy_auth_file '{path}': {err}")))?; + Ok(credential.to_string()) +} + +/// Read and validate the corporate proxy CA bundle from the gateway host. +/// +/// Uses the reader shared with the supervisor, so the bundle is bounded and +/// non-regular files are rejected (an operator path such as `/dev/zero` can +/// otherwise exhaust driver memory), and a bundle accepted here contributes at +/// least one trust anchor rustls accepts rather than merely looking like PEM. +/// Checked here rather than only in the guest so the operator gets an error +/// attributable to `proxy_ca_bundle` instead of an opaque supervisor startup +/// failure inside every sandbox. The error never carries the file contents. +async fn read_sandbox_proxy_ca_bundle(path: &str) -> Result, Status> { + let path_owned = path.to_string(); + let pem = tokio::task::spawn_blocking(move || { + openshell_core::driver_utils::read_upstream_proxy_ca_bundle_file( + &path_owned, + "proxy_ca_bundle", + ) + }) + .await + .map_err(|err| Status::internal(format!("proxy_ca_bundle read task failed: {err}")))? + .map_err(Status::invalid_argument)?; + Ok(pem.into_bytes()) +} + +/// Stage the corporate upstream-proxy configuration into the guest overlay. +/// +/// Writes three files into the overlay upperdir the driver owns: +/// +/// * the credential at [`GUEST_UPSTREAM_PROXY_AUTH_PATH`], mode `0600`; +/// * the CA bundle at [`GUEST_PROXY_CA_PATH`], mode `0644` (a CA certificate +/// is not secret); +/// * the supervisor argument list at [`GUEST_SUPERVISOR_ARGS_PATH`], mode +/// `0644`. +/// +/// All three are written on every launch, empty when the corresponding +/// setting is absent. Writing rather than skipping is what makes the channel +/// unforgeable: the upperdir copy always shadows the read-only image layer, so +/// a sandbox image cannot supply its own arguments or credential by baking a +/// file at these paths, and cannot disable the operator's by omitting one. It +/// also clears material a previous launch staged into a preserved overlay +/// after the operator removed the setting. +/// +/// A microVM has no bind mounts or container secrets, so the credential lives +/// at rest inside the per-sandbox overlay disk on the host — the same +/// delivery the per-sandbox gateway JWT already uses. It is removed with the +/// sandbox when the state directory is deleted. +#[allow(clippy::result_large_err)] +async fn inject_guest_upstream_proxy( + overlay_disk: &Path, + config: &VmDriverConfig, +) -> Result<(), Status> { + // Written whether or not they are configured. Writing empty files when + // the operator removed a setting clears material a previous launch staged + // into a preserved overlay, and shadows anything an image baked at these + // paths, so a staged file is only ever the one this launch produced. + let credential = match config.proxy_auth_file.as_deref() { + Some(path) => format!("{}\n", read_sandbox_proxy_credential(path).await?).into_bytes(), + None => Vec::new(), + }; + let credential_path = overlay_upper_path(GUEST_UPSTREAM_PROXY_AUTH_PATH); + write_rootfs_image_file(overlay_disk, &credential_path, &credential) + .map_err(|err| Status::internal(format!("write VM guest proxy credential: {err}")))?; + set_rootfs_image_file_mode(overlay_disk, &credential_path, 0o600) + .map_err(|err| Status::internal(format!("set VM guest proxy credential mode: {err}")))?; + + let ca_bundle = match config.proxy_ca_bundle.as_deref() { + Some(path) => read_sandbox_proxy_ca_bundle(path).await?, + None => Vec::new(), + }; + let ca_path = overlay_upper_path(GUEST_PROXY_CA_PATH); + write_rootfs_image_file(overlay_disk, &ca_path, &ca_bundle) + .map_err(|err| Status::internal(format!("write VM guest proxy CA bundle: {err}")))?; + set_rootfs_image_file_mode(overlay_disk, &ca_path, 0o644) + .map_err(|err| Status::internal(format!("set VM guest proxy CA bundle mode: {err}")))?; + + let args = upstream_proxy_cli_args(config); + validate_guest_supervisor_args(&args).map_err(Status::failed_precondition)?; + let guest_path = overlay_upper_path(GUEST_SUPERVISOR_ARGS_PATH); + write_rootfs_image_file( + overlay_disk, + &guest_path, + &render_guest_supervisor_args(&args), + ) + .map_err(|err| Status::internal(format!("write VM guest supervisor arguments: {err}")))?; + set_rootfs_image_file_mode(overlay_disk, &guest_path, 0o644).map_err(|err| { + Status::internal(format!("set VM guest supervisor arguments mode: {err}")) + })?; + Ok(()) +} + /// Render the drop-in allow-list as newline-separated, ASCII-sorted, /// de-duplicated names. Names are already validated to be path-safe by /// [`validate_guest_init_dropins`]. @@ -8074,6 +8466,12 @@ mod tests { } } + fn test_driver_with_proxy(https_proxy: &str) -> VmDriver { + let mut driver = test_driver_with_extensions(LifecycleExtensionRegistry::new()); + driver.config.https_proxy = Some(https_proxy.to_string()); + driver + } + #[derive(Debug)] struct QemuRequiringExtension { name: String, @@ -8403,4 +8801,466 @@ mod tests { assert!(err.is_resource_exhausted()); assert_eq!(err.message(), "pool empty"); } + + /// A driver config carrying only corporate proxy settings. + fn proxy_config( + https_proxy: Option<&str>, + auth_file: Option<&str>, + ca_bundle: Option<&str>, + ) -> VmDriverConfig { + VmDriverConfig { + openshell_endpoint: "http://127.0.0.1:8080".to_string(), + https_proxy: https_proxy.map(ToString::to_string), + proxy_auth_file: auth_file.map(ToString::to_string), + proxy_auth_allow_insecure: auth_file.map(|_| true), + proxy_ca_bundle: ca_bundle.map(ToString::to_string), + ..Default::default() + } + } + + #[test] + fn driver_config_debug_redacts_the_proxy_url_and_credential_path() { + // `Debug` can be emitted before validation runs, and an unvalidated + // proxy URL may still carry inline `user:pass@` credentials. + let rendered = format!( + "{:?}", + proxy_config( + Some("http://user:secret@proxy.corp.test:3128"), + Some("/etc/openshell/secrets/proxy-auth"), + Some("/etc/openshell/tls/corp-ca.pem"), + ) + ); + assert!( + !rendered.contains("secret") && !rendered.contains("proxy.corp.test"), + "the proxy URL must be logged as presence only: {rendered}" + ); + assert!( + !rendered.contains("/etc/openshell/secrets/proxy-auth"), + "the credential path must be logged as presence only: {rendered}" + ); + assert!( + rendered.contains("https_proxy: true") && rendered.contains("proxy_auth_file: true"), + "presence of each must still be visible for debugging: {rendered}" + ); + // A CA path is not sensitive and stays readable. + assert!( + rendered.contains("corp-ca.pem"), + "the CA bundle path is not a secret and should stay legible: {rendered}" + ); + } + + #[test] + fn proxy_material_is_staged_inside_the_per_sandbox_overlay() { + // Everything the driver stages lands in the overlay upperdir, which + // lives in the sandbox's own state directory. That is what makes the + // credential removable with the sandbox (remove_sandbox_state_dir + // deletes the whole directory) and unforgeable by the guest image + // (the upperdir shadows the read-only image layer). + for guest_path in [ + GUEST_UPSTREAM_PROXY_AUTH_PATH, + GUEST_PROXY_CA_PATH, + GUEST_SUPERVISOR_ARGS_PATH, + ] { + assert!( + guest_path.starts_with("/opt/openshell/"), + "{guest_path} must be under the reserved guest control root" + ); + assert_eq!( + overlay_upper_path(guest_path), + format!("/upper{guest_path}"), + "{guest_path} must be staged into the overlay upperdir" + ); + } + } + + #[test] + fn upstream_proxy_args_are_empty_without_a_configured_proxy() { + assert!(upstream_proxy_cli_args(&VmDriverConfig::default()).is_empty()); + // The file is still written, empty, so the guest cannot fall back to + // an image-baked argument list. + assert!(render_guest_supervisor_args(&[]).is_empty()); + } + + #[test] + fn upstream_proxy_args_pass_guest_paths_not_host_paths() { + let config = proxy_config( + Some("http://proxy.corp.test:3128"), + Some("/etc/openshell/secrets/proxy-auth"), + Some("/etc/openshell/tls/corp-ca.pem"), + ); + let args = upstream_proxy_cli_args(&config); + + // The credential and CA live at fixed guest paths; the gateway-host + // paths the operator configured must never reach the guest argv. + let auth = args + .iter() + .position(|arg| arg == "--upstream-proxy-auth-file") + .map(|i| args[i + 1].as_str()); + assert_eq!(auth, Some(GUEST_UPSTREAM_PROXY_AUTH_PATH)); + let ca = args + .iter() + .position(|arg| arg == "--upstream-proxy-ca-bundle") + .map(|i| args[i + 1].as_str()); + assert_eq!(ca, Some(GUEST_PROXY_CA_PATH)); + assert!( + !args + .iter() + .any(|arg| arg.contains("/etc/openshell/secrets") || arg.contains("corp-ca.pem")), + "host paths leaked into the guest argv: {args:?}" + ); + } + + #[test] + fn upstream_proxy_args_pass_only_explicit_opt_ins() { + let mut config = proxy_config(Some("https://proxy.corp.test:3130"), None, None); + config.no_proxy = Some("10.0.0.0/8,.svc.cluster.local".to_string()); + let args = upstream_proxy_cli_args(&config); + assert_eq!( + args, + vec![ + "--upstream-proxy".to_string(), + "https://proxy.corp.test:3130".to_string(), + "--upstream-no-proxy".to_string(), + "10.0.0.0/8,.svc.cluster.local".to_string(), + ] + ); + + // `Some(false)` must not be passed as the presence flag it is on the + // supervisor side. + config.proxy_connect_by_hostname = Some(false); + assert!( + !upstream_proxy_cli_args(&config) + .iter() + .any(|arg| arg == "--upstream-proxy-connect-by-hostname") + ); + config.proxy_connect_by_hostname = Some(true); + assert!( + upstream_proxy_cli_args(&config) + .iter() + .any(|arg| arg == "--upstream-proxy-connect-by-hostname") + ); + } + + #[test] + fn guest_supervisor_args_render_one_argument_per_line() { + let args = vec![ + "--upstream-proxy".to_string(), + "http://proxy.corp.test:3128".to_string(), + "--upstream-no-proxy".to_string(), + "a.example, b.example".to_string(), + ]; + // A value containing a space stays one line, so the guest reads it + // back as a single argument rather than word-splitting it. + assert_eq!( + String::from_utf8(render_guest_supervisor_args(&args)).unwrap(), + "--upstream-proxy\nhttp://proxy.corp.test:3128\n--upstream-no-proxy\na.example, b.example\n" + ); + } + + #[test] + fn guest_supervisor_args_reject_line_breaking_values() { + // A newline would split one operator value into two guest arguments. + for bad in ["a\nb", "a\rb", "a\0b"] { + assert!( + validate_guest_supervisor_args(&[bad.to_string()]).is_err(), + "{bad:?} must be rejected" + ); + } + validate_guest_supervisor_args(&["--upstream-proxy".to_string()]) + .expect("ordinary arguments are accepted"); + } + + #[test] + fn proxy_config_validation_rejects_settings_without_a_proxy_url() { + let config = VmDriverConfig { + no_proxy: Some("10.0.0.0/8".to_string()), + ..Default::default() + }; + let err = config + .validate_proxy_config() + .expect_err("a bypass list without a proxy would hide a fail-open state"); + assert!(err.contains("no_proxy"), "{err}"); + + let config = proxy_config(Some("http://proxy.corp.test:3128"), None, None); + config + .validate_proxy_config() + .expect("a lone proxy URL is a complete configuration"); + } + + #[test] + fn proxy_config_validation_requires_the_cleartext_acknowledgement() { + let mut config = proxy_config( + Some("http://proxy.corp.test:3128"), + Some("/etc/openshell/secrets/proxy-auth"), + None, + ); + config.proxy_auth_allow_insecure = None; + let err = config + .validate_proxy_config() + .expect_err("Basic auth to an http:// proxy is cleartext on the wire"); + assert!(err.contains("proxy_auth_allow_insecure"), "{err}"); + } + + #[tokio::test] + async fn proxy_ca_bundle_without_a_certificate_fails_the_sandbox() { + let dir = std::env::temp_dir().join(format!("openshell-vm-ca-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("not-a-ca.pem"); + std::fs::write(&path, b"this is not a certificate\n").unwrap(); + + let err = read_sandbox_proxy_ca_bundle(path.to_str().unwrap()) + .await + .expect_err("a certificate-free bundle must fail closed"); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("no PEM certificate"), "{err}"); + + std::fs::write(&path, b"").unwrap(); + let err = read_sandbox_proxy_ca_bundle(path.to_str().unwrap()) + .await + .expect_err("an empty bundle must fail closed"); + assert!(err.message().contains("no PEM certificate"), "{err}"); + + // PEM framing that base64-decodes but is not X.509 DER: accepted by + // `rustls_pemfile` alone, contributes zero trust anchors at runtime, + // and so would make every guest supervisor fail after boot. + std::fs::write( + &path, + b"-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----\n", + ) + .unwrap(); + let err = read_sandbox_proxy_ca_bundle(path.to_str().unwrap()) + .await + .expect_err("a bundle with invalid DER must fail closed"); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("no usable trust anchors"), "{err}"); + + let err = read_sandbox_proxy_ca_bundle(dir.join("missing.pem").to_str().unwrap()) + .await + .expect_err("an unreadable bundle must fail closed"); + assert!(err.message().contains("could not be read"), "{err}"); + + // A special file must be rejected on its type, not read: an + // unbounded read of /dev/zero would exhaust driver memory. + #[cfg(unix)] + { + let err = read_sandbox_proxy_ca_bundle("/dev/zero") + .await + .expect_err("a non-regular bundle path must fail closed"); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("not a regular file"), "{err}"); + } + + // Oversized regular file: rejected on the stat'd length, again + // without reading it whole. + let oversized = dir.join("oversized.pem"); + let bound = openshell_core::driver_utils::MAX_UPSTREAM_PROXY_CA_BUNDLE_BYTES; + std::fs::write(&oversized, vec![b'x'; usize::try_from(bound).unwrap() + 1]).unwrap(); + let err = read_sandbox_proxy_ca_bundle(oversized.to_str().unwrap()) + .await + .expect_err("an oversized bundle must fail closed"); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("exceeds"), "{err}"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn qemu_backend_rejects_a_gateway_host_proxy() { + // gvproxy's host-loopback NAT has no QEMU/TAP equivalent, so a proxy + // on the gateway host is unreachable from a GPU sandbox and must be + // rejected rather than time out on every CONNECT. The address that + // reaches the gateway host from a QEMU guest is this sandbox's own + // TAP host address, so the classifier is parameterized by it. + let tap_host = Some("10.0.128.1"); + for url in [ + "http://host.openshell.internal:8080", + "http://host.containers.internal:8080", + "http://host.docker.internal:8080", + "http://127.0.0.1:8080", + "http://localhost:8080", + "https://[::1]:8080", + // The address the aliases above resolve to inside the guest. + "http://10.0.128.1:8080", + ] { + assert!(proxy_url_targets_gateway_host(url, tap_host), "{url}"); + } + for url in [ + "http://proxy.corp.example:8080", + "https://10.1.2.3:3128", + // Special only to libkrun/gvproxy. On QEMU/TAP it is an ordinary + // address that may be routable through the guest's masqueraded + // egress, so rejecting it would refuse a working configuration. + "http://192.168.127.254:8080", + // Another sandbox's TAP host, not this one's. + "http://10.0.128.5:8080", + "not a url", + ] { + assert!(!proxy_url_targets_gateway_host(url, tap_host), "{url}"); + } + + // Without an allocated TAP host only the address-independent + // spellings classify; the loopback and alias guards still hold. + assert!(proxy_url_targets_gateway_host( + "http://127.0.0.1:8080", + None + )); + assert!(proxy_url_targets_gateway_host( + "http://host.openshell.internal:8080", + None + )); + assert!(!proxy_url_targets_gateway_host( + "http://10.0.128.1:8080", + None + )); + } + + #[test] + fn qemu_launch_plan_rejects_a_proxy_at_the_allocated_tap_host() { + // The preflight has to run against the address this sandbox actually + // got, which only exists once the launch plan's subnet is allocated. + // A proxy there is what `host.openshell.internal` resolves to in the + // guest, and the driver's own nftables input chain drops the port. + let probe = test_driver_with_extensions(LifecycleExtensionRegistry::new()); + let tap_host = probe + .build_vm_launch_plan("sandbox-proxy-tap", true, true, None) + .expect("gpu plan should build") + .host_ip + .expect("a QEMU plan carries a TAP host address"); + probe.release_subnet("sandbox-proxy-tap"); + + let driver = test_driver_with_proxy(&format!("http://{tap_host}:8080")); + let mut plan = driver + .build_vm_launch_plan("sandbox-proxy-tap", true, true, None) + .expect("gpu plan should build"); + assert_eq!(plan.host_ip.as_deref(), Some(tap_host.as_str())); + + let err = driver + .resolve_launch_plan_backend("sandbox-proxy-tap", true, None, &mut plan) + .expect_err("a proxy at the TAP host address is unreachable from the guest"); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(err.message().contains(&tap_host), "{err}"); + + driver.release_subnet("sandbox-proxy-tap"); + } + + #[test] + fn qemu_launch_plan_allows_a_proxy_at_the_gvproxy_host_loopback_address() { + // 192.168.127.254 carries no meaning on QEMU/TAP, so a launch must + // proceed rather than be refused for a libkrun-only reason. + let driver = test_driver_with_proxy(&format!("http://{GVPROXY_HOST_LOOPBACK_IP}:8080")); + let mut plan = driver + .build_vm_launch_plan("sandbox-proxy-gvproxy", true, true, None) + .expect("gpu plan should build"); + assert_ne!(plan.host_ip.as_deref(), Some(GVPROXY_HOST_LOOPBACK_IP)); + + driver + .resolve_launch_plan_backend("sandbox-proxy-gvproxy", true, None, &mut plan) + .expect("a routable proxy address must not block a GPU launch"); + assert_eq!(plan.backend, VmBackend::Qemu); + + driver.release_subnet("sandbox-proxy-gvproxy"); + } + + #[tokio::test] + async fn proxy_credential_is_validated_against_the_supervisor_rules() { + let dir = std::env::temp_dir().join(format!("openshell-vm-cred-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("proxy-auth"); + + std::fs::write(&path, "proxyuser:proxypass\n").unwrap(); + assert_eq!( + read_sandbox_proxy_credential(path.to_str().unwrap()) + .await + .expect("a well-formed credential is accepted"), + "proxyuser:proxypass" + ); + + // Rejected here rather than inside every sandbox's supervisor. + std::fs::write(&path, "no-separator\n").unwrap(); + let err = read_sandbox_proxy_credential(path.to_str().unwrap()) + .await + .expect_err("a malformed credential must fail closed"); + assert_eq!(err.code(), Code::InvalidArgument); + assert!( + !err.message().contains("no-separator"), + "the error must not echo credential file contents: {err}" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn guest_environment_carries_no_corporate_proxy_settings() { + // The egress boundary is argv-only: `build_guest_environment` merges + // user-supplied environment, so anything it emitted here would be + // attacker-influenced. + let config = proxy_config( + Some("http://proxy.corp.test:3128"), + Some("/etc/openshell/secrets/proxy-auth"), + Some("/etc/openshell/tls/corp-ca.pem"), + ); + let sandbox = Sandbox { + id: "sb-proxy".to_string(), + name: "proxy".to_string(), + spec: Some(SandboxSpec { + environment: [ + ( + "HTTPS_PROXY".to_string(), + "http://attacker:3128".to_string(), + ), + ("NO_PROXY".to_string(), "*".to_string()), + ] + .into_iter() + .collect(), + ..Default::default() + }), + ..Default::default() + }; + + let env = build_guest_environment(&sandbox, &config, None); + assert!( + !env.iter().any(|entry| entry.starts_with("--upstream")), + "driver environment must never carry supervisor arguments: {env:?}" + ); + // A sandbox may still set the conventional variables for its own + // workload, but the supervisor ignores them on this path -- what + // matters is that the driver never derives the boundary from them. + assert!( + !env.iter() + .any(|entry| entry.contains("proxy.corp.test") || entry.contains("proxy-auth")), + "operator proxy settings must not reach the guest environment: {env:?}" + ); + } + + #[test] + fn sandbox_driver_config_cannot_carry_proxy_settings() { + // The upstream proxy is host network topology, not a per-sandbox + // setting: the caller-supplied envelope must reject it outright + // rather than silently ignoring it. + for key in [ + "https_proxy", + "no_proxy", + "proxy_auth_file", + "proxy_auth_allow_insecure", + "proxy_connect_by_hostname", + "proxy_ca_bundle", + ] { + let template = SandboxTemplate { + driver_config: Some(Struct { + fields: std::iter::once(( + key.to_string(), + Value { + kind: Some(Kind::StringValue("http://attacker:3128".to_string())), + }, + )) + .collect(), + }), + ..Default::default() + }; + assert!( + VmSandboxDriverConfig::from_template(&template).is_err(), + "template.driver_config.vm must reject '{key}'" + ); + } + } } diff --git a/crates/openshell-driver-vm/src/main.rs b/crates/openshell-driver-vm/src/main.rs index 95ebf0f8b2..2546cb2606 100644 --- a/crates/openshell-driver-vm/src/main.rs +++ b/crates/openshell-driver-vm/src/main.rs @@ -146,6 +146,30 @@ struct Args { #[arg(long, env = "OPENSHELL_VM_SANDBOX_GID")] sandbox_gid: Option, + // Corporate forward proxy for sandbox egress. Operator-owned: these reach + // the guest supervisor on its argv, which the sandbox image and the + // user-supplied environment cannot influence. + #[arg(long, env = "OPENSHELL_VM_HTTPS_PROXY")] + https_proxy: Option, + + #[arg(long, env = "OPENSHELL_VM_NO_PROXY")] + no_proxy: Option, + + #[arg(long, env = "OPENSHELL_VM_PROXY_AUTH_FILE")] + proxy_auth_file: Option, + + // Value-taking rather than a presence flag so an explicit `false` in + // `[openshell.drivers.vm]` survives the gateway -> driver hop and still + // trips the "acknowledgement without a credential" check. + #[arg(long, env = "OPENSHELL_VM_PROXY_AUTH_ALLOW_INSECURE")] + proxy_auth_allow_insecure: Option, + + #[arg(long, env = "OPENSHELL_VM_PROXY_CONNECT_BY_HOSTNAME")] + proxy_connect_by_hostname: Option, + + #[arg(long, env = "OPENSHELL_VM_PROXY_CA_BUNDLE")] + proxy_ca_bundle: Option, + #[arg(long, hide = true)] vm_backend: Option, @@ -243,6 +267,12 @@ async fn main() -> Result<()> { gpu_vcpus: args.gpu_vcpus, sandbox_uid: args.sandbox_uid, sandbox_gid: args.sandbox_gid, + https_proxy: args.https_proxy.clone(), + no_proxy: args.no_proxy.clone(), + proxy_auth_file: args.proxy_auth_file.clone(), + proxy_auth_allow_insecure: args.proxy_auth_allow_insecure, + proxy_connect_by_hostname: args.proxy_connect_by_hostname, + proxy_ca_bundle: args.proxy_ca_bundle.clone(), }) .await .map_err(|err| miette::miette!("{err}"))?; @@ -620,6 +650,63 @@ mod tests { use clap::Parser; use std::path::PathBuf; + #[test] + fn corporate_proxy_flags_parse_into_driver_settings() { + let args = Args::parse_from([ + "openshell-driver-vm", + "--openshell-endpoint", + "https://host.openshell.internal:17670", + "--https-proxy", + "http://proxy.corp.com:8080", + "--no-proxy", + "10.0.0.0/8,.svc.cluster.local", + "--proxy-auth-file", + "/etc/openshell/secrets/proxy-auth", + "--proxy-auth-allow-insecure", + "true", + "--proxy-connect-by-hostname", + "false", + "--proxy-ca-bundle", + "/etc/openshell/tls/proxy-ca.pem", + ]); + + assert_eq!( + args.https_proxy.as_deref(), + Some("http://proxy.corp.com:8080") + ); + assert_eq!( + args.no_proxy.as_deref(), + Some("10.0.0.0/8,.svc.cluster.local") + ); + assert_eq!( + args.proxy_auth_file.as_deref(), + Some("/etc/openshell/secrets/proxy-auth") + ); + assert_eq!(args.proxy_auth_allow_insecure, Some(true)); + // Value-taking rather than a presence flag, so the gateway can + // forward an explicit `false` from `[openshell.drivers.vm]`. + assert_eq!(args.proxy_connect_by_hostname, Some(false)); + assert_eq!( + args.proxy_ca_bundle.as_deref(), + Some("/etc/openshell/tls/proxy-ca.pem") + ); + } + + #[test] + fn corporate_proxy_settings_default_to_unset() { + let args = Args::parse_from([ + "openshell-driver-vm", + "--openshell-endpoint", + "https://host.openshell.internal:17670", + ]); + assert!(args.https_proxy.is_none()); + assert!(args.no_proxy.is_none()); + assert!(args.proxy_auth_file.is_none()); + assert!(args.proxy_auth_allow_insecure.is_none()); + assert!(args.proxy_connect_by_hostname.is_none()); + assert!(args.proxy_ca_bundle.is_none()); + } + #[test] fn peer_authorization_accepts_matching_uid_and_pid() { authorize_peer_credentials( diff --git a/crates/openshell-gateway/src/vm.rs b/crates/openshell-gateway/src/vm.rs index e86de28c12..f52bd9ddfa 100644 --- a/crates/openshell-gateway/src/vm.rs +++ b/crates/openshell-gateway/src/vm.rs @@ -99,6 +99,34 @@ pub struct VmComputeConfig { /// Host-side private key for the guest's mTLS client bundle. pub guest_tls_key: Option, + + /// Corporate forward proxy URL (`http://host:port` or `https://host:port`) + /// for policy-approved TLS egress from VM sandboxes. + /// + /// Deployment-level configuration, not a per-sandbox setting: it is passed + /// to the driver, which puts it on the guest supervisor's argv. A proxy on + /// this host's loopback is reachable from a guest only through the gvproxy + /// host alias `host.openshell.internal`. + pub https_proxy: Option, + + /// Comma-separated `NO_PROXY` list. Bypasses only the corporate proxy, + /// never `OpenShell` policy evaluation. + pub no_proxy: Option, + + /// Path on this host to a `user:pass` corporate proxy credential file. + pub proxy_auth_file: Option, + + /// Acknowledgement that Basic auth to an `http://` proxy is cleartext. + /// Required alongside `proxy_auth_file` unless the proxy is `https://`. + pub proxy_auth_allow_insecure: Option, + + /// Send hostnames rather than validated IPs in CONNECT requests. Last + /// resort for proxies whose ACLs reject IP CONNECT targets. + pub proxy_connect_by_hostname: Option, + + /// Path on this host to a PEM CA bundle trusted for the corporate proxy + /// and for server certificates a TLS-intercepting proxy re-signs. + pub proxy_ca_bundle: Option, } impl VmComputeConfig { @@ -135,6 +163,29 @@ impl VmComputeConfig { 4096 } + /// Validate the corporate upstream-proxy settings, fail-closed. + /// + /// Runs in the gateway as well as in the driver so an invalid + /// `[openshell.drivers.vm]` table reports the offending key instead of + /// surfacing as an opaque driver-startup timeout. + /// + /// # Errors + /// + /// Returns a [`Error::config`] naming the offending key. + pub fn validate_proxy_config(&self) -> Result<()> { + openshell_core::driver_utils::validate_upstream_proxy_settings( + &openshell_core::driver_utils::UpstreamProxySettings { + url: self.https_proxy.as_deref(), + no_proxy: self.no_proxy.as_deref(), + auth_file: self.proxy_auth_file.as_deref(), + auth_allow_insecure: self.proxy_auth_allow_insecure, + connect_by_hostname: self.proxy_connect_by_hostname, + ca_bundle: self.proxy_ca_bundle.as_deref(), + }, + ) + .map_err(Error::config) + } + #[must_use] fn default_driver_search_dirs(home: Option) -> Vec { let mut dirs = Vec::new(); @@ -163,6 +214,12 @@ impl Default for VmComputeConfig { guest_tls_ca: None, guest_tls_cert: None, guest_tls_key: None, + https_proxy: None, + no_proxy: None, + proxy_auth_file: None, + proxy_auth_allow_insecure: None, + proxy_connect_by_hostname: None, + proxy_ca_bundle: None, } } } @@ -460,6 +517,8 @@ pub async fn spawn( )); } + vm_config.validate_proxy_config()?; + let driver_bin = resolve_compute_driver_bin(vm_config)?; let socket_path = compute_driver_socket_path(vm_config); let guest_tls_paths = compute_driver_guest_tls_paths(vm_config)?; @@ -501,6 +560,7 @@ pub async fn spawn( command.arg("--guest-tls-cert").arg(tls.cert); command.arg("--guest-tls-key").arg(tls.key); } + append_upstream_proxy_args(&mut command, vm_config); let mut child = command.spawn().map_err(|e| { Error::execution(format!( @@ -515,6 +575,38 @@ pub async fn spawn( )) } +/// Forward the operator's corporate proxy settings to the driver subprocess. +/// +/// Only keys the operator actually set are passed, so the driver keeps the +/// same "omitted means no proxy" contract the supervisor enforces. The +/// booleans travel as explicit values rather than presence flags so an +/// explicit `false` still trips the driver's pairing checks. +#[cfg(unix)] +fn append_upstream_proxy_args(command: &mut Command, vm_config: &VmComputeConfig) { + if let Some(url) = &vm_config.https_proxy { + command.arg("--https-proxy").arg(url); + } + if let Some(list) = &vm_config.no_proxy { + command.arg("--no-proxy").arg(list); + } + if let Some(path) = &vm_config.proxy_auth_file { + command.arg("--proxy-auth-file").arg(path); + } + if let Some(allow) = vm_config.proxy_auth_allow_insecure { + command + .arg("--proxy-auth-allow-insecure") + .arg(allow.to_string()); + } + if let Some(by_hostname) = vm_config.proxy_connect_by_hostname { + command + .arg("--proxy-connect-by-hostname") + .arg(by_hostname.to_string()); + } + if let Some(path) = &vm_config.proxy_ca_bundle { + command.arg("--proxy-ca-bundle").arg(path); + } +} + #[cfg(unix)] fn append_otlp_args(command: &mut Command, otlp_config: Option<&OtlpConfig>, gateway_name: &str) { if let Some(config) = otlp_config { @@ -606,9 +698,10 @@ async fn connect_compute_driver(socket_path: &Path) -> Result { #[cfg(all(test, unix))] mod tests { use super::{ - VmComputeConfig, append_otlp_args, compute_driver_guest_tls_paths, - compute_driver_socket_path, current_euid, prepare_compute_driver_socket_path, - prepare_vm_state_dir, resolve_compute_driver_bin, resolve_driver_search_dirs, + VmComputeConfig, append_otlp_args, append_upstream_proxy_args, + compute_driver_guest_tls_paths, compute_driver_socket_path, current_euid, + prepare_compute_driver_socket_path, prepare_vm_state_dir, resolve_compute_driver_bin, + resolve_driver_search_dirs, }; use openshell_server::config_file::OtlpConfig; use std::os::unix::fs::PermissionsExt; @@ -644,6 +737,83 @@ mod tests { ); } + #[test] + fn vm_driver_command_forwards_corporate_proxy_settings() { + let mut command = tokio::process::Command::new("openshell-driver-vm"); + append_upstream_proxy_args( + &mut command, + &VmComputeConfig { + https_proxy: Some("http://proxy.corp.com:8080".to_string()), + no_proxy: Some("10.0.0.0/8".to_string()), + proxy_auth_file: Some("/etc/openshell/secrets/proxy-auth".to_string()), + proxy_auth_allow_insecure: Some(true), + proxy_connect_by_hostname: Some(false), + proxy_ca_bundle: Some("/etc/openshell/tls/proxy-ca.pem".to_string()), + ..VmComputeConfig::default() + }, + ); + + let args = command + .as_std() + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + assert_eq!( + args, + [ + "--https-proxy", + "http://proxy.corp.com:8080", + "--no-proxy", + "10.0.0.0/8", + "--proxy-auth-file", + "/etc/openshell/secrets/proxy-auth", + "--proxy-auth-allow-insecure", + "true", + // Passed as an explicit value, not a presence flag, so the + // driver still sees the operator's `false`. + "--proxy-connect-by-hostname", + "false", + "--proxy-ca-bundle", + "/etc/openshell/tls/proxy-ca.pem", + ] + ); + } + + #[test] + fn vm_driver_command_omits_unset_corporate_proxy_settings() { + let mut command = tokio::process::Command::new("openshell-driver-vm"); + append_upstream_proxy_args(&mut command, &VmComputeConfig::default()); + assert_eq!(command.as_std().get_args().count(), 0); + } + + #[test] + fn invalid_corporate_proxy_config_is_rejected_before_the_driver_starts() { + // Without this the operator would see an opaque driver-readiness + // timeout instead of an error naming the offending key. + let err = VmComputeConfig { + https_proxy: Some("socks5://proxy.corp.com:1080".to_string()), + ..VmComputeConfig::default() + } + .validate_proxy_config() + .expect_err("only http:// and https:// proxies are supported"); + assert!(err.to_string().contains("https_proxy"), "{err}"); + + let err = VmComputeConfig { + proxy_ca_bundle: Some("/etc/openshell/tls/proxy-ca.pem".to_string()), + ..VmComputeConfig::default() + } + .validate_proxy_config() + .expect_err("a CA bundle without a proxy URL would hide a fail-open state"); + assert!(err.to_string().contains("proxy_ca_bundle"), "{err}"); + + VmComputeConfig { + https_proxy: Some("http://proxy.corp.com:8080".to_string()), + ..VmComputeConfig::default() + } + .validate_proxy_config() + .expect("a lone proxy URL is a complete configuration"); + } + #[test] fn resolve_driver_bin_uses_driver_dir_when_binary_present() { let dir = tempdir().unwrap(); diff --git a/crates/openshell-supervisor-network/src/upstream_proxy.rs b/crates/openshell-supervisor-network/src/upstream_proxy.rs index 253fa52263..f95c5a3e81 100644 --- a/crates/openshell-supervisor-network/src/upstream_proxy.rs +++ b/crates/openshell-supervisor-network/src/upstream_proxy.rs @@ -612,31 +612,12 @@ fn parse_proxy_url(raw: &str, var_name: &str) -> Result<(ProxyEndpoint, bool), S /// fall-back to the built-in roots that would quietly weaken the trust /// boundary. The error names `var_name` so the operator can locate the setting. pub(crate) fn read_proxy_ca_bundle(path: &str, var_name: &str) -> Result { - let pem = std::fs::read_to_string(path) - .map_err(|err| format!("{var_name} '{path}' could not be read: {err}"))?; - // Validate that the bundle contributes at least one trust anchor that - // rustls actually accepts, not just that PEM framing base64-decodes. - // A PEM block with invalid DER passes `rustls_pemfile::certs` but is - // silently rejected by `RootCertStore::add_parsable_certificates`; - // counting only PEM blocks would let such a bundle satisfy the check - // while contributing zero usable anchors at runtime. - let certs: Vec<_> = rustls_pemfile::certs(&mut pem.as_bytes()) - .flatten() - .collect(); - if certs.is_empty() { - return Err(format!( - "{var_name} '{path}' contains no PEM certificate blocks" - )); - } - let mut store = rustls::RootCertStore::empty(); - let (added, _ignored) = store.add_parsable_certificates(certs); - if added == 0 { - return Err(format!( - "{var_name} '{path}' contains no usable trust anchors \ - (PEM blocks were found but none contain valid X.509 DER)" - )); - } - Ok(pem) + // Shared with the compute driver, which validates the same file on the + // gateway host before staging it, so host acceptance and guest acceptance + // cannot diverge. It also bounds the read: the file arrives from the + // driver, but a bundle the size of the sandbox disk should fail rather + // than be loaded whole. + openshell_core::driver_utils::read_upstream_proxy_ca_bundle_file(path, var_name) } /// Build the TLS client config used to connect to an `https://` corporate diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index c04c0040d0..7553e7deb4 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -796,6 +796,50 @@ guest_tls_key = "/var/lib/openshell/guest-tls/client-key.pem" # Defaults to 10001 when unset; matching GID is used if sandbox_gid is empty. # Any non-root Linux UID/GID is valid. # sandbox_uid = 20001 +# Corporate forward proxy for sandbox egress. The keys, their semantics, and +# the fail-closed contract are identical to the Podman driver above: only TLS +# (CONNECT) egress is chained, plain-HTTP destination requests always dial +# directly, credentials must come from proxy_auth_file rather than the URL, +# an http:// proxy with credentials requires proxy_auth_allow_insecure, and +# any present-but-invalid value is rejected at gateway startup rather than +# degrading to a direct dial. proxy_auth_file and proxy_ca_bundle are paths on +# the gateway host. +# +# The sandbox cannot select or override these settings. They reach the guest +# supervisor on its command line through a per-sandbox file the driver writes +# into the overlay upperdir on every launch, so a sandbox image cannot supply +# its own values or disable the operator's by baking a file at that path. +# +# Reachability: a proxy on the corporate network needs no special address and +# works on every VM sandbox. The guest's callback to the gateway is unaffected +# and never traverses the proxy. +# +# A proxy on the gateway host itself is reachable only from libkrun-backed +# (non-GPU) sandboxes: their egress leaves through gvproxy, which NATs +# 192.168.127.254 to the host's 127.0.0.1, so address it as +# http://host.openshell.internal: rather than http://127.0.0.1:. +# GPU sandboxes run on the QEMU/TAP backend, which has no such NAT — +# host.openshell.internal resolves to the TAP host address, and the driver's +# nftables rules let the guest reach only the gateway port on the host. A +# gateway-host proxy URL is therefore rejected when the sandbox launches on +# QEMU, rather than timing out on every CONNECT; give GPU sandboxes a proxy +# address routable from the guest's masqueraded egress. +# +# Because a microVM has no bind mounts or container secrets, the driver stages +# the credential and the CA into the per-sandbox overlay disk: the credential +# root-only inside the guest, and both removed with the sandbox. The +# credential is therefore at rest in that overlay image on the gateway host — +# the same delivery the per-sandbox gateway token already uses, and a +# difference from the Podman secret model worth noting when choosing where to +# keep proxy credentials. +# https_proxy = "http://host.openshell.internal:8080" +# no_proxy = "10.0.0.0/8,.internal.example" +# proxy_auth_file = "/etc/openshell/secrets/proxy-auth" +# proxy_auth_allow_insecure = true +# Last resort for hostname-filtering proxy ACLs; see the Podman section above. +# proxy_connect_by_hostname = true +# Corporate CA trusted for an https:// proxy and TLS-intercepting proxies. +# proxy_ca_bundle = "/etc/openshell/tls/proxy-ca.pem" ``` ### Extension Driver diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index f00c49afd6..987e66b0d9 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -360,6 +360,18 @@ The VM driver creates nftables rules on the host for each sandbox VM's TAP netwo On hosts with restrictive firewalls (e.g. firewalld), the host firewall may additionally block VM traffic that the driver's rules accept. If VM sandboxes cannot reach the network, verify that the host firewall allows forwarding and input for `vmtap-*` interfaces. See the [VM driver README](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-driver-vm/README.md#host-side-nftables-rules) for details. +### Corporate Proxy Egress + +For proxy-required networks, the VM driver accepts the same corporate egress proxy keys as the Podman driver: `https_proxy`, `no_proxy`, `proxy_auth_file`, `proxy_auth_allow_insecure`, `proxy_connect_by_hostname`, and `proxy_ca_bundle`. The in-guest supervisor chains policy-approved TLS tunnels through the proxy with HTTP CONNECT instead of dialing destinations directly. + +The settings reach the guest supervisor on its command line through a per-sandbox argument file the driver writes into the overlay upperdir on every launch, so a sandbox cannot select, alter, or disable the proxy from inside the guest — including through image `ENV`, the sandbox environment, or files baked into the image at the paths the driver uses. + +A proxy on the corporate network needs no special address and works on every VM sandbox. The guest's callback to the gateway never traverses the proxy. + +A proxy on the gateway host itself works only for libkrun-backed (non-GPU) sandboxes, whose egress leaves through gvproxy: configure `https_proxy = "http://host.openshell.internal:"` rather than a `127.0.0.1` URL, because gvproxy NATs that alias to the host's `127.0.0.1`. GPU sandboxes use the QEMU/TAP backend, where `host.openshell.internal` resolves to the TAP host address and the driver's [host firewall rules](#host-firewall) allow the guest to reach only the gateway port on the host. The driver rejects a gateway-host proxy URL when a sandbox launches on QEMU instead of letting every CONNECT time out, so give GPU sandboxes a proxy address routable from the guest's masqueraded egress. + +Because a microVM has no bind mounts or container secrets, the driver stages the credential (root-only) and the CA bundle into the per-sandbox overlay disk and removes them with the sandbox. See the [Gateway Configuration File](./gateway-config) reference for the full contract, including the cleartext-credential acknowledgement and the validated-IP CONNECT behavior. + ## Kubernetes Driver Kubernetes-backed sandboxes run as pods in the configured sandbox namespace. Use Kubernetes for shared clusters, remote compute, GPU scheduling, and operator-managed environments. diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 6dbf46a392..18556d0f7b 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -108,6 +108,11 @@ name = "vm_gateway_start" path = "tests/vm_gateway_start.rs" required-features = ["e2e-vm"] +[[test]] +name = "vm_corporate_proxy" +path = "tests/vm_corporate_proxy.rs" +required-features = ["e2e-vm"] + [[test]] name = "provider_token_exchange" path = "tests/provider_token_exchange.rs" diff --git a/e2e/rust/e2e-vm.sh b/e2e/rust/e2e-vm.sh index 9acc633d65..96da2a879f 100755 --- a/e2e/rust/e2e-vm.sh +++ b/e2e/rust/e2e-vm.sh @@ -409,4 +409,5 @@ else run_e2e_test host_gateway_alias run_e2e_test vm_overlay run_e2e_test vm_gateway_start + run_e2e_test vm_corporate_proxy fi diff --git a/e2e/rust/src/harness/host_process.rs b/e2e/rust/src/harness/host_process.rs new file mode 100644 index 0000000000..f9fefd75a6 --- /dev/null +++ b/e2e/rust/src/harness/host_process.rs @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Host-process TCP fixtures for e2e tests. +//! +//! [`HostSupportContainer`](super::container::HostSupportContainer) publishes +//! the same shape of fixture through a container engine. VM sandboxes reach +//! the host through gvproxy's `host.openshell.internal` alias and the VM e2e +//! lane has no container runtime of its own, so this variant runs the fixture +//! as a plain host process instead — keeping the lane free of a container +//! dependency it does not otherwise need. + +use std::io::Read as _; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +use super::port::wait_for_port; + +/// A `python3` fixture listening on a host TCP port. +/// +/// Output is captured to a temp file rather than a pipe: these fixtures log +/// every request they serve, and a full pipe buffer would block the process +/// mid-test. [`logs`](Self::logs) reads the file, which is where a test finds +/// its evidence (the CONNECT targets a proxy saw, for example). +pub struct HostPythonFixture { + /// Host port the fixture listens on. + pub port: u16, + child: Child, + log_path: PathBuf, +} + +impl HostPythonFixture { + /// Start `python3 -c